feat: 完成智能电网电工端 Flutter 应用初始化

创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
andyesfly committed 2026-10-06 11:54:10 +08:00
commit 56830e94ec
108 files changed
+9272

No files matched your search

@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../data/order_api.dart';
import '../data/order_models.dart';
final orderApiProvider = Provider<OrderApi>(
(ref) => OrderApi(ref.watch(apiClientProvider)),
);
/// 订单池(待接订单)。接单成功后由调用方 `invalidate` 触发刷新。
final orderPoolProvider = FutureProvider.autoDispose<List<Order>>((ref) async {
final res = await ref.watch(orderApiProvider).pool();
if (!res.isOk) throw ApiException(res.message);
return res.data ?? const [];
});
/// 我的订单,`status` 为 `pending` / `completed` / null(全部)。
final myOrdersProvider = FutureProvider.autoDispose
.family<List<Order>, String?>((ref, status) async {
final res = await ref.watch(orderApiProvider).myOrders(status: status);
if (!res.isOk) throw ApiException(res.message);
return res.data ?? const [];
});
/// 订单详情。参数为 `(source, id)`。
final orderDetailProvider = FutureProvider.autoDispose
.family<Order, ({String source, int id})>((ref, arg) async {
final res = await ref.watch(orderApiProvider).detail(arg.source, arg.id);
if (!res.isOk) throw ApiException(res.message);
if (res.data == null) throw const ApiException('订单不存在');
return res.data!;
});
+46
View File
@@ -0,0 +1,46 @@
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import 'order_models.dart';
/// 订单接口,对应后端 `/api/worker-app/orders*`。
class OrderApi {
OrderApi(this._client);
final ApiClient _client;
/// 订单池:四类未被接单的订单合并。
Future<ApiResponse<List<Order>>> pool() => _client.get<List<Order>>(
'/worker-app/orders/pool',
parse: _parseList,
);
/// 我的订单。`status` 传 `pending` / `completed`,留空取全部。
Future<ApiResponse<List<Order>>> myOrders({String? status}) =>
_client.get<List<Order>>(
'/worker-app/orders',
query: {'status': status},
parse: _parseList,
);
/// 订单详情。
Future<ApiResponse<Order>> detail(String source, int id) =>
_client.get<Order>(
'/worker-app/orders/$source/$id',
parse: (d) => Order.fromJson((d as Map).cast<String, dynamic>()),
);
/// 接单。订单被他人接走时后端返回 409。
Future<ApiResponse<void>> grab(String source, int id) =>
_client.post<void>('/worker-app/orders/$source/$id/grab');
/// 完成订单。非本人订单后端返回 403。
Future<ApiResponse<void>> complete(String source, int id) =>
_client.put<void>('/worker-app/orders/$source/$id/complete');
static List<Order> _parseList(dynamic data) {
final map = (data as Map).cast<String, dynamic>();
return (map['list'] as List? ?? const [])
.map((e) => Order.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
}
+113
View File
@@ -0,0 +1,113 @@
import '../../../core/utils/id_util.dart';
/// 统一订单视图,对应后端 `WorkerAppService.toOrderView()`。
///
/// `id` 形如 `repair-12` / `install-5`,`source` 为四类来源之一。
class Order {
const Order({
required this.id,
required this.source,
required this.status,
required this.jobId,
required this.title,
required this.type,
required this.amount,
required this.distance,
required this.schedule,
required this.description,
required this.customerName,
required this.customerPhone,
required this.photos,
required this.address,
required this.laborFee,
required this.materialFee,
required this.estimatedDuration,
});
/// `repair-12`
final String id;
/// repair | install | maintenance | survey
final String source;
final int status;
/// 业务单号(repairNo / installNo / maintenanceNo / surveyNo)
final String jobId;
final String title;
/// urgent | standard | regular
final String type;
final double amount;
final double distance;
final String schedule;
final String description;
final String customerName;
final String customerPhone;
final List<String> photos;
final String address;
final double laborFee;
final double materialFee;
final String estimatedDuration;
/// 来源中文名,用于收益明细副标题等场景。
String get sourceName => switch (source) {
'repair' => '家庭电路维修',
'install' => '设备安装',
'maintenance' => '设备维保',
'survey' => '现场勘察',
_ => '服务订单',
};
/// 列表页类型标签:紧急呼叫 / 标准预约 / 常规巡检。
String get listTypeText => switch (type) {
'urgent' => '紧急呼叫',
'standard' => '标准预约',
_ => '常规巡检',
};
/// 详情页类型标签:紧急 / 标准 / 常规。
String get detailTypeText => switch (type) {
'urgent' => '紧急',
'standard' => '标准',
_ => '常规',
};
/// 客户头像占位文字:后端无头像字段,用姓名首字(与「我的」页一致)。
String get customerAvatarText => customerName.isEmpty
? '客'
: String.fromCharCode(customerName.runes.first);
/// 卡片上展示的订单号:后端 `id` 前缀即来源,取 `jobId` 更贴近源 `#EL-8821` 的观感。
String get displayNo => jobId.isNotEmpty ? jobId : id;
/// 后端接口里的数字主键:`repair-12` → 12。
int get intId {
final idx = id.lastIndexOf('-');
return toIntOr(idx >= 0 ? id.substring(idx + 1) : id, 0);
}
factory Order.fromJson(Map<String, dynamic> json) => Order(
id: json['id'] as String? ?? '',
source: json['source'] as String? ?? '',
status: toIntOr(json['status'], 0),
jobId: json['jobId'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String? ?? 'regular',
amount: toDoubleOr(json['amount'], 0),
distance: toDoubleOr(json['distance'], 0),
schedule: json['schedule'] as String? ?? '',
description: json['description'] as String? ?? '',
customerName: json['customerName'] as String? ?? '',
customerPhone: json['customerPhone'] as String? ?? '',
photos:
(json['photos'] as List?)?.map((e) => e.toString()).toList() ??
const [],
address: json['address'] as String? ?? '',
laborFee: toDoubleOr(json['laborFee'], 0),
materialFee: toDoubleOr(json['materialFee'], 0),
estimatedDuration: json['estimatedDuration'] as String? ?? '',
);
}
@@ -0,0 +1,739 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../application/order_providers.dart';
import '../data/order_models.dart';
/// 金额展示:整数不带小数,否则保留两位。
String _money(num v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
/// 订单详情页,对应源 `pages/orderdetail/index.vue`。
///
/// **与源的差异(后端无对应字段)**:客户卡片省略评分与任务数,改为展示电话;
/// 服务地点只有一行地址(后端无 `addressDetail`);现场照片为空时只显示「添加」。
class OrderDetailPage extends ConsumerWidget {
const OrderDetailPage({super.key, required this.source, required this.id});
/// repair | install | maintenance | survey
final String source;
final int id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(orderDetailProvider((source: source, id: id)));
return Scaffold(
body: XPageShell(
backgroundColor: AppColors.surfaceContainerLow,
topBar: XTopBar.nav(
title: '订单详情',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => showAppToast('分享功能'),
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.all(8),
child: XIcon('share', size: 24, color: AppColors.primary),
),
),
),
child: Stack(
children: [
Positioned.fill(
child: switch (detail) {
AsyncData(:final value) => _buildContent(context, value),
AsyncError(:final error) => XPlaceholder(text: '订单加载失败\n$error'),
_ => const Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
),
},
),
// 底部固定操作栏,对应源 `.bottom-action`
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _BottomAction(
onStart: () => showAppToast('已开始出发', success: true),
),
),
],
),
),
);
}
Widget _buildContent(BuildContext context, Order order) {
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MapSection(address: order.address),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_JobCard(order: order),
const SizedBox(height: 12),
_CustomerCard(order: order),
const SizedBox(height: 16),
_PhotosSection(photos: order.photos),
const SizedBox(height: 16),
_FinancialCard(order: order),
const SizedBox(height: 16),
_LocationCard(address: order.address),
],
),
),
],
),
);
}
}
/// 顶部地图占位(176 高)+ 右下角「导航」按钮。
class _MapSection extends StatelessWidget {
const _MapSection({required this.address});
final String address;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 176,
child: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFDDE1FF), Color(0xFFF4F3F8)],
),
),
),
),
const Positioned.fill(
child: CustomPaint(painter: _DotPatternPainter()),
),
Center(
child: XIcon(
'location',
size: 48,
color: AppColors.primary.withValues(alpha: 0.6),
),
),
Positioned(
right: 16,
bottom: 12,
child: GestureDetector(
onTap: () => showAppToast('打开导航'),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(25),
boxShadow: const [
BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
XIcon('navigation', size: 20, color: Colors.white),
SizedBox(width: 8),
Text(
'导航',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
],
),
);
}
}
/// 工单卡片,对应源 `.job-card`。
class _JobCard extends StatelessWidget {
const _JobCard({required this.order});
final Order order;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'JOB ID: #${order.jobId}',
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
),
),
),
Text(
order.title,
style: AppTypography.headlineMd.copyWith(
color: AppColors.onSurface,
),
),
],
),
),
const SizedBox(width: 12),
XTypeChip(
type: order.type,
text: order.detailTypeText,
compact: false,
),
],
),
const SizedBox(height: 16),
// 描述块
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSoft),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: XIcon('warning', size: 20, color: AppColors.error),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'"${order.description}"',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
height: 1.5,
),
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _MetaItem(label: '预约时间', value: order.schedule),
),
Container(
width: 1,
height: 40,
color: AppColors.borderSoft,
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: _MetaItem(
label: '预计时长',
value: order.estimatedDuration,
),
),
),
],
),
],
),
);
}
}
class _MetaItem extends StatelessWidget {
const _MetaItem({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
Text(
value.isEmpty ? '-' : value,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
],
);
}
}
/// 客户卡片,对应源 `.customer-card`。
class _CustomerCard extends StatelessWidget {
const _CustomerCard({required this.order});
final Order order;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Row(
children: [
// 后端无客户头像,用姓名首字占位(与「我的」页头像一致)
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: AppColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
order.customerAvatarText,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
order.customerName.isEmpty ? '未知客户' : order.customerName,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
order.customerPhone.isEmpty
? '暂无联系方式'
: order.customerPhone,
style: AppTypography.bodyMd.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
],
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => showAppToast('拨打电话'),
behavior: HitTestBehavior.opaque,
child: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const XIcon('call', size: 18, color: Colors.white),
),
),
],
),
);
}
}
/// 现场照片横向列表。
class _PhotosSection extends StatelessWidget {
const _PhotosSection({required this.photos});
final List<String> photos;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'现场照片',
style: AppTypography.bodySm.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.26,
),
),
),
SizedBox(
height: 128,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final url in photos) ...[
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Image.network(
url,
width: 128,
height: 128,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 128,
height: 128,
color: AppColors.surfaceContainerHigh,
child: const XIcon(
'camera',
size: 28,
color: AppColors.textMuted,
),
),
),
),
const SizedBox(width: 12),
],
GestureDetector(
onTap: () => showAppToast('添加现场照片'),
behavior: HitTestBehavior.opaque,
child: Container(
width: 128,
height: 128,
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: AppColors.textPlaceholder,
width: 2,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
XIcon('add-photo', size: 32, color: AppColors.primary),
SizedBox(height: 4),
Text(
'添加',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
],
),
),
),
],
),
),
],
);
}
}
/// 费用卡片,对应源 `.financial-card`。
class _FinancialCard extends StatelessWidget {
const _FinancialCard({required this.order});
final Order order;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'预计收益',
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
),
_row('人工费', '¥${_money(order.laborFee)}'),
const SizedBox(height: 12),
_row('预计材料费', '¥${_money(order.materialFee)}'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.only(top: 12),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.borderSoft)),
),
child: Row(
children: [
Text(
'总计预估',
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const Spacer(),
Text(
'¥${_money(order.amount)}',
style: AppTypography.titleLg.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const XIcon('info', size: 18, color: AppColors.textMuted),
const SizedBox(width: 10),
Expanded(
child: Text(
'最终金额将根据现场核查和实际材料使用情况而定。',
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
height: 1.5,
),
),
),
],
),
),
],
),
);
}
Widget _row(String label, String value) => Row(
children: [
Text(
label,
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
),
const Spacer(),
Text(
value,
style: AppTypography.bodyMd.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
);
}
/// 服务地点卡片。
class _LocationCard extends StatelessWidget {
const _LocationCard({required this.address});
final String address;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'服务地点',
style: AppTypography.bodySm.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.26,
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: XIcon('location', size: 20, color: AppColors.primary),
),
const SizedBox(width: 12),
Expanded(
child: Text(
address.isEmpty ? '地址待确认' : address,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
),
],
),
],
),
);
}
}
/// 底部固定「开始出发」按钮。
class _BottomAction extends StatelessWidget {
const _BottomAction({required this.onStart});
final VoidCallback onStart;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.paddingOf(context).bottom,
),
decoration: const BoxDecoration(
color: AppColors.headerGlass,
border: Border(top: BorderSide(color: AppColors.borderSoft)),
),
child: GestureDetector(
onTap: onStart,
behavior: HitTestBehavior.opaque,
child: Container(
height: 54,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
XIcon('bolt', size: 24, color: Colors.white),
SizedBox(width: 10),
Text(
'开始出发',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
);
}
}
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
class _DotPatternPainter extends CustomPainter {
const _DotPatternPainter();
static const _dots = <(double, double, double)>[
(11, 11, 7),
(59, 36, 7),
(16, 33, 3),
(79, 64, 3),
(34, 87, 3),
(90, 11, 3),
(12, 82, 4),
(40, 17, 4),
(63, 5, 5),
(57, 66, 4),
(86, 87, 5),
(32, 58, 5),
(89, 45, 5),
(80, 27, 2),
(60, 89, 2),
(35, 39, 2),
(12, 58, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0x140052FF);
for (var ox = 0.0; ox < size.width; ox += 100) {
for (var oy = 0.0; oy < size.height; oy += 100) {
for (final (cx, cy, r) in _dots) {
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
}
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
@@ -0,0 +1,647 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../application/order_providers.dart';
import '../data/order_models.dart';
/// 金额展示:整数不带小数,否则保留两位(源 mock 里金额是整数)。
String _money(num v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
///
/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
class OrderTab extends ConsumerStatefulWidget {
const OrderTab({super.key});
@override
ConsumerState<OrderTab> createState() => _OrderTabState();
}
class _OrderTabState extends ConsumerState<OrderTab> {
/// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
final _grabbed = <String>{};
String? _grabbing;
Future<void> _handleGrab(Order order) async {
if (_grabbing != null || _grabbed.contains(order.id)) return;
setState(() => _grabbing = order.id);
try {
final res = await ref
.read(orderApiProvider)
.grab(order.source, order.intId);
if (!mounted) return;
if (res.isOk) {
setState(() => _grabbed.add(order.id));
showAppToast('接单成功', success: true);
ref.invalidate(orderPoolProvider);
} else {
showAppToast(res.message.isEmpty ? '接单失败' : res.message);
}
} on ApiException catch (e) {
if (mounted) showAppToast(e.message);
} finally {
if (mounted) setState(() => _grabbing = null);
}
}
@override
Widget build(BuildContext context) {
final pool = ref.watch(orderPoolProvider);
final unread = ref.watch(noticeUnreadCountProvider);
return XPageShell(
topBar: XTopBar.brand(
height: 64,
logoSize: 28,
titleSize: 20,
titleWeight: FontWeight.w700,
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,
children: [
_buildSectionHeader(pool),
const SizedBox(height: 16),
..._buildBody(pool),
],
),
),
],
),
),
);
}
Widget _buildSectionHeader(AsyncValue<List<Order>> pool) {
final count = pool.value?.length;
return Row(
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,
),
),
],
),
),
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),
],
),
),
),
],
);
}
List<Widget> _buildBody(AsyncValue<List<Order>> pool) {
if (pool.isLoading && !pool.hasValue) {
return const [
Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
),
),
];
}
if (pool.hasError && !pool.hasValue) {
return [
XPlaceholder(text: '订单加载失败\n${pool.error}'),
Center(
child: TextButton(
onPressed: () => ref.invalidate(orderPoolProvider),
child: const Text('点击重试'),
),
),
];
}
final orders = pool.value ?? const <Order>[];
if (orders.isEmpty) {
return const [XPlaceholder(text: '附近暂无待接订单')];
}
return [
for (final order in orders)
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}'),
),
),
];
}
}
/// 单张待接订单卡片,对应源 `.order-card`。
class _OrderCard extends StatelessWidget {
const _OrderCard({
required this.order,
required this.grabbing,
required this.grabbed,
required this.onGrab,
required this.onTap,
});
final Order order;
final bool grabbing;
final bool grabbed;
final VoidCallback onGrab;
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),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'¥${_money(order.amount)}',
style: AppTypography.headlineMd.copyWith(
color: AppColors.primary,
),
),
const Text(
'预估收益',
style: TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
),
const SizedBox(height: 12),
Text(
order.title,
style: AppTypography.headlineMd.copyWith(
color: AppColors.onSurface,
),
),
const SizedBox(height: 12),
Row(
children: [
const XIcon(
'location',
size: 16,
color: AppColors.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'${order.distance}km',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(width: 24),
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(height: 16),
_buildGrabButton(),
],
),
),
),
);
}
Widget _buildGrabButton() {
final (Color bg, Color fg, BoxShadow? shadow, Widget icon) = switch ((
grabbing,
grabbed,
order.type,
)) {
(true, _, _) => (
AppColors.primary,
Colors.white,
_primaryShadow,
const _SpinningRefresh(),
),
(_, true, _) => (
AppColors.tertiaryFixed,
AppColors.onTertiaryFixedVariant,
null,
const XIcon('check', size: 20, color: AppColors.onTertiaryFixedVariant),
),
(_, _, 'urgent') => (
AppColors.primary,
Colors.white,
_primaryShadow,
const XIcon('forward', size: 20, color: Colors.white),
),
_ => (
AppColors.surfaceContainerLow,
AppColors.onSurface,
null,
const XIcon('forward', size: 20, color: AppColors.onSurface),
),
};
final label = grabbing
? '接单中...'
: grabbed
? '已接单'
: (order.type == 'urgent' ? '立即接单' : '接单');
final outlined = !grabbing && !grabbed && order.type != 'urgent';
return Opacity(
opacity: grabbing ? 0.7 : 1,
child: GestureDetector(
onTap: (grabbing || grabbed) ? null : onGrab,
behavior: HitTestBehavior.opaque,
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: outlined
? Border.all(color: const Color(0x80C3C5D9))
: null,
boxShadow: shadow == null ? null : [shadow],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
icon,
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: fg,
),
),
],
),
),
),
);
}
static const _primaryShadow = BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 12,
offset: Offset(0, 4),
);
}
/// 接单中的旋转刷新图标。
class _SpinningRefresh extends StatefulWidget {
const _SpinningRefresh();
@override
State<_SpinningRefresh> createState() => _SpinningRefreshState();
}
class _SpinningRefreshState extends State<_SpinningRefresh>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => RotationTransition(
turns: _c,
child: const XIcon('refresh', size: 20, color: Colors.white),
);
}
/// 地图占位:源 `.map-bg` 的圆点纹理 + 两个标记 + 右侧控制按钮。
class _MapPlaceholder extends StatelessWidget {
const _MapPlaceholder();
@override
Widget build(BuildContext context) {
return Stack(
children: [
const Positioned.fill(
child: ColoredBox(color: AppColors.surfaceContainerHighest),
),
const Positioned.fill(child: CustomPaint(painter: _DotPatternPainter())),
// 紧急标记(带脉冲光环)
const Positioned(
top: 40,
left: 60,
child: _PulsingMarker(),
),
// 普通标记
Positioned(
top: 120,
right: 80,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: const XIcon('shandian', size: 18, color: Colors.white),
),
),
Positioned(
right: 16,
bottom: 48,
child: Column(
children: [
_MapControlButton(
icon: 'location',
onTap: () => showAppToast('定位中...'),
),
const SizedBox(height: 8),
_MapControlButton(
icon: 'map',
onTap: () => showAppToast('图层切换'),
),
],
),
),
],
);
}
}
/// 紧急标记:红色胶囊 + 呼吸光环,对应源 `.marker.urgent`。
class _PulsingMarker extends StatefulWidget {
const _PulsingMarker();
@override
State<_PulsingMarker> createState() => _PulsingMarkerState();
}
class _PulsingMarkerState extends State<_PulsingMarker>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _c,
builder: (context, _) => Container(
width: 76 + 24 * _c.value,
height: 32 + 24 * _c.value,
decoration: BoxDecoration(
color: AppColors.secondaryContainer.withValues(
alpha: 0.4 * (1 - _c.value),
),
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
XIcon('shandian', size: 14, color: Colors.white),
SizedBox(width: 6),
Text(
'紧急',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
],
);
}
}
class _MapControlButton extends StatelessWidget {
const _MapControlButton({required this.icon, required this.onTap});
final String icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xE6FFFFFF),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: XIcon(icon, size: 20, color: AppColors.primary),
),
);
}
}
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
class _DotPatternPainter extends CustomPainter {
const _DotPatternPainter();
/// 取自源 SVG 里的 (cx, cy, r) 列表。
static const _dots = <(double, double, double)>[
(11, 11, 7),
(59, 36, 7),
(16, 33, 3),
(79, 64, 3),
(34, 87, 3),
(90, 11, 3),
(12, 82, 4),
(40, 17, 4),
(63, 5, 5),
(57, 66, 4),
(86, 87, 5),
(32, 58, 5),
(89, 45, 5),
(80, 27, 2),
(60, 89, 2),
(35, 39, 2),
(12, 58, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0x140052FF);
for (var ox = 0.0; ox < size.width; ox += 100) {
for (var oy = 0.0; oy < size.height; oy += 100) {
for (final (cx, cy, r) in _dots) {
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
}
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}