feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
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!;
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user