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

@@ -16,6 +16,7 @@ class _LoginPageState extends State<LoginPage> {
final _password = TextEditingController();
bool _busy = false;
String? _error;
bool _obscurePassword = true;
Future<void> _login() async {
setState(() {
@@ -43,28 +44,43 @@ class _LoginPageState extends State<LoginPage> {
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.engineering_rounded,
size: 72,
color: Theme.of(context).colorScheme.primary,
Align(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
Icons.engineering_rounded,
size: 38,
color: Theme.of(context).colorScheme.primary,
semanticLabel: '服务工作台标识',
),
),
),
const SizedBox(height: 20),
const SizedBox(height: 24),
Text(
'瓶安芯服务工作台',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900),
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
const Text('配送、安装维修与安检共用入口', textAlign: TextAlign.center),
const SizedBox(height: 34),
Text(
'配送、安装维修与安检共用入口',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 40),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
@@ -76,22 +92,50 @@ class _LoginPageState extends State<LoginPage> {
const SizedBox(height: 14),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(
obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
labelText: '登录密码',
prefixIcon: Icon(Icons.lock_outline),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
tooltip: _obscurePassword ? '显示密码' : '隐藏密码',
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
child: Semantics(
liveRegion: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline,
size: 20,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
),
),
const SizedBox(height: 20),
ElevatedButton(
const SizedBox(height: 24),
FilledButton(
onPressed: _busy ? null : _login,
child: _busy
? const SizedBox.square(
@@ -100,11 +144,13 @@ class _LoginPageState extends State<LoginPage> {
)
: const Text('进入工作台'),
),
const SizedBox(height: 12),
const Text(
const SizedBox(height: 16),
Text(
'账号与角色由平台审核分配,不提供自助注册',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),

View File

@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
import '../../../app/dependencies.dart';
import '../../../data/offline/encrypted_draft_store.dart';
import '../../../data/repositories/service_repository.dart';
import '../../core/widgets.dart';
class EvidencePage extends StatefulWidget {
const EvidencePage({
@@ -160,66 +161,108 @@ class _EvidencePageState extends State<EvidencePage> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('现场取证')),
body: ListView(
padding: const EdgeInsets.all(18),
children: [
const Card(
child: Padding(
padding: EdgeInsets.all(18),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.lock_outline),
SizedBox(width: 12),
Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')),
Icon(Icons.lock_outline, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
const Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')),
],
),
),
),
const SizedBox(height: 10),
..._requiredStages.map(
(stage) => Card(
child: ListTile(
leading: Icon(
_evidence.containsKey(stage) ? Icons.check_circle : Icons.camera_alt_outlined,
color: _evidence.containsKey(stage) ? Colors.green : null,
),
title: Text(_stageName(stage)),
subtitle: Text(_evidence.containsKey(stage) ? '已加密暂存' : '尚未采集'),
trailing: TextButton(
onPressed: _busy ? null : () => _capture(stage),
child: Text(_evidence.containsKey(stage) ? '重拍' : '拍摄'),
),
const SizedBox(height: 28),
SectionHeader(
title: '取证进度',
description: '已完成 ${_evidence.length}/${_requiredStages.length} 项必需材料',
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: _requiredStages.isEmpty ? 0 : _evidence.length / _requiredStages.length,
minHeight: 6,
borderRadius: BorderRadius.circular(3),
),
const SizedBox(height: 16),
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var index = 0; index < _requiredStages.length; index++) ...[
ListTile(
minTileHeight: 68,
leading: Icon(
_evidence.containsKey(_requiredStages[index])
? Icons.check_circle_outline
: Icons.camera_alt_outlined,
color: _evidence.containsKey(_requiredStages[index])
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(_stageName(_requiredStages[index])),
subtitle: Text(
_evidence.containsKey(_requiredStages[index]) ? '已加密暂存' : '尚未采集',
),
trailing: TextButton(
onPressed: _busy ? null : () => _capture(_requiredStages[index]),
child: Text(_evidence.containsKey(_requiredStages[index]) ? '重拍' : '拍摄'),
),
),
if (index < _requiredStages.length - 1) const Divider(indent: 56),
],
],
),
),
),
const SizedBox(height: 12),
TextField(
controller: _result,
maxLines: 4,
onChanged: (_) => _save(),
decoration: const InputDecoration(labelText: '现场结果说明'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _conclusion,
decoration: const InputDecoration(labelText: '结论'),
items: const [
DropdownMenuItem(value: 'qualified', child: Text('合格')),
DropdownMenuItem(value: 'noncompliant', child: Text('不合格')),
DropdownMenuItem(value: 'high_risk', child: Text('高风险')),
],
onChanged: (value) {
if (value == null) return;
setState(() => _conclusion = value);
_save();
},
),
const SizedBox(height: 22),
ElevatedButton(
onPressed: _busy ? null : _submit,
child: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'),
),
],
const SizedBox(height: 28),
const SectionHeader(title: '作业结论', description: '说明现场处理结果,并选择对应结论'),
const SizedBox(height: 12),
TextField(
controller: _result,
maxLines: 4,
minLines: 3,
onChanged: (_) => _save(),
decoration: const InputDecoration(
labelText: '现场结果说明',
helperText: '请填写处理过程、现场状态和后续建议',
alignLabelWithHint: true,
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _conclusion,
decoration: const InputDecoration(labelText: '结论'),
items: const [
DropdownMenuItem(value: 'qualified', child: Text('合格')),
DropdownMenuItem(value: 'noncompliant', child: Text('不合格')),
DropdownMenuItem(value: 'high_risk', child: Text('高风险')),
],
onChanged: (value) {
if (value == null) return;
setState(() => _conclusion = value);
_save();
},
),
const SizedBox(height: 28),
FilledButton.icon(
onPressed: _busy ? null : _submit,
icon: _busy
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_upload_outlined),
label: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'),
),
],
),
),
);

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';
class PreflightPage extends StatefulWidget {
const PreflightPage({required this.session, required this.repository, super.key});
@@ -46,84 +47,60 @@ class _PreflightPageState extends State<PreflightPage> {
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 = widget.repository.preflight()),
);
}
return const Center(child: CircularProgressIndicator());
}
final result = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(18),
children: [
Card(
color: Theme.of(context).colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(22),
final entries = result.checks.entries.toList();
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
_PreflightBanner(result: result),
const SizedBox(height: 28),
SectionHeader(
title: '准入检查',
description: result.canWork ? '所有必需条件均已通过服务端校验' : '完成阻断项后才能进入工作台',
),
const SizedBox(height: 12),
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(roleName(result.roleCode), style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 6),
Text(
result.canWork ? '可以开始今日作业' : '仍有前置条件未完成',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 24,
),
),
for (var index = 0; index < entries.length; index++) ...[
_CheckRow(entry: entries[index], label: _label(entries[index].key)),
if (index < entries.length - 1) const Divider(indent: 56),
],
],
),
),
),
const SizedBox(height: 12),
...result.checks.entries.map((entry) {
final detail = entry.value is Map
? Map<Object?, Object?>.from(entry.value as Map)
: const <Object?, Object?>{};
final status = detail['status']?.toString() ?? 'blocked';
final passed = status == 'passed';
return Card(
child: ListTile(
leading: Icon(
passed
? Icons.check_circle
: status == 'not_configured'
? Icons.info_outline
: Icons.cancel,
color: passed
? Colors.green
: status == 'not_configured'
? Colors.orange
: Colors.red,
),
title: Text(_label(entry.key)),
subtitle: Text(
status == 'not_configured'
? '平台暂未启用,不冒充校验通过'
: passed
? '已通过服务端校验'
: '未通过',
),
const SizedBox(height: 28),
if (result.workStatus != 'on_duty')
FilledButton.icon(
onPressed: _busy ? null : () => _attendance('clock_in'),
icon: const Icon(Icons.location_on_outlined),
label: Text(_busy ? '正在定位并打卡…' : '定位并上班打卡'),
)
else ...[
FilledButton(
onPressed: result.canWork ? () => context.go('/work') : null,
child: const Text('进入工作台'),
),
);
}),
const SizedBox(height: 18),
if (result.workStatus != 'on_duty')
ElevatedButton.icon(
onPressed: _busy ? null : () => _attendance('clock_in'),
icon: const Icon(Icons.location_on),
label: const Text('定位并上班打卡'),
)
else ...[
ElevatedButton(
onPressed: result.canWork ? () => context.go('/work') : null,
child: const Text('进入工作台'),
),
TextButton(
onPressed: _busy ? null : () => _attendance('clock_out'),
child: const Text('下班打卡'),
),
const SizedBox(height: 8),
TextButton(
onPressed: _busy ? null : () => _attendance('clock_out'),
child: const Text('下班打卡'),
),
],
],
],
),
);
},
),
@@ -141,3 +118,83 @@ class _PreflightPageState extends State<PreflightPage> {
_ => value,
};
}
class _PreflightBanner extends StatelessWidget {
const _PreflightBanner({required this.result});
final PreflightResult result;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = result.canWork ? scheme.secondaryContainer : scheme.tertiaryContainer;
final foreground = result.canWork ? scheme.onSecondaryContainer : scheme.onTertiaryContainer;
return Semantics(
liveRegion: true,
label: result.canWork ? '作业检查通过,可以开始今日作业' : '作业检查未通过,仍有前置条件未完成',
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(20),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
result.canWork ? Icons.verified_rounded : Icons.warning_amber_rounded,
color: foreground,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
roleName(result.roleCode),
style: Theme.of(context).textTheme.labelLarge?.copyWith(color: foreground),
),
const SizedBox(height: 6),
Text(
result.canWork ? '可以开始今日作业' : '仍有前置条件未完成',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: foreground),
),
],
),
),
],
),
),
);
}
}
class _CheckRow extends StatelessWidget {
const _CheckRow({required this.entry, required this.label});
final MapEntry<String, Object?> entry;
final String label;
@override
Widget build(BuildContext context) {
final detail = entry.value is Map
? Map<Object?, Object?>.from(entry.value as Map)
: const <Object?, Object?>{};
final status = detail['status']?.toString() ?? 'blocked';
final passed = status == 'passed';
final tone = passed
? StatusTone.success
: status == 'not_configured'
? StatusTone.warning
: StatusTone.danger;
final text = status == 'not_configured'
? '平台暂未启用'
: passed
? '已通过'
: '未通过';
return ListTile(
leading: Icon(passed ? Icons.check_circle_outline : Icons.info_outline_rounded),
title: Text(label),
trailing: StatusPill(label: text, tone: tone),
);
}
}

