feat(apps): establish shared mobile design system
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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 ? '正在上传并等待服务端确认…' : '在线提交结果'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('退出登录'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user