feat(apps): establish shared mobile design system

This commit is contained in:
2026-08-10 23:31:44 +08:00
parent 7242048abf
commit a2383fc8a1
34 changed files with 2007 additions and 751 deletions

View File

@@ -7,6 +7,7 @@ import '../../../app/dependencies.dart';
import '../../../data/offline/encrypted_draft_store.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
import '../../core/widgets.dart';
class WorkDetailPage extends StatefulWidget {
const WorkDetailPage({
@@ -173,150 +174,183 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
future: _future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
if (snapshot.hasError) {
return MessageState(
title: '任务详情加载失败',
description: '请检查网络后重新加载',
onRetry: () => setState(() => _future = _load()),
);
}
return const Center(child: CircularProgressIndicator());
}
final item = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(18),
children: [
Card(
color: Theme.of(context).colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(22),
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.number,
style: const TextStyle(color: Colors.white70),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 8),
Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w900,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 12),
Chip(label: Text(statusName(item.status))),
const SizedBox(height: 16),
StatusPill(label: statusName(item.status), tone: StatusTone.info),
],
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('服务地址'),
subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address),
const SizedBox(height: 28),
const SectionHeader(title: '服务信息'),
const SizedBox(height: 12),
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('服务地址'),
subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address),
),
const Divider(indent: 56),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(item.raw['contact_name'] as String? ?? '服务用户'),
subtitle: Text(item.raw['contact_phone'] as String? ?? '未提供联系电话'),
),
],
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.person_outline),
title: Text(item.raw['contact_name'] as String? ?? '服务用户'),
subtitle: Text(item.raw['contact_phone'] as String? ?? ''),
),
),
const SizedBox(height: 18),
if (item.allowedActions.contains('start'))
ElevatedButton(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.start(
value,
widget.session.roleCode,
const SizedBox(height: 28),
const SectionHeader(title: '任务操作', description: '操作结果以服务端状态与现场凭证为准'),
const SizedBox(height: 12),
if (item.allowedActions.isEmpty) const SurfaceSection(child: Text('当前状态没有可执行操作')),
if (item.allowedActions.contains('start'))
FilledButton.icon(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.start(value, widget.session.roleCode),
item,
),
item,
),
child: const Text('开始处理'),
),
if (item.allowedActions.contains('append_tracks'))
OutlinedButton(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.appendCurrentTrack(
value.identity,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('开始处理'),
),
if (item.allowedActions.contains('append_tracks')) ...[
if (item.allowedActions.contains('start')) const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.appendCurrentTrack(value.identity),
item,
),
item,
),
child: const Text('上报当前位置'),
),
if (item.allowedActions.contains('arrive'))
ElevatedButton(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.arrive(value.identity),
item,
),
child: const Text('到达并校验围栏'),
),
if (item.allowedActions.contains('submit_receipt'))
ElevatedButton(
onPressed: _busy ? null : () => _receipt(item),
child: const Text('拍摄签收凭证并提交'),
),
if (item.allowedActions.contains('submit_result'))
ElevatedButton(
onPressed: _busy
? null
: () async {
final changed = await context.push<bool>(
'/tasks/${item.identity}/evidence',
);
if (changed == true) setState(() => _future = _load());
},
child: const Text('现场取证与提交'),
),
if (item.allowedActions.contains('exception'))
TextButton(
onPressed: _busy
? null
: () async {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) => widget.repository.exception(
value,
widget.session.roleCode,
reason,
),
item,
icon: const Icon(Icons.my_location_outlined),
label: const Text('上报当前位置'),
),
],
if (item.allowedActions.contains('arrive')) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _busy
? null
: () => _run((value) => widget.repository.arrive(value.identity), item),
icon: const Icon(Icons.location_on_outlined),
label: const Text('到达并校验围栏'),
),
],
if (item.allowedActions.contains('submit_receipt')) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _busy ? null : () => _receipt(item),
icon: const Icon(Icons.photo_camera_outlined),
label: const Text('拍摄签收凭证并提交'),
),
],
if (item.allowedActions.contains('submit_result')) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _busy
? null
: () async {
final changed = await context.push<bool>(
'/tasks/${item.identity}/evidence',
);
}
},
child: const Text('标记异常'),
),
if (item.allowedActions.contains('recover'))
ElevatedButton(
onPressed: _busy
? null
: () async {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) => widget.repository.recover(
value,
widget.session.roleCode,
reason,
),
item,
);
}
},
child: const Text('恢复任务'),
),
if (_busy)
const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
],
if (changed == true) setState(() => _future = _load());
},
icon: const Icon(Icons.fact_check_outlined),
label: const Text('现场取证与提交'),
),
],
if (item.allowedActions.contains('recover')) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _busy
? null
: () async {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) =>
widget.repository.recover(value, widget.session.roleCode, reason),
item,
);
}
},
icon: const Icon(Icons.restart_alt_rounded),
label: const Text('恢复任务'),
),
],
if (item.allowedActions.contains('exception')) ...[
const SizedBox(height: 20),
TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
onPressed: _busy
? null
: () async {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) => widget.repository.exception(
value,
widget.session.roleCode,
reason,
),
item,
);
}
},
icon: const Icon(Icons.report_problem_outlined),
label: const Text('标记异常'),
),
],
if (_busy)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Semantics(
liveRegion: true,
label: '正在处理任务操作',
child: const LinearProgressIndicator(),
),
),
],
),
);
},
),

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
import '../../core/widgets.dart';
import 'work_list_view_model.dart';
class WorkListPage extends StatefulWidget {
@@ -53,59 +54,88 @@ class _WorkListPageState extends State<WorkListPage> {
return const Center(child: CircularProgressIndicator());
}
if (_viewModel.error != null && _viewModel.items.isEmpty) {
return Center(child: Text(_viewModel.error.toString()));
return MessageState(
title: '任务加载失败',
description: '请检查网络后重新加载',
onRetry: _viewModel.load,
);
}
return RefreshIndicator(
onRefresh: _viewModel.load,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
Card(
color: widget.session.roleCode == 'operations'
? const Color(0xFFE9F8F0)
: const Color(0xFFEEEAFE),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
widget.session.roleCode == 'delivery'
? Icons.local_shipping
? Icons.local_shipping_outlined
: widget.session.roleCode == 'installer'
? Icons.handyman
: Icons.health_and_safety,
size: 38,
? Icons.handyman_outlined
: Icons.health_and_safety_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 14),
Expanded(
child: Text(
widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.completed ? '作业历史' : '${roleName(widget.session.roleCode)}任务',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
),
const SizedBox(height: 28),
SectionHeader(
title: widget.completed ? '全部记录' : '待处理任务',
description: _viewModel.items.isEmpty
? '当前没有需要展示的任务'
: '${_viewModel.items.length}',
),
const SizedBox(height: 12),
if (_viewModel.items.isEmpty)
const Padding(
padding: EdgeInsets.all(42),
child: Center(child: Text('暂无任务')),
)
const MessageState(title: '暂无任务', description: '新的服务端分派任务会显示在这里')
else
..._viewModel.items.map(
(item) => Card(
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('${item.number}\n${item.address}'),
),
trailing: Chip(label: Text(statusName(item.status))),
onTap: () => context.push('/tasks/${item.identity}'),
),
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var index = 0; index < _viewModel.items.length; index++) ...[
_TaskRow(
item: _viewModel.items[index],
onTap: () => context.push('/tasks/${_viewModel.items[index].identity}'),
),
if (index < _viewModel.items.length - 1)
const Divider(indent: 16, endIndent: 16),
],
],
),
),
],
@@ -115,3 +145,28 @@ class _WorkListPageState extends State<WorkListPage> {
),
);
}
class _TaskRow extends StatelessWidget {
const _TaskRow({required this.item, required this.onTap});
final WorkItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => ListTile(
minTileHeight: 88,
title: Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis),
titleTextStyle: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'${item.number}${item.address.isEmpty ? '' : ' · ${item.address}'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
trailing: StatusPill(label: statusName(item.status), tone: StatusTone.info),
onTap: onTap,
);
}