feat: 静态页面开发完成

This commit is contained in:
2026-05-21 14:09:46 +08:00
parent 8156e8efbf
commit 763aef6bb9
73 changed files with 6345 additions and 1370 deletions

View File

@@ -0,0 +1,331 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import '../theme.dart';
class ChartSpec {
final String type;
final String? title;
final List<String> labels;
final List<double> data;
const ChartSpec({
required this.type,
required this.labels,
required this.data,
this.title,
});
static ChartSpec? tryParse(String raw) {
final lines = raw.split('\n').where((l) => l.trim().isNotEmpty);
final map = <String, String>{};
for (final line in lines) {
final colon = line.indexOf(':');
if (colon < 0) continue;
map[line.substring(0, colon).trim().toLowerCase()] =
line.substring(colon + 1).trim();
}
final type = (map['type'] ?? 'bar').toLowerCase();
final labels = (map['labels'] ?? '')
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
final data = (map['data'] ?? '')
.split(',')
.map((s) => double.tryParse(s.trim()))
.whereType<double>()
.toList();
if (labels.isEmpty || data.isEmpty || labels.length != data.length) {
return null;
}
return ChartSpec(type: type, title: map['title'], labels: labels, data: data);
}
}
class ChartBlock extends StatelessWidget {
final ChartSpec spec;
const ChartBlock({super.key, required this.spec});
static const _palette = <Color>[
AppColors.primaryLight,
AppColors.accent,
AppColors.warning,
AppColors.error,
AppColors.success,
AppColors.primaryDark,
AppColors.accentLight,
];
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (spec.title != null && spec.title!.isNotEmpty) ...[
Text(
spec.title!,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
],
SizedBox(height: 180, child: _buildChart()),
if (spec.type == 'pie') ...[
const SizedBox(height: 8),
_buildLegend(),
],
],
),
);
}
Widget _buildChart() {
switch (spec.type) {
case 'line':
return _scrollable(_buildLine());
case 'pie':
return _buildPie();
case 'bar':
default:
return _scrollable(_buildBar());
}
}
Widget _scrollable(Widget chart) {
const perPoint = 56.0;
final intrinsicWidth = spec.data.length * perPoint;
return LayoutBuilder(
builder: (context, constraints) {
final width = intrinsicWidth > constraints.maxWidth
? intrinsicWidth
: constraints.maxWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: SizedBox(width: width, child: chart),
);
},
);
}
Widget _buildBar() {
final maxY = spec.data.reduce((a, b) => a > b ? a : b);
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxY * 1.2,
barTouchData: BarTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
FlLine(color: AppColors.divider, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (v, _) => Text(
v.toInt().toString(),
style: TextStyle(fontSize: 10, color: AppColors.textTertiary),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
getTitlesWidget: (v, _) {
final idx = v.toInt();
if (idx < 0 || idx >= spec.labels.length) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
spec.labels[idx],
style:
TextStyle(fontSize: 10, color: AppColors.textSecondary),
),
);
},
),
),
),
barGroups: [
for (var i = 0; i < spec.data.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: spec.data[i],
color: _palette[i % _palette.length],
width: 18,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
),
],
),
],
),
);
}
Widget _buildLine() {
final maxY = spec.data.reduce((a, b) => a > b ? a : b);
final minY = spec.data.reduce((a, b) => a < b ? a : b);
final pad = (maxY - minY).abs() * 0.2 + 1;
return LineChart(
LineChartData(
minY: minY - pad,
maxY: maxY + pad,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) =>
FlLine(color: AppColors.divider, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (v, _) => Text(
v.toInt().toString(),
style: TextStyle(fontSize: 10, color: AppColors.textTertiary),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
getTitlesWidget: (v, _) {
final idx = v.toInt();
if (idx < 0 || idx >= spec.labels.length) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
spec.labels[idx],
style:
TextStyle(fontSize: 10, color: AppColors.textSecondary),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: [
for (var i = 0; i < spec.data.length; i++)
FlSpot(i.toDouble(), spec.data[i]),
],
isCurved: true,
color: AppColors.primaryLight,
barWidth: 2.5,
dotData: FlDotData(
show: true,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 3,
color: AppColors.primaryLight,
strokeWidth: 1.5,
strokeColor: AppColors.surface,
),
),
belowBarData: BarAreaData(
show: true,
color: AppColors.primaryLight.withOpacity(0.15),
),
),
],
),
);
}
Widget _buildPie() {
final total = spec.data.fold<double>(0, (a, b) => a + b);
return PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 28,
sections: [
for (var i = 0; i < spec.data.length; i++)
PieChartSectionData(
value: spec.data[i],
color: _palette[i % _palette.length],
radius: 50,
title: total == 0
? ''
: '${(spec.data[i] / total * 100).toStringAsFixed(0)}%',
titleStyle: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildLegend() {
return Wrap(
spacing: 12,
runSpacing: 4,
children: [
for (var i = 0; i < spec.labels.length; i++)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _palette[i % _palette.length],
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 4),
Text(
'${spec.labels[i]} ${spec.data[i].toInt()}',
style: TextStyle(fontSize: 11, color: AppColors.textSecondary),
),
],
),
],
);
}
}
class ChartCodeBuilder extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
final lang = element.attributes['class'] ?? '';
if (!lang.contains('language-chart')) return null;
final raw = element.textContent;
final spec = ChartSpec.tryParse(raw);
if (spec == null) return null;
return ChartBlock(spec: spec);
}
}

