feat: add Flutter mobile clients and staff delivery API
This commit is contained in:
290
apps/service_app/lib/ui/features/work/work_detail_page.dart
Normal file
290
apps/service_app/lib/ui/features/work/work_detail_page.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/offline/encrypted_draft_store.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
|
||||
class WorkDetailPage extends StatefulWidget {
|
||||
const WorkDetailPage({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.drafts,
|
||||
required this.identity,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final EncryptedDraftStore drafts;
|
||||
final String identity;
|
||||
|
||||
@override
|
||||
State<WorkDetailPage> createState() => _WorkDetailPageState();
|
||||
}
|
||||
|
||||
class _WorkDetailPageState extends State<WorkDetailPage> {
|
||||
late Future<WorkItem> _future;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<WorkItem> _load() => widget.session.roleCode == 'delivery'
|
||||
? widget.repository.deliveryDetail(widget.identity)
|
||||
: widget.repository.ticketDetail(widget.identity);
|
||||
|
||||
Future<void> _run(Future<void> Function(WorkItem) action, WorkItem item) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await action(item);
|
||||
setState(() => _future = _load());
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _reason() async {
|
||||
final controller = TextEditingController();
|
||||
final value = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('填写原因'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(labelText: '原因'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> _receipt(WorkItem item) async {
|
||||
final existing = await widget.drafts.readDraft(widget.session.identity, item.identity);
|
||||
if (!mounted) return;
|
||||
String? sealedName;
|
||||
if (existing?['kind'] == 'delivery_receipt' && existing?['sealed_name'] is String) {
|
||||
final reuse = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('发现未提交签收草稿'),
|
||||
content: const Text('是否继续提交上次加密保存的签收凭证?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('重新拍摄')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('继续提交')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (reuse == true) sealedName = existing!['sealed_name'] as String;
|
||||
}
|
||||
if (sealedName == null) {
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 82);
|
||||
if (image == null) return;
|
||||
sealedName = await widget.drafts.sealAttachment(
|
||||
accountIdentity: widget.session.identity,
|
||||
taskIdentity: item.identity,
|
||||
stage: 'receipt',
|
||||
sourcePath: image.path,
|
||||
);
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
String? temporaryPath;
|
||||
try {
|
||||
await widget.drafts.saveDraft(
|
||||
accountIdentity: widget.session.identity,
|
||||
taskIdentity: item.identity,
|
||||
value: {
|
||||
'task_identity': item.identity,
|
||||
'kind': 'delivery_receipt',
|
||||
'sealed_name': sealedName,
|
||||
'request_no': const Uuid().v7(),
|
||||
'captured_at': DateTime.now().toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
temporaryPath = await widget.drafts.materializeAttachment(
|
||||
accountIdentity: widget.session.identity,
|
||||
sealedName: sealedName,
|
||||
);
|
||||
await widget.repository.submitDeliveryReceipt(
|
||||
identity: item.identity,
|
||||
recipientName: item.raw['contact_name'] as String? ?? '收货人',
|
||||
recipientPhone: item.raw['contact_phone'] as String? ?? '',
|
||||
proofFile: temporaryPath,
|
||||
);
|
||||
await widget.drafts.deleteDraft(widget.session.identity, item.identity);
|
||||
if (mounted) setState(() => _future = _load());
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('签收提交失败,加密草稿已保留:$error')));
|
||||
}
|
||||
} finally {
|
||||
if (temporaryPath != null) {
|
||||
final file = File(temporaryPath);
|
||||
if (file.existsSync()) await file.delete();
|
||||
}
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('任务详情')),
|
||||
body: FutureBuilder<WorkItem>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.number, style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Chip(label: Text(statusName(item.status))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.location_on_outlined),
|
||||
title: const Text('服务地址'),
|
||||
subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address),
|
||||
),
|
||||
),
|
||||
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),
|
||||
item,
|
||||
),
|
||||
child: const Text('开始处理'),
|
||||
),
|
||||
if (item.allowedActions.contains('append_tracks'))
|
||||
OutlinedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
(value) => widget.repository.appendCurrentTrack(value.identity),
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
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()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
117
apps/service_app/lib/ui/features/work/work_list_page.dart
Normal file
117
apps/service_app/lib/ui/features/work/work_list_page.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
import 'work_list_view_model.dart';
|
||||
|
||||
class WorkListPage extends StatefulWidget {
|
||||
const WorkListPage({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.completed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final bool completed;
|
||||
|
||||
@override
|
||||
State<WorkListPage> createState() => _WorkListPageState();
|
||||
}
|
||||
|
||||
class _WorkListPageState extends State<WorkListPage> {
|
||||
late final WorkListViewModel _viewModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewModel = WorkListViewModel(
|
||||
widget.repository,
|
||||
widget.session.roleCode,
|
||||
completed: widget.completed,
|
||||
)..load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_viewModel.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.completed ? '作业记录' : '${roleName(widget.session.roleCode)}工作台'),
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: _viewModel,
|
||||
builder: (context, _) {
|
||||
if (_viewModel.loading && _viewModel.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_viewModel.error != null && _viewModel.items.isEmpty) {
|
||||
return Center(child: Text(_viewModel.error.toString()));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _viewModel.load,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
|
||||
children: [
|
||||
Card(
|
||||
color: widget.session.roleCode == 'operations'
|
||||
? const Color(0xFFE9F8F0)
|
||||
: const Color(0xFFEEEAFE),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.session.roleCode == 'delivery'
|
||||
? Icons.local_shipping
|
||||
: widget.session.roleCode == 'installer'
|
||||
? Icons.handyman
|
||||
: Icons.health_and_safety,
|
||||
size: 38,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_viewModel.items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(42),
|
||||
child: Center(child: Text('暂无任务')),
|
||||
)
|
||||
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}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
|
||||
class WorkListViewModel extends ChangeNotifier {
|
||||
WorkListViewModel(this._repository, this._roleCode, {required this.completed});
|
||||
|
||||
final ServiceRepository _repository;
|
||||
final String _roleCode;
|
||||
final bool completed;
|
||||
List<WorkItem> _items = const [];
|
||||
Object? _error;
|
||||
bool _loading = false;
|
||||
|
||||
List<WorkItem> get items => _items;
|
||||
Object? get error => _error;
|
||||
bool get loading => _loading;
|
||||
|
||||
Future<void> load() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final all = await _repository.tasks(_roleCode);
|
||||
_items = all
|
||||
.where((item) => completed ? item.status == 23 : item.status != 23 && item.status != 22)
|
||||
.toList();
|
||||
} catch (error) {
|
||||
_error = error;
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user