455 lines
14 KiB
Dart
455 lines
14 KiB
Dart
import 'dart:async';
|
||
import 'package:autosos_flutter/model/order.dart';
|
||
import 'package:autosos_flutter/provider/providers.dart';
|
||
import 'package:autosos_flutter/router/app_router.dart';
|
||
import 'package:autosos_flutter/util/toast.dart';
|
||
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||
import 'package:autosos_flutter/widget/watermark.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
/// 救援中页(对应 Android ConductActivity)
|
||
/// 步骤:开始 → 到达现场 → 拍照 → 签字 → 收款 → 完成
|
||
class ConductPage extends ConsumerStatefulWidget {
|
||
final int orderId;
|
||
const ConductPage({super.key, required this.orderId});
|
||
|
||
@override
|
||
ConsumerState<ConductPage> createState() => _ConductPageState();
|
||
}
|
||
|
||
class _ConductPageState extends ConsumerState<ConductPage> {
|
||
MultiState _state = MultiState.loading;
|
||
Order? _order;
|
||
Timer? _ticker;
|
||
Duration _elapsed = Duration.zero;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (!mounted || _order == null) return;
|
||
setState(() {
|
||
final start = _order!.acceptedAt ?? _order!.createdAt;
|
||
_elapsed = DateTime.now().difference(start);
|
||
});
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
setState(() => _state = MultiState.loading);
|
||
try {
|
||
final resp = await ref.read(orderApiProvider).orderDetail(widget.orderId);
|
||
if (!mounted) return;
|
||
if (!resp.isSuccess || resp.data == null) {
|
||
setState(() => _state = MultiState.error);
|
||
Toast.error(resp.message ?? '订单不存在');
|
||
return;
|
||
}
|
||
final o = resp.data!;
|
||
setState(() {
|
||
_order = o;
|
||
_state = MultiState.success;
|
||
});
|
||
ref.read(orderServiceProvider).setCurrent(o);
|
||
} catch (e) {
|
||
if (mounted) setState(() => _state = MultiState.error);
|
||
}
|
||
}
|
||
|
||
Future<void> _arrive() async {
|
||
EasyLoading.show(status: '上报中…');
|
||
try {
|
||
await ref.read(orderServiceProvider).arrive();
|
||
EasyLoading.dismiss();
|
||
Toast.success('已标记到达现场');
|
||
_load();
|
||
} catch (e) {
|
||
EasyLoading.dismiss();
|
||
Toast.error('上报失败:$e');
|
||
}
|
||
}
|
||
|
||
Future<void> _cancel() async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('取消订单'),
|
||
content: const Text('确定要取消此订单吗?取消后无法恢复。'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('不取消'),
|
||
),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('确认取消'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
try {
|
||
await ref.read(orderServiceProvider).cancel();
|
||
Toast.success('已取消');
|
||
if (mounted) context.go(Routes.home);
|
||
} catch (e) {
|
||
Toast.error('取消失败:$e');
|
||
}
|
||
}
|
||
|
||
Future<void> _call(String? phone) async {
|
||
if (phone == null || phone.isEmpty) {
|
||
Toast.error('客户未留电话');
|
||
return;
|
||
}
|
||
final uri = Uri.parse('tel:$phone');
|
||
if (await canLaunchUrl(uri)) {
|
||
await launchUrl(uri);
|
||
} else {
|
||
Toast.error('无法拨打电话');
|
||
}
|
||
}
|
||
|
||
Future<void> _navigate() async {
|
||
final o = _order;
|
||
if (o?.lat == null || o?.lng == null) {
|
||
Toast.error('暂无位置信息');
|
||
return;
|
||
}
|
||
// 高德地图 URL Scheme
|
||
final uri = Uri.parse(
|
||
'androidamap://navi?sourceApplication=jjsos&lat=${o!.lat}&lon=${o.lng}&dev=0&style=2',
|
||
);
|
||
if (await canLaunchUrl(uri)) {
|
||
await launchUrl(uri);
|
||
} else {
|
||
// 退回到 web 版
|
||
final web = Uri.parse(
|
||
'https://uri.amap.com/navigation?to=${o.lng},${o.lat},${o.address ?? "救援地点"}&mode=car',
|
||
);
|
||
await launchUrl(web, mode: LaunchMode.externalApplication);
|
||
}
|
||
}
|
||
|
||
String _fmt(Duration d) {
|
||
String two(int n) => n.toString().padLeft(2, '0');
|
||
return '${two(d.inHours)}:${two(d.inMinutes.remainder(60))}:${two(d.inSeconds.remainder(60))}';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('救援中'),
|
||
actions: [
|
||
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||
],
|
||
),
|
||
body: MultiStateView(
|
||
state: _state,
|
||
onRetry: _load,
|
||
child: _order == null
|
||
? const SizedBox.shrink()
|
||
: _body(_order!),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _body(Order o) {
|
||
final isArrived = o.status == OrderStatus.conducting ||
|
||
o.status == OrderStatus.photographing ||
|
||
o.status == OrderStatus.signing ||
|
||
o.status == OrderStatus.collecting;
|
||
return Stack(
|
||
children: [
|
||
ListView(
|
||
padding: const EdgeInsets.all(12),
|
||
children: [
|
||
// 倒计时卡片
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [Colors.red.shade400, Colors.red.shade700],
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.timer, color: Colors.white),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'已耗时 ${_fmt(_elapsed)}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text('订单号:${o.orderNo}',
|
||
style:
|
||
const TextStyle(color: Colors.white, fontSize: 12)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
// 客户信息
|
||
_section(
|
||
title: '客户信息',
|
||
children: [
|
||
_kv('联系人', o.customerName ?? '—'),
|
||
_kv('电话', o.customerPhone ?? '—'),
|
||
if (o.carNo != null) _kv('车牌', o.carNo!),
|
||
if (o.carType != null) _kv('车型', o.carType!),
|
||
if (o.description != null) _kv('描述', o.description!),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
// 救援地点
|
||
_section(
|
||
title: '救援地点',
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 8,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.location_on,
|
||
color: Colors.red, size: 18),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
o.address ?? '—',
|
||
style: const TextStyle(fontSize: 14),
|
||
),
|
||
),
|
||
TextButton.icon(
|
||
onPressed: _navigate,
|
||
icon: const Icon(Icons.directions, size: 16),
|
||
label: const Text('导航'),
|
||
),
|
||
TextButton.icon(
|
||
onPressed: () => _call(o.customerPhone),
|
||
icon: const Icon(Icons.phone, size: 16),
|
||
label: const Text('呼叫'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
// 步骤流程
|
||
_section(
|
||
title: '服务流程',
|
||
children: [
|
||
_Step(
|
||
active: true,
|
||
done: isArrived,
|
||
title: '到达现场',
|
||
child: isArrived
|
||
? Text('已到达:${o.arrivedAt ?? "—"}',
|
||
style: const TextStyle(color: Colors.grey))
|
||
: ElevatedButton(
|
||
onPressed: _arrive,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Theme.of(context).primaryColor,
|
||
),
|
||
child: const Text('我已到达现场'),
|
||
),
|
||
),
|
||
_Step(
|
||
active: isArrived,
|
||
done: o.photoUrls.isNotEmpty,
|
||
title: '拍照取证',
|
||
child: ElevatedButton(
|
||
onPressed: isArrived
|
||
? () => context
|
||
.push('${Routes.orderPhotograph}/${o.id}')
|
||
.then((_) => _load())
|
||
: null,
|
||
child: Text(
|
||
o.photoUrls.isEmpty
|
||
? '开始拍照'
|
||
: '已拍 ${o.photoUrls.length} 张,继续',
|
||
),
|
||
),
|
||
),
|
||
_Step(
|
||
active: o.photoUrls.isNotEmpty,
|
||
done: o.signatureUrl != null,
|
||
title: '客户签字',
|
||
child: ElevatedButton(
|
||
onPressed: o.photoUrls.isNotEmpty
|
||
? () => context
|
||
.push('${Routes.orderAutograph}/${o.id}')
|
||
.then((_) => _load())
|
||
: null,
|
||
child: const Text('让客户签字'),
|
||
),
|
||
),
|
||
_Step(
|
||
active: o.signatureUrl != null,
|
||
done: o.payMethod != null,
|
||
title: '客户付款',
|
||
child: ElevatedButton(
|
||
onPressed: o.signatureUrl != null
|
||
? () => context
|
||
.push('${Routes.orderCollection}/${o.id}')
|
||
.then((_) => _load())
|
||
: null,
|
||
child: const Text('发起收款'),
|
||
),
|
||
),
|
||
_Step(
|
||
active: o.payMethod != null,
|
||
done: o.status == OrderStatus.completed,
|
||
title: '完成订单',
|
||
child: ElevatedButton(
|
||
onPressed: o.payMethod != null
|
||
? () => context
|
||
.push('${Routes.orderComplete}/${o.id}')
|
||
.then((_) => _load())
|
||
: null,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.green,
|
||
),
|
||
child: const Text('提交完成'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
// 取消订单
|
||
OutlinedButton.icon(
|
||
onPressed: _cancel,
|
||
icon: const Icon(Icons.cancel_outlined, color: Colors.red),
|
||
label: const Text(
|
||
'取消订单',
|
||
style: TextStyle(color: Colors.red),
|
||
),
|
||
style: OutlinedButton.styleFrom(
|
||
side: const BorderSide(color: Colors.red),
|
||
minimumSize: const Size.fromHeight(48),
|
||
),
|
||
),
|
||
const SizedBox(height: 80),
|
||
],
|
||
),
|
||
// 全屏水印
|
||
const IgnorePointer(
|
||
child: Watermark(
|
||
text: '啾啾救援 © 内部资料',
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _section({required String title, required List<Widget> children}) {
|
||
return Material(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||
child: Text(
|
||
title,
|
||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
...children,
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _kv(String k, String v) => Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 60,
|
||
child: Text(k,
|
||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||
),
|
||
Expanded(
|
||
child: Text(v, style: const TextStyle(fontSize: 14)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _Step extends StatelessWidget {
|
||
final bool active;
|
||
final bool done;
|
||
final String title;
|
||
final Widget child;
|
||
const _Step({
|
||
required this.active,
|
||
required this.done,
|
||
required this.title,
|
||
required this.child,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 24,
|
||
height: 24,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: done
|
||
? Colors.green
|
||
: (active
|
||
? Theme.of(context).primaryColor
|
||
: Colors.grey.shade300),
|
||
),
|
||
child: Icon(
|
||
done ? Icons.check : Icons.circle,
|
||
size: 14,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 70,
|
||
child: Text(
|
||
title,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: active ? Colors.black : Colors.grey,
|
||
),
|
||
),
|
||
),
|
||
Expanded(child: child),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|