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,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;
}