feat: 新增订单拨号功能,重构订单页与登录页

This commit is contained in:
andyesfly committed 2026-10-21 11:58:36 +08:00
1 parent 774403785c
commit 411ec8be23
9 files changed
+875 -482

No files matched your search

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
@@ -33,9 +34,14 @@ class OrderDetailPage extends ConsumerWidget {
final String source;
final int id;
/// 地图占位暂时隐藏(代码保留,后续接入真实地图时改回 true)。
static const _showMap = false;
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(orderDetailProvider((source: source, id: id)));
final order = detail.value;
final showBottomAction = order != null && !order.isCompleted;
return Scaffold(
body: XPageShell(
@@ -70,15 +76,16 @@ class OrderDetailPage extends ConsumerWidget {
),
},
),
// 底部固定操作栏,对应源 `.bottom-action`
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _BottomAction(
onStart: () => showAppToast('已开始出发', success: true),
// 已完成订单不再展示出发操作
if (showBottomAction)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _BottomAction(
onStart: () => showAppToast('已开始出发', success: true),
),
),
),
],
),
),
@@ -87,11 +94,11 @@ class OrderDetailPage extends ConsumerWidget {
Widget _buildContent(BuildContext context, Order order) {
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 120),
padding: EdgeInsets.only(bottom: order.isCompleted ? 24 : 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MapSection(address: order.address),
if (_showMap) _MapSection(address: order.address),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -392,7 +399,7 @@ class _CustomerCard extends StatelessWidget {
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => showAppToast('拨打电话'),
onTap: () => _callPhone(order.customerPhone),
behavior: HitTestBehavior.opaque,
child: Container(
width: 40,
@@ -744,6 +751,21 @@ class _DotPatternPainter extends CustomPainter {
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// 调起系统拨号盘。
Future<void> _callPhone(String raw) async {
final phone = raw.replaceAll(RegExp(r'[^0-9+]'), '');
if (phone.isEmpty) {
showAppToast('该客户暂无联系方式');
return;
}
try {
final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
if (!ok) showAppToast('无法调起拨号');
} catch (_) {
showAppToast('无法调起拨号');
}
}
/// 打开单张照片大图预览。
void _previewPhoto(BuildContext context, String url) {
showDialog<void>(
+457 -81
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
@@ -20,10 +21,10 @@ import '../data/order_models.dart';
String _money(num v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
/// 订单 Tab:顶部三档切换「进行中 / 待接单 / 已完成」。
///
/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
/// 「进行中」「已完成」来自 `myOrders`,待接单来自订单池;地图区域暂时隐藏,
/// `_showMap` 置 false 保留占位实现,后续接入真实地图时改回 true 即可。
class OrderTab extends ConsumerStatefulWidget {
const OrderTab({super.key});
@@ -32,10 +33,34 @@ class OrderTab extends ConsumerStatefulWidget {
}
class _OrderTabState extends ConsumerState<OrderTab> {
/// 地图占位暂时隐藏(代码保留)。
static const _showMap = false;
static const _segments = <String>['进行中', '待接单', '已完成'];
/// 待接单的类型筛选,null 表示全部。
static const _typeFilters = <(String?, String)>[
(null, '全部'),
('repair', '报修'),
('install', '安装'),
('maintenance', '维保'),
('survey', '勘察'),
];
/// 0 进行中 / 1 待接单 / 2 已完成。
int _segment = 0;
String? _typeFilter;
/// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
final _grabbed = <String>{};
String? _grabbing;
/// 只订阅当前分段的列表,切换时旧的 autoDispose 实例自动释放。
AsyncValue<List<Order>> _watchActive() => switch (_segment) {
1 => ref.watch(orderPoolProvider),
_ => ref.watch(myOrdersProvider(_segment == 0 ? 'pending' : 'completed')),
};
Future<void> _handleGrab(Order order) async {
if (_grabbing != null || _grabbed.contains(order.id)) return;
setState(() => _grabbing = order.id);
@@ -45,9 +70,14 @@ class _OrderTabState extends ConsumerState<OrderTab> {
.grab(order.source, order.intId);
if (!mounted) return;
if (res.isOk) {
setState(() => _grabbed.add(order.id));
setState(() {
_grabbed.add(order.id);
// 接单成功后订单转入「进行中」,顺手切到该分段,避免用户还要自己找
_segment = 0;
});
showAppToast('接单成功', success: true);
ref.invalidate(orderPoolProvider);
ref.invalidate(myOrdersProvider);
} else {
showAppToast(res.message.isEmpty ? '接单失败' : res.message);
}
@@ -58,9 +88,41 @@ class _OrderTabState extends ConsumerState<OrderTab> {
}
}
Future<void> _refresh() async {
ref.invalidate(orderPoolProvider);
ref.invalidate(myOrdersProvider);
try {
switch (_segment) {
case 1:
await ref.read(orderPoolProvider.future);
case 0:
await ref.read(myOrdersProvider('pending').future);
default:
await ref.read(myOrdersProvider('completed').future);
}
} catch (_) {
// 失败由列表的 hasError 分支展示,这里只负责结束下拉动画
}
}
/// 联系客户:调起系统拨号盘。
Future<void> _callCustomer(Order order) async {
final phone = order.customerPhone.replaceAll(RegExp(r'[^0-9+]'), '');
if (phone.isEmpty) {
showAppToast('该客户暂无联系方式');
return;
}
try {
final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
if (!ok && mounted) showAppToast('无法调起拨号');
} catch (_) {
if (mounted) showAppToast('无法调起拨号');
}
}
@override
Widget build(BuildContext context) {
final pool = ref.watch(orderPoolProvider);
final active = _watchActive();
final unread = ref.watch(noticeUnreadCountProvider);
return XPageShell(
@@ -72,93 +134,176 @@ class _OrderTabState extends ConsumerState<OrderTab> {
trailingBadge: unread,
onTrailingTap: () => context.push('/notice'),
),
child: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
ref.invalidate(orderPoolProvider);
await ref.read(orderPoolProvider.future);
},
child: ListView(
padding: EdgeInsets.zero,
physics: const AlwaysScrollableScrollPhysics(),
children: [
// 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
SizedBox(height: 296, child: const _MapPlaceholder()),
Container(
decoration: const BoxDecoration(
color: AppColors.bgPage,
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column(
children: [
_buildSegments(),
Expanded(
child: RefreshIndicator(
color: AppColors.primary,
onRefresh: _refresh,
child: ListView(
padding: EdgeInsets.zero,
physics: const AlwaysScrollableScrollPhysics(),
children: [
_buildSectionHeader(pool),
const SizedBox(height: 16),
..._buildBody(pool),
// 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
if (_showMap)
const SizedBox(height: 296, child: _MapPlaceholder()),
Container(
decoration: BoxDecoration(
color: AppColors.bgPage,
borderRadius: _showMap
? const BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
)
: BorderRadius.zero,
),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionHeader(active),
if (_segment == 1) ...[
const SizedBox(height: 12),
_buildTypeFilter(),
],
const SizedBox(height: 16),
..._buildBody(active),
],
),
),
],
),
),
),
],
),
);
}
/// 顶部三档分段控件。
Widget _buildSegments() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Row(
children: [
for (var i = 0; i < _segments.length; i++)
Expanded(
child: GestureDetector(
onTap: () => setState(() => _segment = i),
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _segment == i
? AppColors.surfaceContainerLowest
: Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.full),
boxShadow: _segment == i
? const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 6,
offset: Offset(0, 2),
),
]
: null,
),
child: Text(
_segments[i],
style: TextStyle(
fontSize: 14,
fontWeight: _segment == i
? FontWeight.w700
: FontWeight.w500,
color: _segment == i
? AppColors.onSurface
: AppColors.onSurfaceVariant,
),
),
),
),
),
],
),
),
);
}
Widget _buildSectionHeader(AsyncValue<List<Order>> pool) {
final count = pool.value?.length;
return Row(
Widget _buildSectionHeader(AsyncValue<List<Order>> active) {
final count = active.value?.length;
final (title, loadingText, unit) = switch (_segment) {
0 => ('进行中派单', '正在获取进行中的派单…', ' 个进行中'),
1 => ('待接订单', '正在获取附近订单…', ' 个待接订单'),
_ => ('已完成订单', '正在获取已完成订单…', ' 个已完成'),
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'待接订单',
style: AppTypography.navLargeTitle.copyWith(
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
count == null ? '正在获取附近订单…' : '附近有 $count 个订单',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
Text(
title,
style: AppTypography.navLargeTitle.copyWith(
color: AppColors.onSurface,
),
),
GestureDetector(
onTap: () => showAppToast('筛选功能'),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Text(
'筛选',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
const SizedBox(width: 6),
const XIcon('filter', size: 18, color: AppColors.primary),
],
),
const SizedBox(height: 4),
Text(
count == null ? loadingText : '共 $count$unit',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
);
}
List<Widget> _buildBody(AsyncValue<List<Order>> pool) {
if (pool.isLoading && !pool.hasValue) {
/// 待接单的类型筛选。
Widget _buildTypeFilter() {
return SizedBox(
height: 34,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _typeFilters.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final (value, label) = _typeFilters[i];
final selected = _typeFilter == value;
return GestureDetector(
onTap: () => setState(() => _typeFilter = value),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? AppColors.primary
: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColors.onSurfaceVariant,
),
),
),
);
},
),
);
}
List<Widget> _buildBody(AsyncValue<List<Order>> active) {
if (active.isLoading && !active.hasValue) {
return const [
Padding(
padding: EdgeInsets.symmetric(vertical: 60),
@@ -175,31 +320,62 @@ class _OrderTabState extends ConsumerState<OrderTab> {
),
];
}
if (pool.hasError && !pool.hasValue) {
if (active.hasError && !active.hasValue) {
return [
XPlaceholder(text: '订单加载失败\n${pool.error}'),
XPlaceholder(text: '订单加载失败\n${active.error}'),
Center(
child: TextButton(
onPressed: () => ref.invalidate(orderPoolProvider),
onPressed: () {
ref.invalidate(orderPoolProvider);
ref.invalidate(myOrdersProvider);
},
child: const Text('点击重试'),
),
),
];
}
final orders = pool.value ?? const <Order>[];
final orders = active.value ?? const <Order>[];
if (_segment == 1) {
final filtered = _typeFilter == null
? orders
: orders.where((o) => o.source == _typeFilter).toList();
if (filtered.isEmpty) {
return [
XPlaceholder(
text: _typeFilter == null ? '附近暂无待接订单' : '该类型暂无待接订单',
),
];
}
return [
for (final order in filtered)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _OrderCard(
order: order,
grabbing: _grabbing == order.id,
grabbed: _grabbed.contains(order.id),
onGrab: () => _handleGrab(order),
onTap: () => context.push('/order/${order.source}/${order.intId}'),
),
),
];
}
if (orders.isEmpty) {
return const [XPlaceholder(text: '附近暂无待接订单')];
return [
XPlaceholder(
text: _segment == 0 ? '暂无进行中的派单' : '暂无已完成订单',
),
];
}
return [
for (final order in orders)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _OrderCard(
child: _MyOrderCard(
order: order,
grabbing: _grabbing == order.id,
grabbed: _grabbed.contains(order.id),
onGrab: () => _handleGrab(order),
onCall: () => _callCustomer(order),
onTap: () => context.push('/order/${order.source}/${order.intId}'),
),
),
@@ -410,6 +586,206 @@ class _OrderCard extends StatelessWidget {
);
}
/// 进行中 / 已完成订单卡片:状态标签 + 联系客户 + 查看详情。
class _MyOrderCard extends StatelessWidget {
const _MyOrderCard({
required this.order,
required this.onCall,
required this.onTap,
});
final Order order;
final VoidCallback onCall;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderSoft),
borderRadius: BorderRadius.circular(AppRadius.xl),
boxShadow: AppShadows.orderCard,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Text(
'#${order.displayNo}',
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
),
),
const SizedBox(width: 12),
XTypeChip(type: order.type, text: order.listTypeText),
],
),
),
const SizedBox(width: 8),
_statusChip(),
],
),
const SizedBox(height: 12),
Text(
order.title,
style: AppTypography.headlineMd.copyWith(
color: AppColors.onSurface,
),
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: XIcon(
'location',
size: 16,
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
order.address.isEmpty ? '地址待确认' : order.address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
const XIcon(
'clock',
size: 16,
color: AppColors.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
order.schedule,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Text(
'¥${_money(order.amount)}',
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _actionButton(
label: '联系客户',
icon: 'call',
filled: false,
onTap: onCall,
),
),
const SizedBox(width: 12),
Expanded(
child: _actionButton(
label: '查看详情',
icon: 'forward',
filled: true,
onTap: onTap,
),
),
],
),
],
),
),
),
);
}
Widget _statusChip() {
final completed = order.isCompleted;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: completed ? AppColors.surfaceContainer : AppColors.accentSubtle,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
order.statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: completed ? AppColors.onSurfaceVariant : AppColors.primary,
),
),
);
}
Widget _actionButton({
required String label,
required String icon,
required bool filled,
required VoidCallback onTap,
}) {
final foreground = filled ? Colors.white : AppColors.onSurface;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled ? AppColors.primary : AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: filled
? null
: Border.all(color: const Color(0x80C3C5D9)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 18, color: foreground),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: foreground,
),
),
],
),
),
);
}
}
/// 接单中的旋转刷新图标。
class _SpinningRefresh extends StatefulWidget {
const _SpinningRefresh();