View File

@@ -0,0 +1,308 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../theme.dart';
class MarkdownMessage extends StatelessWidget {
final String data;
final MarkdownStyleSheet styleSheet;
final Map<String, MarkdownElementBuilder> builders;
const MarkdownMessage({
super.key,
required this.data,
required this.styleSheet,
this.builders = const {},
});
@override
Widget build(BuildContext context) {
final segments = _split(data);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final seg in segments)
if (seg.isTable)
_ScrollableTable(rows: seg.rows!, styleSheet: styleSheet)
else
MarkdownBody(
data: seg.text,
styleSheet: styleSheet,
builders: builders,
imageBuilder: (uri, title, alt) =>
_MarkdownImage(uri: uri, alt: alt ?? title),
shrinkWrap: true,
),
],
);
}
static final _tableSep = RegExp(r'^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$');
static final _tableRow = RegExp(r'^\s*\|.*\|\s*$');
List<_Segment> _split(String src) {
final lines = src.split('\n');
final result = <_Segment>[];
final buffer = <String>[];
void flushText() {
if (buffer.isEmpty) return;
final text = buffer.join('\n').trim();
if (text.isNotEmpty) result.add(_Segment.text(text));
buffer.clear();
}
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
final isHeader = _tableRow.hasMatch(line);
final next = i + 1 < lines.length ? lines[i + 1] : '';
if (isHeader && _tableSep.hasMatch(next)) {
flushText();
final rows = <List<String>>[];
rows.add(_cells(line));
i += 2;
while (i < lines.length && _tableRow.hasMatch(lines[i])) {
rows.add(_cells(lines[i]));
i++;
}
i--;
result.add(_Segment.table(rows));
} else {
buffer.add(line);
}
}
flushText();
return result;
}
List<String> _cells(String line) {
var trimmed = line.trim();
if (trimmed.startsWith('|')) trimmed = trimmed.substring(1);
if (trimmed.endsWith('|')) trimmed = trimmed.substring(0, trimmed.length - 1);
return trimmed.split('|').map((c) => c.trim()).toList();
}
}
class _Segment {
final String text;
final List<List<String>>? rows;
bool get isTable => rows != null;
_Segment.text(this.text) : rows = null;
_Segment.table(this.rows) : text = '';
}
class _MarkdownImage extends StatelessWidget {
final Uri uri;
final String? alt;
const _MarkdownImage({required this.uri, this.alt});
@override
Widget build(BuildContext context) {
final url = uri.toString();
final tag = 'md-img:$url';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context, rootNavigator: true).push(
PageRouteBuilder(
opaque: false,
barrierColor: Colors.black,
transitionDuration: const Duration(milliseconds: 240),
pageBuilder: (_, __, ___) =>
_ImageViewer(url: url, heroTag: tag, alt: alt),
),
);
},
child: Hero(
tag: tag,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
width: double.infinity,
placeholder: (context, _) => AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: AppColors.divider.withOpacity(0.3),
alignment: Alignment.center,
child: const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (context, _, __) => AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: AppColors.divider.withOpacity(0.3),
alignment: Alignment.center,
child: Icon(
Icons.broken_image_outlined,
color: AppColors.textTertiary,
size: 32,
),
),
),
),
),
),
),
);
}
}
class _ImageViewer extends StatelessWidget {
final String url;
final String heroTag;
final String? alt;
const _ImageViewer({required this.url, required this.heroTag, this.alt});
void _close(BuildContext context) {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).maybePop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: () => _close(context),
behavior: HitTestBehavior.opaque,
child: SafeArea(
child: Stack(
children: [
Center(
child: Hero(
tag: heroTag,
child: InteractiveViewer(
minScale: 1,
maxScale: 4,
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.contain,
placeholder: (context, _) => const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
),
errorWidget: (context, _, __) => const Icon(
Icons.broken_image_outlined,
color: Colors.white54,
size: 64,
),
),
),
),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => _close(context),
),
),
if (alt != null && alt!.isNotEmpty)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Text(
alt!,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
),
],
),
),
),
);
}
}
class _ScrollableTable extends StatelessWidget {
final List<List<String>> rows;
final MarkdownStyleSheet styleSheet;
const _ScrollableTable({required this.rows, required this.styleSheet});
@override
Widget build(BuildContext context) {
if (rows.isEmpty) return const SizedBox.shrink();
final header = rows.first;
final body = rows.skip(1).toList();
final cols = header.length;
final padding = styleSheet.tableCellsPadding ?? const EdgeInsets.all(8);
final headStyle = styleSheet.tableHead ??
const TextStyle(fontWeight: FontWeight.w600);
final bodyStyle = styleSheet.tableBody ?? const TextStyle();
final border = styleSheet.tableBorder ??
TableBorder.all(color: AppColors.divider, width: 1);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: border,
children: [
TableRow(
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.06),
),
children: [
for (var c = 0; c < cols; c++)
Padding(
padding: padding,
child: Text(
c < header.length ? header[c] : '',
style: headStyle,
),
),
],
),
for (final row in body)
TableRow(
children: [
for (var c = 0; c < cols; c++)
Padding(
padding: padding,
child: Text(
c < row.length ? row[c] : '',
style: bodyStyle,
),
),
],
),
],
),
),
);
},
),
);
}
}