View File

@@ -5,6 +5,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 ProfilePage extends StatefulWidget {
const ProfilePage({
@@ -64,76 +65,101 @@ class _ProfilePageState extends State<ProfilePage> {
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 (profile, wallet, drafts) = snapshot.data!;
final balance = (wallet['balance'] as num?)?.toInt() ?? 0;
return ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
radius: 30,
child: Text(profile.name.isEmpty ? '' : profile.name.substring(0, 1)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
SurfaceSection(
child: Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: Theme.of(context).colorScheme.primary,
child: Text(profile.name.isEmpty ? '' : profile.name.substring(0, 1)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(profile.name, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'${roleName(profile.roleCode)} · ${profile.phone}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
Text('${roleName(profile.roleCode)} · ${profile.phone}'),
],
),
],
),
),
StatusPill(
label: profile.workStatus == 'on_duty' ? '在岗' : '离岗',
tone: profile.workStatus == 'on_duty' ? StatusTone.success : StatusTone.neutral,
),
],
),
),
const SizedBox(height: 28),
const SectionHeader(title: '工作概览'),
const SizedBox(height: 12),
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined),
title: const Text('钱包余额'),
trailing: Text(
'¥${(balance / 100).toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
Chip(label: Text(profile.workStatus == 'on_duty' ? '在岗' : '离岗')),
],
),
),
const Divider(indent: 56),
ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('加密现场草稿'),
subtitle: const Text('仅当前账号重新认证后可恢复'),
trailing: StatusPill(
label: '$drafts',
tone: drafts > 0 ? StatusTone.warning : StatusTone.neutral,
),
),
],
),
),
Card(
child: ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined),
title: const Text('钱包余额'),
trailing: Text(
'¥${(balance / 100).toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w900),
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('加密现场草稿'),
subtitle: const Text('仅当前账号重新认证后可恢复'),
trailing: Text('$drafts'),
),
),
Card(
const SizedBox(height: 28),
const SectionHeader(title: '账户操作'),
const SizedBox(height: 12),
SurfaceSection(
padding: EdgeInsets.zero,
child: ListTile(
leading: const Icon(Icons.verified_user_outlined),
title: const Text('重新执行作业前检查'),
subtitle: const Text('检查岗位、资质、在岗与设备授权状态'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/preflight'),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.logout),
title: const Text('退出登录'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _logout(drafts),
),
const SizedBox(height: 24),
TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
onPressed: () => _logout(drafts),
icon: const Icon(Icons.logout),
label: const Text('退出登录'),
),
],
);

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,
);
}