310
lib/widgets/skeleton.dart Normal file
View File

@@ -0,0 +1,310 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../theme.dart';
class SkeletonBox extends StatelessWidget {
final double? width;
final double height;
final double radius;
const SkeletonBox({
super.key,
this.width,
required this.height,
this.radius = 6,
});
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(radius),
),
);
}
}
class SkeletonShimmer extends StatelessWidget {
final Widget child;
const SkeletonShimmer({super.key, required this.child});
@override
Widget build(BuildContext context) {
final base = AppColors.isDark
? const Color(0xFF334155)
: const Color(0xFFE2E8F0);
final highlight = AppColors.isDark
? const Color(0xFF475569)
: const Color(0xFFF1F5F9);
return Shimmer.fromColors(
baseColor: base,
highlightColor: highlight,
period: const Duration(milliseconds: 1400),
child: child,
);
}
}
class EventCardSkeleton extends StatelessWidget {
const EventCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: SkeletonShimmer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SkeletonBox(width: 44, height: 44, radius: 12),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Expanded(child: SkeletonBox(height: 16, radius: 6)),
SizedBox(width: 8),
SkeletonBox(width: 48, height: 18, radius: 10),
],
),
const SizedBox(height: 10),
const SkeletonBox(height: 12, radius: 4),
const SizedBox(height: 6),
const SkeletonBox(width: 180, height: 12, radius: 4),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: const [
SkeletonBox(width: 160, height: 12, radius: 4),
],
),
),
],
),
),
);
}
}
class WorkOrderCardSkeleton extends StatelessWidget {
const WorkOrderCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: SkeletonShimmer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Expanded(child: SkeletonBox(height: 16, radius: 6)),
SizedBox(width: 8),
SkeletonBox(width: 56, height: 18, radius: 10),
],
),
const SizedBox(height: 12),
const SkeletonBox(height: 12, radius: 4),
const SizedBox(height: 6),
const SkeletonBox(width: 220, height: 12, radius: 4),
const SizedBox(height: 14),
Row(
children: const [
SkeletonBox(width: 80, height: 12, radius: 4),
SizedBox(width: 12),
SkeletonBox(width: 100, height: 12, radius: 4),
Spacer(),
SkeletonBox(width: 36, height: 16, radius: 4),
],
),
],
),
),
);
}
}
class OrderCardSkeleton extends StatelessWidget {
const OrderCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: SkeletonShimmer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
SkeletonBox(width: 140, height: 14, radius: 4),
Spacer(),
SkeletonBox(width: 56, height: 18, radius: 10),
],
),
const SizedBox(height: 14),
const SkeletonBox(height: 15, width: 200, radius: 4),
const SizedBox(height: 10),
Row(
children: const [
SkeletonBox(width: 90, height: 12, radius: 4),
SizedBox(width: 16),
SkeletonBox(width: 50, height: 12, radius: 4),
],
),
const SizedBox(height: 14),
Row(
children: const [
SkeletonBox(width: 100, height: 12, radius: 4),
Spacer(),
SkeletonBox(width: 70, height: 16, radius: 4),
],
),
],
),
),
);
}
}
class DetailPageSkeleton extends StatelessWidget {
const DetailPageSkeleton({super.key});
@override
Widget build(BuildContext context) {
return SkeletonShimmer(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonBox(width: 100, height: 14, radius: 4),
SizedBox(height: 12),
SkeletonBox(height: 22, radius: 6),
SizedBox(height: 10),
SkeletonBox(width: 220, height: 14, radius: 4),
],
),
),
const SizedBox(height: 20),
_block(height: 130),
const SizedBox(height: 20),
_block(height: 130),
const SizedBox(height: 20),
_block(height: 100),
],
),
),
);
}
Widget _block({required double height}) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonBox(width: 80, height: 14, radius: 4),
SizedBox(height: 14),
SkeletonBox(height: 14, radius: 4),
SizedBox(height: 8),
SkeletonBox(width: 200, height: 14, radius: 4),
],
),
),
);
}
}
class SkeletonList extends StatelessWidget {
final WidgetBuilder itemBuilder;
final int count;
final EdgeInsetsGeometry padding;
const SkeletonList({
super.key,
required this.itemBuilder,
this.count = 5,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: padding,
itemCount: count,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, _) => itemBuilder(context),
);
}
}