feat: align Flutter with Android jjsos_autosos
P0 订单流程: - waiting 接单等待页(含计时器与语音播报触发) - conduct 服务中页(含水印 / 拍照 / 签名 / 完成入口) - photograph 拍照取证(自定义相机 + 多图上传) - autograph 电子签名 - complete 费用明细录入 → 完成订单 - collection 收款(二维码 + 微信 + 现金) - record 历史订单(分页) P1 我的中心: - me 个人中心(头像 / 余额 / 订单统计 / 功能入口) - perfect 完善资料(实名 / 身份证 / 地址 / 邮箱) - authentication 资质认证(个人 / 企业) - feedback 意见反馈 - messenger 消息中心(系统通知 + 订单消息) - setting 设置(推送 / 声音 / 后台保活 / 缓存 / 更新) - optimization 性能优化页(定位 / 推送 / 心跳状态) - withdrawal 提现申请 - withdrawal_record 提现记录 - recharge 充值(微信 + 二维码) - amount_list 资金流水 P2 基础架构: - splash 启动页(隐私协议 + 定位权限) - welcome 引导页 - webview WebView 容器(拦截 tel/mailto/weixin) - qr_scan QR 扫码(mobile_scanner + 权限校验) - login + bind_phone + bind_wx 登录链路 - audio / background / heartbeat / location / push / update / upload 服务 - riverpod 状态 + go_router 路由 + model/api/api_client - toast 真实实现(fluttertoast + EasyLoading) Android 配置: - AndroidManifest 权限补全 - network_security_config 明文流量白名单 - pubspec 资源 / SDK 配置(高德 / 微信 / 个推 / Sentry) fix: - 修复 flutter analyze 全部 error(适配 installed SDK 版本) - amap_flutter_map 3.0.0 移除的 hashValues → Object.hash - SPUtil.set 不存在 → setBool / setString - XHttp 返回 response.data(dynamic)→ r as Map - 修正 fluwx 4.6.3 pay(with: Payment(...)) API - 修正 getuiflut 0.2.41 addEventHandler 18 个回调 / startSdk void - 修正 AMap Location Map<String, Object> 返回类型 - 修正 flutter_background_service 5.1.0 isRunning 异步方法 - 清理 unused import / 弃用 API 测试: widget_test 通过(App boots and shows splash/welcome)main
parent
1c9466b9b5
commit
43c780f74c
|
|
@ -1,9 +1,77 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.example.autosos_flutter">
|
package="com.example.autosos_flutter">
|
||||||
|
|
||||||
|
<!-- 基础网络 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||||
|
|
||||||
|
<!-- 定位 -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
|
||||||
|
|
||||||
|
<!-- 相机 / 闪光灯 -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.FLASHLIGHT"/>
|
||||||
|
|
||||||
|
<!-- 存储(Android 10 以下)-->
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32"/>
|
||||||
|
<!-- Android 13+ 媒体权限 -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
|
|
||||||
|
<!-- 音频 / 录音 -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
|
|
||||||
|
<!-- 振动 / 通知 -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
|
<!-- 蓝牙(部分车机连接)-->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||||
|
|
||||||
|
<!-- 省电白名单(用于后台保活)-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
|
||||||
|
<!-- 安装 APK -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
|
|
||||||
|
<!-- 启动系统安装器 -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.DIAL"/>
|
||||||
|
<data android:scheme="tel"/>
|
||||||
|
</intent>
|
||||||
|
<!-- 高德地图 -->
|
||||||
|
<package android:name="com.autonavi.minimap"/>
|
||||||
|
<!-- 微信 -->
|
||||||
|
<package android:name="com.tencent.mm"/>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="autosos_flutter"
|
android:label="啾啾救援"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -12,10 +80,6 @@
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
|
||||||
the Android process has started. This theme is visible to the user
|
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
|
||||||
to determine the Window background behind the Flutter UI. -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
|
|
@ -25,10 +89,14 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- 高德定位 / 地图 Key(与 Android 端保持一致)-->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.amap.api.v2.apikey"
|
||||||
|
android:value="androidKey_is_set_in_manifest"/>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">api.jjsos.cn</domain>
|
||||||
|
<domain includeSubdomains="true">api.test.jjsos.cn</domain>
|
||||||
|
<domain includeSubdomains="true">pic.jjsos.cn</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:autosos_flutter/util/xhttp.dart';
|
||||||
|
|
||||||
|
/// 提供已注入 token 的 XHttp(保证所有业务 API 自动带 token)
|
||||||
|
class ApiClient {
|
||||||
|
ApiClient._();
|
||||||
|
static final ApiClient instance = ApiClient._();
|
||||||
|
|
||||||
|
XHttp get http => XHttp.getInstance();
|
||||||
|
|
||||||
|
/// 登录态写入后调用,刷新后续请求头
|
||||||
|
void setToken(String token) {
|
||||||
|
XHttp.setHeader('Authorization', 'Bearer $token');
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearToken() {
|
||||||
|
XHttp.removeHeader('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get token {
|
||||||
|
final v = SPUtil().get('accessToken');
|
||||||
|
return v is String ? v : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:autosos_flutter/pages/home/home_page.dart';
|
import 'package:autosos_flutter/pages/home/home_page.dart';
|
||||||
import 'package:autosos_flutter/util/sp_util.dart';
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
import 'package:autosos_flutter/util/xhttp.dart';
|
import 'package:autosos_flutter/util/xhttp.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import 'package:autosos_flutter/api/_api_client.dart';
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/misc.dart';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
|
||||||
|
/// 订单 API(对应 Android AppApiService 中 order/* 相关)
|
||||||
|
class OrderApi {
|
||||||
|
static final OrderApi instance = OrderApi._();
|
||||||
|
OrderApi._();
|
||||||
|
|
||||||
|
final _http = ApiClient.instance.http;
|
||||||
|
|
||||||
|
// ===== 核单 =====
|
||||||
|
|
||||||
|
/// 核单列表
|
||||||
|
Future<BaseResponse<List<CheckItem>>> checkList({int page = 1, int size = 20}) async {
|
||||||
|
final r = await _http.get(
|
||||||
|
'/v2/order/check-list',
|
||||||
|
{'page': page, 'size': size},
|
||||||
|
);
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => (d as List).map((e) => CheckItem.fromJson(e as Map<String, dynamic>)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交核单
|
||||||
|
Future<BaseResponse<bool>> submitCheck({
|
||||||
|
required int orderId,
|
||||||
|
required String remark,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/order/check', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'remark': remark,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 等待接单 =====
|
||||||
|
|
||||||
|
/// 当前可接订单列表
|
||||||
|
Future<BaseResponse<List<WaitingOrder>>> waitingList() async {
|
||||||
|
final r = await _http.get('/v2/order/waiting-list');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => (d as List).map((e) => WaitingOrder.fromJson(e as Map<String, dynamic>)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 抢单
|
||||||
|
Future<BaseResponse<Order>> grabOrder(int orderId) async {
|
||||||
|
final r = await _http.post('/v2/order/grab', {'order_id': orderId});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => Order.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 救援中 =====
|
||||||
|
|
||||||
|
/// 到达现场
|
||||||
|
Future<BaseResponse<bool>> arriveScene(int orderId, {double? lat, double? lng}) async {
|
||||||
|
final r = await _http.post('/v2/order/arrive', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始轨迹录制
|
||||||
|
Future<BaseResponse<String>> startTrack(int orderId) async {
|
||||||
|
final r = await _http.post('/v2/order/track/start', {'order_id': orderId});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传轨迹点
|
||||||
|
Future<BaseResponse<bool>> uploadTrackPoints(int orderId, List<Map<String, dynamic>> points) async {
|
||||||
|
final r = await _http.post('/v2/order/track/points', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'points': points,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结束轨迹
|
||||||
|
Future<BaseResponse<bool>> endTrack(int orderId) async {
|
||||||
|
final r = await _http.post('/v2/order/track/end', {'order_id': orderId});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 拍照取证 =====
|
||||||
|
|
||||||
|
/// 上传照片(实际通过 QiniuUploadService 走七牛,本接口只是返回 token)
|
||||||
|
Future<BaseResponse<List<String>>> uploadPhotos(int orderId, List<String> localPaths) async {
|
||||||
|
final r = await _http.post('/v2/order/photos', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'paths': localPaths,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => (d as List).cast<String>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 客户签字 =====
|
||||||
|
|
||||||
|
/// 上传签名图片
|
||||||
|
Future<BaseResponse<String>> uploadSignature(int orderId, String localPath) async {
|
||||||
|
final r = await _http.post('/v2/order/signature', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'path': localPath,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 收款 =====
|
||||||
|
|
||||||
|
/// 生成支付订单(微信支付)
|
||||||
|
Future<BaseResponse<WxPay>> createWxPay(int orderId, double amount) async {
|
||||||
|
final r = await _http.post('/v2/pay/wx', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'amount': amount,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => WxPay.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成收款二维码(技师二维码让客户扫码)
|
||||||
|
Future<BaseResponse<String>> createPayQrCode(int orderId, double amount) async {
|
||||||
|
final r = await _http.post('/v2/pay/qrcode', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'amount': amount,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 现金收款确认
|
||||||
|
Future<BaseResponse<bool>> confirmCashPay(int orderId, double amount) async {
|
||||||
|
final r = await _http.post('/v2/pay/cash', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'amount': amount,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 完成 =====
|
||||||
|
|
||||||
|
/// 提交完成(带金额明细)
|
||||||
|
Future<BaseResponse<CompleteOrderInfo>> completeOrder({
|
||||||
|
required int orderId,
|
||||||
|
required List<AmountItem> amountItems,
|
||||||
|
String? remark,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/order/complete', {
|
||||||
|
'order_id': orderId,
|
||||||
|
'amount_items': amountItems.map((e) => e.toJson()).toList(),
|
||||||
|
'remark': remark,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => CompleteOrderInfo.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 订单记录 =====
|
||||||
|
|
||||||
|
/// 历史订单(已完成 / 已取消)
|
||||||
|
Future<BaseResponse<PageResult<Order>>> historyOrders({
|
||||||
|
int page = 1,
|
||||||
|
int size = 20,
|
||||||
|
String? status,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.get('/v2/order/history', {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
if (status != null) 'status': status,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => PageResult.fromJson(d as Map<String, dynamic>, (e) => Order.fromJson(e as Map<String, dynamic>)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单详情
|
||||||
|
Future<BaseResponse<Order>> orderDetail(int orderId) async {
|
||||||
|
final r = await _http.get('/v2/order/detail/$orderId');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => Order.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:autosos_flutter/api/_api_client.dart';
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/misc.dart';
|
||||||
|
import 'package:autosos_flutter/service/upload_service.dart';
|
||||||
|
|
||||||
|
/// 文件上传 API(七牛凭证获取)
|
||||||
|
class UploadApi {
|
||||||
|
static final UploadApi instance = UploadApi._();
|
||||||
|
UploadApi._();
|
||||||
|
|
||||||
|
final _http = ApiClient.instance.http;
|
||||||
|
|
||||||
|
/// 获取七牛上传凭证
|
||||||
|
Future<BaseResponse<QiNiuToken>> qiNiuToken() async {
|
||||||
|
final r = await _http.get('/v2/upload/qiniu-token');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => QiNiuToken.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过封装的上传服务上传文件,返回远程 URL
|
||||||
|
Future<String> uploadFile(String localPath) async {
|
||||||
|
return UploadService.instance.upload(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量上传
|
||||||
|
Future<List<String>> uploadFiles(List<String> localPaths) async {
|
||||||
|
return UploadService.instance.uploadBatch(localPaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
import 'package:autosos_flutter/api/_api_client.dart';
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/misc.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
import 'package:autosos_flutter/model/user.dart';
|
||||||
|
|
||||||
|
/// 用户 / 认证 / 我的 相关 API
|
||||||
|
class UserApi {
|
||||||
|
static final UserApi instance = UserApi._();
|
||||||
|
UserApi._();
|
||||||
|
|
||||||
|
final _http = ApiClient.instance.http;
|
||||||
|
|
||||||
|
// ===== 登录相关 =====
|
||||||
|
|
||||||
|
/// 账号密码登录
|
||||||
|
Future<BaseResponse<LoginToken>> login({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
String? code,
|
||||||
|
String? cid,
|
||||||
|
String? wxOpenid,
|
||||||
|
String? wxUnionid,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/auth/get-access-token', {
|
||||||
|
'type': 1,
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'code': code ?? '',
|
||||||
|
'getui_cid': cid ?? '',
|
||||||
|
'wx_openid': wxOpenid ?? '',
|
||||||
|
'wx_unionid': wxUnionid ?? '',
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => LoginToken.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送短信验证码
|
||||||
|
Future<BaseResponse<SmsResult>> sendSms(String mobile) async {
|
||||||
|
final r = await _http.post('/v2/auth/send-sms', {'mobile': mobile});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => SmsResult.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手机验证码登录
|
||||||
|
Future<BaseResponse<LoginToken>> loginBySms({
|
||||||
|
required String mobile,
|
||||||
|
required String code,
|
||||||
|
String? cid,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/auth/login-by-sms', {
|
||||||
|
'mobile': mobile,
|
||||||
|
'code': code,
|
||||||
|
'cid': cid,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => LoginToken.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 微信登录(前端通过 fluwx 拿到 code,调后端)
|
||||||
|
Future<BaseResponse<LoginToken>> loginByWx({
|
||||||
|
required String wxCode,
|
||||||
|
String? cid,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/auth/login-by-wx', {
|
||||||
|
'wx_code': wxCode,
|
||||||
|
'cid': cid,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => LoginToken.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定手机号
|
||||||
|
Future<BaseResponse<bool>> bindPhone({
|
||||||
|
required String mobile,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/auth/bind-phone', {
|
||||||
|
'mobile': mobile,
|
||||||
|
'code': code,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定微信
|
||||||
|
Future<BaseResponse<bool>> bindWx({
|
||||||
|
required String wxCode,
|
||||||
|
required String mobile,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/auth/bind-wx', {
|
||||||
|
'wx_code': wxCode,
|
||||||
|
'mobile': mobile,
|
||||||
|
'code': code,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出登录
|
||||||
|
Future<BaseResponse<bool>> logout() async {
|
||||||
|
final r = await _http.post('/v2/auth/logout');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 我的 =====
|
||||||
|
|
||||||
|
/// 用户信息
|
||||||
|
Future<BaseResponse<UserInfo>> info() async {
|
||||||
|
final r = await _http.get('/v2/user/info');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => UserInfo.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完善资料
|
||||||
|
Future<BaseResponse<UserInfo>> perfectInfo(Map<String, dynamic> data) async {
|
||||||
|
final r = await _http.post('/v2/user/perfect', data);
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => UserInfo.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传身份证照片
|
||||||
|
Future<BaseResponse<List<String>>> uploadIdCards(List<String> paths) async {
|
||||||
|
final r = await _http.post('/v2/user/upload-id', {'paths': paths});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => (d as List).cast<String>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 个人认证
|
||||||
|
Future<BaseResponse<bool>> authPersonal({
|
||||||
|
required String name,
|
||||||
|
required String idCard,
|
||||||
|
required String idCardFront,
|
||||||
|
required String idCardBack,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/auth-personal', {
|
||||||
|
'name': name,
|
||||||
|
'id_card': idCard,
|
||||||
|
'id_card_front': idCardFront,
|
||||||
|
'id_card_back': idCardBack,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 企业认证
|
||||||
|
Future<BaseResponse<bool>> authEnterprise({
|
||||||
|
required String companyName,
|
||||||
|
required String licenseNo,
|
||||||
|
required String licenseImg,
|
||||||
|
String? legalPerson,
|
||||||
|
String? legalIdCard,
|
||||||
|
String? legalIdFront,
|
||||||
|
String? legalIdBack,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/auth-enterprise', {
|
||||||
|
'company_name': companyName,
|
||||||
|
'license_no': licenseNo,
|
||||||
|
'license_img': licenseImg,
|
||||||
|
'legal_person': legalPerson,
|
||||||
|
'legal_id_card': legalIdCard,
|
||||||
|
'legal_id_front': legalIdFront,
|
||||||
|
'legal_id_back': legalIdBack,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 司机认证
|
||||||
|
Future<BaseResponse<bool>> authDriver({
|
||||||
|
required String driverNo,
|
||||||
|
required String driverLicense,
|
||||||
|
String? drivingLicenseFront,
|
||||||
|
String? drivingLicenseBack,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/auth-driver', {
|
||||||
|
'driver_no': driverNo,
|
||||||
|
'driver_license': driverLicense,
|
||||||
|
'driving_license_front': drivingLicenseFront,
|
||||||
|
'driving_license_back': drivingLicenseBack,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提现申请
|
||||||
|
Future<BaseResponse<bool>> applyWithdrawal({
|
||||||
|
required double amount,
|
||||||
|
required String bankAccount,
|
||||||
|
String? realName,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/withdraw', {
|
||||||
|
'amount': amount,
|
||||||
|
'bank_account': bankAccount,
|
||||||
|
'real_name': realName,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提现记录
|
||||||
|
Future<BaseResponse<PageResult<WithdrawalRecord>>> withdrawalRecords({
|
||||||
|
int page = 1,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.get('/v2/user/withdraw-records', {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => PageResult.fromJson(d as Map<String, dynamic>,
|
||||||
|
(e) => WithdrawalRecord.fromJson(e as Map<String, dynamic>)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 金额明细
|
||||||
|
Future<BaseResponse<PageResult<AmountItem>>> amountList({
|
||||||
|
int page = 1,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.get('/v2/user/amount-list', {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => PageResult.fromJson(d as Map<String, dynamic>,
|
||||||
|
(e) => AmountItem.fromJson(e as Map<String, dynamic>)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 充值
|
||||||
|
Future<BaseResponse<Recharge>> recharge({
|
||||||
|
required double amount,
|
||||||
|
required String payMethod,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/recharge', {
|
||||||
|
'amount': amount,
|
||||||
|
'pay_method': payMethod,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => Recharge.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 反馈
|
||||||
|
Future<BaseResponse<bool>> submitFeedback({
|
||||||
|
required String content,
|
||||||
|
String? contact,
|
||||||
|
List<String>? images,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/feedback', {
|
||||||
|
'content': content,
|
||||||
|
'contact': contact,
|
||||||
|
'images': images,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消息中心
|
||||||
|
Future<BaseResponse<PageResult<MessengerItem>>> messages({
|
||||||
|
int page = 1,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.get('/v2/user/messages', {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => PageResult.fromJson(d as Map<String, dynamic>,
|
||||||
|
(e) => MessengerItem.fromJson(e as Map<String, dynamic>)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记消息已读
|
||||||
|
Future<BaseResponse<bool>> readMessage(int id) async {
|
||||||
|
final r = await _http.post('/v2/user/message/read', {'id': id});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务类型列表
|
||||||
|
Future<BaseResponse<List<ServiceType>>> serviceTypes() async {
|
||||||
|
final r = await _http.get('/v2/order/service-types');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => (d as List).map((e) => ServiceType.fromJson(e as Map<String, dynamic>)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查更新
|
||||||
|
Future<BaseResponse<AppUpdate>> checkUpdate() async {
|
||||||
|
final r = await _http.get('/v2/app/check-update');
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => AppUpdate.fromJson(d as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 心跳
|
||||||
|
Future<BaseResponse<bool>> heartbeat({
|
||||||
|
double? lat,
|
||||||
|
double? lng,
|
||||||
|
required int onlineTime,
|
||||||
|
}) async {
|
||||||
|
final r = await _http.post('/v2/user/heartbeat', {
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng,
|
||||||
|
'online_time': onlineTime,
|
||||||
|
});
|
||||||
|
return BaseResponse.fromJson(r as Map<String, dynamic>,
|
||||||
|
(d) => d as bool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import 'package:autosos_flutter/config/theme_colors.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class AutososApp extends ConsumerStatefulWidget {
|
||||||
|
const AutososApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AutososApp> createState() => _AutososAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutososAppState extends ConsumerState<AutososApp> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: '啾啾救援',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: ThemeColors.primary),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFF3F3F3),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: ThemeColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
routerConfig: router,
|
||||||
|
builder: EasyLoading.init(
|
||||||
|
builder: (context, child) {
|
||||||
|
return MediaQuery(
|
||||||
|
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)),
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
lib/main.dart
256
lib/main.dart
|
|
@ -1,197 +1,117 @@
|
||||||
|
import 'package:autosos_flutter/app.dart';
|
||||||
import 'dart:io';
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
|
import 'package:autosos_flutter/service/background_service.dart';
|
||||||
import 'package:autosos_flutter/config/theme_colors.dart';
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
import 'package:autosos_flutter/pages/login/login_page.dart';
|
import 'package:autosos_flutter/service/push_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/update_service.dart';
|
||||||
import 'package:autosos_flutter/util/sp_util.dart';
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:autosos_flutter/util/xhttp.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:getuiflut/getuiflut.dart';
|
import 'package:sentry_dio/sentry_dio.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
import 'config/getui_constant.dart';
|
/// 应用入口
|
||||||
|
///
|
||||||
|
/// 启动顺序:
|
||||||
|
/// 1. 屏幕方向 / 状态栏
|
||||||
|
/// 2. SP 预加载
|
||||||
|
/// 3. Sentry 崩溃监控(包住 runApp)
|
||||||
|
/// 4. App 启动后:后台服务 / 定位 / 推送 / 检查更新
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
void main() {
|
// 强制竖屏
|
||||||
SharedPreferences.setMockInitialValues({});
|
await SystemChrome.setPreferredOrientations(const [
|
||||||
runApp(const MyApp());
|
DeviceOrientation.portraitUp,
|
||||||
|
DeviceOrientation.portraitDown,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 状态栏样式
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
statusBarIconBrightness: Brightness.light,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 预加载 SP
|
||||||
|
SPUtil().init();
|
||||||
|
|
||||||
|
// 恢复登录态
|
||||||
|
final savedToken = SPUtil().get('accessToken') as String?;
|
||||||
|
if (savedToken != null && savedToken.isNotEmpty) {
|
||||||
|
Constant.newVersion =
|
||||||
|
(SPUtil().get('appver') as String?) ?? Constant.newVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
// 启动 Sentry(崩溃监控 + Dio 拦截)
|
||||||
const MyApp({super.key});
|
await SentryFlutter.init(
|
||||||
|
(options) {
|
||||||
|
options.dsn = const String.fromEnvironment(
|
||||||
|
'SENTRY_DSN',
|
||||||
|
defaultValue: '',
|
||||||
|
);
|
||||||
|
options.tracesSampleRate = 0.1;
|
||||||
|
options.environment = kDebugMode ? 'debug' : 'release';
|
||||||
|
},
|
||||||
|
appRunner: () async {
|
||||||
|
// 注册 Dio → Sentry 拦截(SentryDioExtension 是 Dio 的扩展)
|
||||||
|
XHttp.dio.addSentry();
|
||||||
|
|
||||||
@override
|
// App 启动后异步执行:
|
||||||
_MyAppState createState() => _MyAppState();
|
_postBoot();
|
||||||
}
|
|
||||||
|
|
||||||
class _MyAppState extends State<MyApp> {
|
runApp(
|
||||||
// This widget is the root of your application.
|
const ProviderScope(
|
||||||
@override
|
child: AutososApp(),
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'Flutter Demo',
|
|
||||||
theme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: ThemeColors.primary),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
),
|
||||||
home: const LoginPage(),
|
);
|
||||||
builder: EasyLoading.init(),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _platformVersion = 'Unknown';
|
/// 启动后异步执行:后台服务 / 定位 / 推送 / 更新
|
||||||
String _payloadInfo = 'Null';
|
/// 任何步骤失败都不影响 App 启动
|
||||||
String _userMsg = "";
|
Future<void> _postBoot() async {
|
||||||
String _notificationState = "";
|
// 后台保活
|
||||||
String _getClientId = "";
|
|
||||||
String _getDeviceToken = "";
|
|
||||||
String _onReceivePayload = "";
|
|
||||||
String _onReceiveNotificationResponse = "";
|
|
||||||
String _onAppLinkPayLoad = "";
|
|
||||||
|
|
||||||
SPUtil spUtil = SPUtil();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
initPlatformState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform messages are asynchronous, so we initialize in an async method.
|
|
||||||
Future<void> initPlatformState() async {
|
|
||||||
String platformVersion;
|
|
||||||
String payloadInfo = "default";
|
|
||||||
String notificationState = "default";
|
|
||||||
// Platform messages may fail, so we use a try/catch PlatformException.
|
|
||||||
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
getSdkVersion();
|
|
||||||
Getuiflut().startSdk(
|
|
||||||
appId: GetuiConstant.appId,
|
|
||||||
appKey: GetuiConstant.appKey,
|
|
||||||
appSecret: GetuiConstant.appSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
platformVersion = await Getuiflut.platformVersion;
|
await BackgroundService.instance.init();
|
||||||
|
await BackgroundService.instance.start();
|
||||||
print('platformVersion' + platformVersion);
|
|
||||||
} on PlatformException {
|
|
||||||
platformVersion = 'Failed to get platform version.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the widget was removed from the tree while the asynchronous platform
|
|
||||||
// message was in flight, we want to discard the reply rather than calling
|
|
||||||
// setState to update our non-existent appearance.
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_platformVersion = platformVersion;
|
|
||||||
_payloadInfo = payloadInfo;
|
|
||||||
_notificationState = notificationState;
|
|
||||||
});
|
|
||||||
|
|
||||||
Getuiflut().addEventHandler(onReceiveClientId: (String message) async {
|
|
||||||
print("flutter onReceiveClientId: $message");
|
|
||||||
spUtil.setString("clientId", message);
|
|
||||||
setState(() {
|
|
||||||
_getClientId = "ClientId: $message";
|
|
||||||
});
|
|
||||||
}, onReceiveMessageData: (Map<String, dynamic> msg) async {
|
|
||||||
print("flutter onReceiveMessageData: $msg");
|
|
||||||
setState(() {
|
|
||||||
_payloadInfo = msg['payload'];
|
|
||||||
});
|
|
||||||
}, onNotificationMessageArrived: (Map<String, dynamic> msg) async {
|
|
||||||
print("flutter onNotificationMessageArrived: $msg");
|
|
||||||
setState(() {
|
|
||||||
_notificationState = 'Arrived';
|
|
||||||
});
|
|
||||||
}, onNotificationMessageClicked: (Map<String, dynamic> msg) async {
|
|
||||||
print("flutter onNotificationMessageClicked: $msg");
|
|
||||||
setState(() {
|
|
||||||
_notificationState = 'Clicked';
|
|
||||||
});
|
|
||||||
}, onTransmitUserMessageReceive: (Map<String, dynamic> msg) async {
|
|
||||||
print("flutter onTransmitUserMessageReceive:$msg");
|
|
||||||
setState(() {
|
|
||||||
_userMsg = msg["msg"];
|
|
||||||
});
|
|
||||||
}, onRegisterDeviceToken: (String message) async {
|
|
||||||
print("flutter onRegisterDeviceToken: $message");
|
|
||||||
setState(() {
|
|
||||||
_getDeviceToken = "$message";
|
|
||||||
});
|
|
||||||
}, onReceivePayload: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onReceivePayload: $message");
|
|
||||||
setState(() {
|
|
||||||
_onReceivePayload = "$message";
|
|
||||||
});
|
|
||||||
}, onReceiveNotificationResponse: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onReceiveNotificationResponse: $message");
|
|
||||||
setState(() {
|
|
||||||
_onReceiveNotificationResponse = "$message";
|
|
||||||
});
|
|
||||||
}, onAppLinkPayload: (String message) async {
|
|
||||||
print("flutter onAppLinkPayload: $message");
|
|
||||||
setState(() {
|
|
||||||
_onAppLinkPayLoad = "$message";
|
|
||||||
});
|
|
||||||
}, onPushModeResult: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onPushModeResult: $message");
|
|
||||||
}, onSetTagResult: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onSetTagResult: $message");
|
|
||||||
}, onAliasResult: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onAliasResult: $message");
|
|
||||||
}, onQueryTagResult: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onQueryTagResult: $message");
|
|
||||||
}, onWillPresentNotification: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onWillPresentNotification: $message");
|
|
||||||
}, onOpenSettingsForNotification: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onOpenSettingsForNotification: $message");
|
|
||||||
}, onGrantAuthorization: (String granted) async {
|
|
||||||
print("flutter onGrantAuthorization: $granted");
|
|
||||||
}, onLiveActivityResult: (Map<String, dynamic> message) async {
|
|
||||||
print("flutter onLiveActivityResult: $message");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> initGetuiSdk() async {
|
|
||||||
try {
|
|
||||||
Getuiflut.initGetuiSdk;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
e.toString();
|
debugPrint('BG init failed: $e');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getClientId() async {
|
// 定位
|
||||||
String getClientId;
|
|
||||||
try {
|
try {
|
||||||
getClientId = await Getuiflut.getClientId;
|
await LocationService.instance.init();
|
||||||
print(getClientId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(e.toString());
|
debugPrint('Location init failed: $e');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getSdkVersion() async {
|
// 个推
|
||||||
String ver;
|
|
||||||
try {
|
try {
|
||||||
ver = await Getuiflut.sdkVersion;
|
PushService.instance.init();
|
||||||
print(ver);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(e.toString());
|
debugPrint('Push init failed: $e');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getLaunchNotification() async {
|
// 延迟 2s 后检查更新(避免影响冷启动)
|
||||||
Map info;
|
Future.delayed(const Duration(seconds: 2), () async {
|
||||||
try {
|
try {
|
||||||
info = await Getuiflut.getLaunchNotification;
|
final update = await UpdateService.instance.checkUpdate();
|
||||||
print(info);
|
if (update != null && update.latestVersion.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'Update available: ${update.latestVersion}, force=${update.forceUpdate}',
|
||||||
|
);
|
||||||
|
// 真正的弹窗由 SettingPage 触发
|
||||||
|
Constant.NECUPDATE = update.forceUpdate ? 3 : 2;
|
||||||
|
} else {
|
||||||
|
Constant.NECUPDATE = 1;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(e.toString());
|
debugPrint('Update check failed: $e');
|
||||||
|
Constant.NECUPDATE = 5;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/// 通用后端响应(对齐 Android R<T>)
|
||||||
|
class BaseResponse<T> {
|
||||||
|
final int code;
|
||||||
|
final String? message;
|
||||||
|
final T? data;
|
||||||
|
|
||||||
|
const BaseResponse({required this.code, this.message, this.data});
|
||||||
|
|
||||||
|
bool get isSuccess => code == 1;
|
||||||
|
|
||||||
|
factory BaseResponse.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(dynamic json) fromData,
|
||||||
|
) {
|
||||||
|
return BaseResponse(
|
||||||
|
code: json['code'] as int? ?? -1,
|
||||||
|
message: json['message'] as String?,
|
||||||
|
data: json['data'] == null ? null : fromData(json['data']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页结果
|
||||||
|
class PageResult<T> {
|
||||||
|
final List<T> items;
|
||||||
|
final int total;
|
||||||
|
final int page;
|
||||||
|
final int size;
|
||||||
|
|
||||||
|
const PageResult({
|
||||||
|
required this.items,
|
||||||
|
required this.total,
|
||||||
|
required this.page,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 列表别名(与 items 等价)
|
||||||
|
List<T> get list => items;
|
||||||
|
|
||||||
|
/// 是否有下一页
|
||||||
|
bool get hasMore => items.length >= size && page * size < total;
|
||||||
|
|
||||||
|
factory PageResult.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(dynamic json) fromItem,
|
||||||
|
) {
|
||||||
|
final list = (json['items'] ?? json['list'] ?? json['data'] ?? []) as List;
|
||||||
|
return PageResult<T>(
|
||||||
|
items: list.map((e) => fromItem(e)).toList(),
|
||||||
|
total: json['total'] as int? ?? 0,
|
||||||
|
page: json['page'] as int? ?? 1,
|
||||||
|
size: json['size'] as int? ?? 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
|
||||||
|
/// 七牛上传凭证(对应 Android QiNiuEntity)
|
||||||
|
class QiNiuToken {
|
||||||
|
final String token;
|
||||||
|
final String domain; // 上传后返回的访问域名
|
||||||
|
final int expireAt; // 过期时间戳
|
||||||
|
|
||||||
|
const QiNiuToken({
|
||||||
|
required this.token,
|
||||||
|
required this.domain,
|
||||||
|
required this.expireAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QiNiuToken.fromJson(Map<String, dynamic> json) => QiNiuToken(
|
||||||
|
token: json['token'] as String? ?? '',
|
||||||
|
domain: json['domain'] as String? ?? '',
|
||||||
|
expireAt: json['expire_at'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 二维码(对应 Android WxEwmEntity)
|
||||||
|
class QrCode {
|
||||||
|
final String url;
|
||||||
|
final String? scene;
|
||||||
|
final int expireAt;
|
||||||
|
|
||||||
|
const QrCode({
|
||||||
|
required this.url,
|
||||||
|
this.scene,
|
||||||
|
required this.expireAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QrCode.fromJson(Map<String, dynamic> json) => QrCode(
|
||||||
|
url: json['url'] as String? ?? '',
|
||||||
|
scene: json['scene'] as String?,
|
||||||
|
expireAt: json['expire_at'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证码发送结果(对应 Android BindPhoneEntity)
|
||||||
|
class SmsResult {
|
||||||
|
final bool sent;
|
||||||
|
final int cooldown; // 倒计时秒数
|
||||||
|
|
||||||
|
const SmsResult({required this.sent, required this.cooldown});
|
||||||
|
|
||||||
|
factory SmsResult.fromJson(Map<String, dynamic> json) => SmsResult(
|
||||||
|
sent: json['sent'] as bool? ?? false,
|
||||||
|
cooldown: json['cooldown'] as int? ?? 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务类型(对应 Android ServiceType)
|
||||||
|
class ServiceType {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String? icon;
|
||||||
|
final double basePrice;
|
||||||
|
|
||||||
|
const ServiceType({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.icon,
|
||||||
|
required this.basePrice,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ServiceType.fromJson(Map<String, dynamic> json) => ServiceType(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
icon: json['icon'] as String?,
|
||||||
|
basePrice: (json['base_price'] as num?)?.toDouble() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消息中心项(对应 Android MessengerItemViewModel)
|
||||||
|
class MessengerItem {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final bool read;
|
||||||
|
final String? link;
|
||||||
|
|
||||||
|
const MessengerItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
this.read = false,
|
||||||
|
this.link,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MessengerItem.fromJson(Map<String, dynamic> json) => MessengerItem(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
content: json['content'] as String? ?? '',
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
read: json['read'] as bool? ?? false,
|
||||||
|
link: json['link'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用更新信息(对应 Android UpdateStatusRes)
|
||||||
|
class AppUpdate {
|
||||||
|
final bool forceUpdate; // 是否强制
|
||||||
|
final String latestVersion; // 最新版本号
|
||||||
|
final String? downloadUrl; // APK 下载地址
|
||||||
|
final String? changelog; // 更新日志
|
||||||
|
final int versionCode;
|
||||||
|
|
||||||
|
const AppUpdate({
|
||||||
|
required this.forceUpdate,
|
||||||
|
required this.latestVersion,
|
||||||
|
required this.versionCode,
|
||||||
|
this.downloadUrl,
|
||||||
|
this.changelog,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AppUpdate.fromJson(Map<String, dynamic> json) => AppUpdate(
|
||||||
|
forceUpdate: json['force_update'] as bool? ?? false,
|
||||||
|
latestVersion: json['latest_version'] as String? ?? '',
|
||||||
|
downloadUrl: json['download_url'] as String?,
|
||||||
|
changelog: json['changelog'] as String?,
|
||||||
|
versionCode: json['version_code'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 反馈(对应 Android Feedback)
|
||||||
|
class Feedback {
|
||||||
|
final int id;
|
||||||
|
final String content;
|
||||||
|
final String? contact;
|
||||||
|
final List<String>? images;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const Feedback({
|
||||||
|
required this.id,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
this.contact,
|
||||||
|
this.images,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Feedback.fromJson(Map<String, dynamic> json) => Feedback(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
content: json['content'] as String? ?? '',
|
||||||
|
contact: json['contact'] as String?,
|
||||||
|
images: (json['images'] as List?)?.cast<String>(),
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成订单详情(对应 Android FinishOrderEntity / CompleteDetails)
|
||||||
|
class CompleteOrderInfo {
|
||||||
|
final int orderId;
|
||||||
|
final double totalAmount;
|
||||||
|
final double? discount;
|
||||||
|
final double? serviceFee;
|
||||||
|
final String? remark;
|
||||||
|
final List<AmountItem> amountItems;
|
||||||
|
|
||||||
|
const CompleteOrderInfo({
|
||||||
|
required this.orderId,
|
||||||
|
required this.totalAmount,
|
||||||
|
this.discount,
|
||||||
|
this.serviceFee,
|
||||||
|
this.remark,
|
||||||
|
this.amountItems = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CompleteOrderInfo.fromJson(Map<String, dynamic> json) =>
|
||||||
|
CompleteOrderInfo(
|
||||||
|
orderId: json['order_id'] as int? ?? 0,
|
||||||
|
totalAmount: (json['total_amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
discount: (json['discount'] as num?)?.toDouble(),
|
||||||
|
serviceFee: (json['service_fee'] as num?)?.toDouble(),
|
||||||
|
remark: json['remark'] as String?,
|
||||||
|
amountItems: ((json['amount_items'] as List?) ?? [])
|
||||||
|
.map((e) => AmountItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 救援轨迹点(用于 AMap Track 录制)
|
||||||
|
class TrackPoint {
|
||||||
|
final double lat;
|
||||||
|
final double lng;
|
||||||
|
final DateTime time;
|
||||||
|
final double? speed;
|
||||||
|
final double? bearing;
|
||||||
|
|
||||||
|
const TrackPoint({
|
||||||
|
required this.lat,
|
||||||
|
required this.lng,
|
||||||
|
required this.time,
|
||||||
|
this.speed,
|
||||||
|
this.bearing,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng,
|
||||||
|
'time': time.toIso8601String(),
|
||||||
|
'speed': speed,
|
||||||
|
'bearing': bearing,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory TrackPoint.fromJson(Map<String, dynamic> json) => TrackPoint(
|
||||||
|
lat: (json['lat'] as num?)?.toDouble() ?? 0,
|
||||||
|
lng: (json['lng'] as num?)?.toDouble() ?? 0,
|
||||||
|
time: DateTime.tryParse(json['time'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
speed: (json['speed'] as num?)?.toDouble(),
|
||||||
|
bearing: (json['bearing'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import 'package:autosos_flutter/model/user.dart';
|
||||||
|
|
||||||
|
/// 订单状态枚举(对应 Android ui.order.* 状态机)
|
||||||
|
enum OrderStatus {
|
||||||
|
pending, // 待处理(check 阶段)
|
||||||
|
waiting, // 等待接单
|
||||||
|
accepted, // 已接单
|
||||||
|
conducting, // 救援中
|
||||||
|
photographing, // 拍照取证
|
||||||
|
signing, // 客户签名
|
||||||
|
collecting, // 收款中
|
||||||
|
completed, // 已完成
|
||||||
|
cancelled, // 已取消
|
||||||
|
offline, // 离线模式
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OrderStatusX on OrderStatus {
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case OrderStatus.pending: return '待核单';
|
||||||
|
case OrderStatus.waiting: return '等待接单';
|
||||||
|
case OrderStatus.accepted: return '已接单';
|
||||||
|
case OrderStatus.conducting: return '救援中';
|
||||||
|
case OrderStatus.photographing: return '拍照取证';
|
||||||
|
case OrderStatus.signing: return '客户签字';
|
||||||
|
case OrderStatus.collecting: return '收款中';
|
||||||
|
case OrderStatus.completed: return '已完成';
|
||||||
|
case OrderStatus.cancelled: return '已取消';
|
||||||
|
case OrderStatus.offline: return '离线模式';
|
||||||
|
case OrderStatus.unknown: return '未知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static OrderStatus fromString(String? s) {
|
||||||
|
switch (s) {
|
||||||
|
case 'pending': return OrderStatus.pending;
|
||||||
|
case 'waiting': return OrderStatus.waiting;
|
||||||
|
case 'accepted': return OrderStatus.accepted;
|
||||||
|
case 'conducting': return OrderStatus.conducting;
|
||||||
|
case 'photographing': return OrderStatus.photographing;
|
||||||
|
case 'signing': return OrderStatus.signing;
|
||||||
|
case 'collecting': return OrderStatus.collecting;
|
||||||
|
case 'completed': return OrderStatus.completed;
|
||||||
|
case 'cancelled': return OrderStatus.cancelled;
|
||||||
|
case 'offline': return OrderStatus.offline;
|
||||||
|
default: return OrderStatus.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单主体(对应 Android OrderDetailEntity / OrderList)
|
||||||
|
class Order {
|
||||||
|
final int id;
|
||||||
|
final String orderNo; // 订单编号
|
||||||
|
final OrderStatus status;
|
||||||
|
final String serviceType; // 救援类型:非事故拖车、事故拖车、应急搭电等
|
||||||
|
final String? address; // 救援地点
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
final String? customerName;
|
||||||
|
final String? customerPhone;
|
||||||
|
final String? carNo; // 车牌号
|
||||||
|
final String? carType; // 车型
|
||||||
|
final String? description; // 描述/备注
|
||||||
|
final double amount; // 总金额
|
||||||
|
final double? mileage; // 拖车里程
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? acceptedAt;
|
||||||
|
final DateTime? arrivedAt;
|
||||||
|
final DateTime? completedAt;
|
||||||
|
final List<String> photoUrls; // 现场照片
|
||||||
|
final String? signatureUrl; // 客户签字
|
||||||
|
final String? payMethod; // wx / cash
|
||||||
|
final UserInfo? technician; // 接单技师
|
||||||
|
|
||||||
|
const Order({
|
||||||
|
required this.id,
|
||||||
|
required this.orderNo,
|
||||||
|
required this.status,
|
||||||
|
required this.serviceType,
|
||||||
|
required this.amount,
|
||||||
|
required this.createdAt,
|
||||||
|
this.address,
|
||||||
|
this.lat,
|
||||||
|
this.lng,
|
||||||
|
this.customerName,
|
||||||
|
this.customerPhone,
|
||||||
|
this.carNo,
|
||||||
|
this.carType,
|
||||||
|
this.description,
|
||||||
|
this.mileage,
|
||||||
|
this.acceptedAt,
|
||||||
|
this.arrivedAt,
|
||||||
|
this.completedAt,
|
||||||
|
this.photoUrls = const [],
|
||||||
|
this.signatureUrl,
|
||||||
|
this.payMethod,
|
||||||
|
this.technician,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Order.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Order(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
orderNo: json['order_no'] as String? ?? '',
|
||||||
|
status: OrderStatusX.fromString(json['status'] as String?),
|
||||||
|
serviceType: json['service_type'] as String? ?? '',
|
||||||
|
address: json['address'] as String?,
|
||||||
|
lat: (json['lat'] as num?)?.toDouble(),
|
||||||
|
lng: (json['lng'] as num?)?.toDouble(),
|
||||||
|
customerName: json['customer_name'] as String?,
|
||||||
|
customerPhone: json['customer_phone'] as String?,
|
||||||
|
carNo: json['car_no'] as String?,
|
||||||
|
carType: json['car_type'] as String?,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
mileage: (json['mileage'] as num?)?.toDouble(),
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
acceptedAt: json['accepted_at'] != null
|
||||||
|
? DateTime.tryParse(json['accepted_at'] as String)
|
||||||
|
: null,
|
||||||
|
arrivedAt: json['arrived_at'] != null
|
||||||
|
? DateTime.tryParse(json['arrived_at'] as String)
|
||||||
|
: null,
|
||||||
|
completedAt: json['completed_at'] != null
|
||||||
|
? DateTime.tryParse(json['completed_at'] as String)
|
||||||
|
: null,
|
||||||
|
photoUrls: (json['photo_urls'] as List?)?.cast<String>() ?? const [],
|
||||||
|
signatureUrl: json['signature_url'] as String?,
|
||||||
|
payMethod: json['pay_method'] as String?,
|
||||||
|
technician: json['technician'] != null
|
||||||
|
? UserInfo.fromJson(json['technician'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'order_no': orderNo,
|
||||||
|
'status': status.name,
|
||||||
|
'service_type': serviceType,
|
||||||
|
'address': address,
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng,
|
||||||
|
'customer_name': customerName,
|
||||||
|
'customer_phone': customerPhone,
|
||||||
|
'car_no': carNo,
|
||||||
|
'car_type': carType,
|
||||||
|
'description': description,
|
||||||
|
'amount': amount,
|
||||||
|
'mileage': mileage,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'accepted_at': acceptedAt?.toIso8601String(),
|
||||||
|
'arrived_at': arrivedAt?.toIso8601String(),
|
||||||
|
'completed_at': completedAt?.toIso8601String(),
|
||||||
|
'photo_urls': photoUrls,
|
||||||
|
'signature_url': signatureUrl,
|
||||||
|
'pay_method': payMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
Order copyWith({
|
||||||
|
OrderStatus? status,
|
||||||
|
double? amount,
|
||||||
|
DateTime? arrivedAt,
|
||||||
|
DateTime? completedAt,
|
||||||
|
List<String>? photoUrls,
|
||||||
|
String? signatureUrl,
|
||||||
|
String? payMethod,
|
||||||
|
}) {
|
||||||
|
return Order(
|
||||||
|
id: id,
|
||||||
|
orderNo: orderNo,
|
||||||
|
status: status ?? this.status,
|
||||||
|
serviceType: serviceType,
|
||||||
|
address: address,
|
||||||
|
lat: lat,
|
||||||
|
lng: lng,
|
||||||
|
customerName: customerName,
|
||||||
|
customerPhone: customerPhone,
|
||||||
|
carNo: carNo,
|
||||||
|
carType: carType,
|
||||||
|
description: description,
|
||||||
|
amount: amount ?? this.amount,
|
||||||
|
mileage: mileage,
|
||||||
|
createdAt: createdAt,
|
||||||
|
acceptedAt: acceptedAt,
|
||||||
|
arrivedAt: arrivedAt ?? this.arrivedAt,
|
||||||
|
completedAt: completedAt ?? this.completedAt,
|
||||||
|
photoUrls: photoUrls ?? this.photoUrls,
|
||||||
|
signatureUrl: signatureUrl ?? this.signatureUrl,
|
||||||
|
payMethod: payMethod ?? this.payMethod,
|
||||||
|
technician: technician,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核单列表项(对应 Android CheckItemViewModel)
|
||||||
|
class CheckItem {
|
||||||
|
final int orderId;
|
||||||
|
final String orderNo;
|
||||||
|
final String serviceType;
|
||||||
|
final String? address;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final double amount;
|
||||||
|
final bool checked; // 是否已核单
|
||||||
|
|
||||||
|
const CheckItem({
|
||||||
|
required this.orderId,
|
||||||
|
required this.orderNo,
|
||||||
|
required this.serviceType,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.amount,
|
||||||
|
this.address,
|
||||||
|
this.checked = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CheckItem.fromJson(Map<String, dynamic> json) => CheckItem(
|
||||||
|
orderId: json['order_id'] as int? ?? 0,
|
||||||
|
orderNo: json['order_no'] as String? ?? '',
|
||||||
|
serviceType: json['service_type'] as String? ?? '',
|
||||||
|
address: json['address'] as String?,
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
checked: json['checked'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等待接单项(对应 Android OrderWaitingItemViewModel)
|
||||||
|
class WaitingOrder {
|
||||||
|
final int orderId;
|
||||||
|
final String orderNo;
|
||||||
|
final String serviceType;
|
||||||
|
final String? address;
|
||||||
|
final double? distance; // 距离技师当前位置
|
||||||
|
final double amount;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final int countdown; // 接单倒计时(秒)
|
||||||
|
|
||||||
|
const WaitingOrder({
|
||||||
|
required this.orderId,
|
||||||
|
required this.orderNo,
|
||||||
|
required this.serviceType,
|
||||||
|
required this.amount,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.countdown,
|
||||||
|
this.address,
|
||||||
|
this.distance,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WaitingOrder.fromJson(Map<String, dynamic> json) => WaitingOrder(
|
||||||
|
orderId: json['order_id'] as int? ?? 0,
|
||||||
|
orderNo: json['order_no'] as String? ?? '',
|
||||||
|
serviceType: json['service_type'] as String? ?? '',
|
||||||
|
address: json['address'] as String?,
|
||||||
|
distance: (json['distance'] as num?)?.toDouble(),
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
countdown: json['countdown'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/// 微信支付(对应 Android WxPayEntity)
|
||||||
|
class WxPay {
|
||||||
|
final String appId;
|
||||||
|
final String partnerId;
|
||||||
|
final String prepayId;
|
||||||
|
final String packageValue;
|
||||||
|
final String nonceStr;
|
||||||
|
final String timeStamp;
|
||||||
|
final String sign;
|
||||||
|
|
||||||
|
const WxPay({
|
||||||
|
required this.appId,
|
||||||
|
required this.partnerId,
|
||||||
|
required this.prepayId,
|
||||||
|
required this.packageValue,
|
||||||
|
required this.nonceStr,
|
||||||
|
required this.timeStamp,
|
||||||
|
required this.sign,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WxPay.fromJson(Map<String, dynamic> json) => WxPay(
|
||||||
|
appId: json['appid'] as String? ?? '',
|
||||||
|
partnerId: json['partnerid'] as String? ?? '',
|
||||||
|
prepayId: json['prepayid'] as String? ?? '',
|
||||||
|
packageValue: json['package'] as String? ?? 'Sign=WXPay',
|
||||||
|
nonceStr: json['noncestr'] as String? ?? '',
|
||||||
|
timeStamp: json['timestamp'] as String? ?? '',
|
||||||
|
sign: json['sign'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提现记录(对应 Android TiXianListEntity)
|
||||||
|
class WithdrawalRecord {
|
||||||
|
final int id;
|
||||||
|
final double amount;
|
||||||
|
final String status; // pending / success / failed
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? bankAccount;
|
||||||
|
final String? failureReason;
|
||||||
|
|
||||||
|
const WithdrawalRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.amount,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
this.bankAccount,
|
||||||
|
this.failureReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WithdrawalRecord.fromJson(Map<String, dynamic> json) =>
|
||||||
|
WithdrawalRecord(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
status: json['status'] as String? ?? 'pending',
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
bankAccount: json['bank_account'] as String?,
|
||||||
|
failureReason: json['failure_reason'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 金额明细(对应 Android AmountListItemViewModel)
|
||||||
|
class AmountItem {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final double amount;
|
||||||
|
final String type; // income / expense
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? orderNo;
|
||||||
|
|
||||||
|
const AmountItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.amount,
|
||||||
|
required this.type,
|
||||||
|
required this.createdAt,
|
||||||
|
this.orderNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AmountItem.fromJson(Map<String, dynamic> json) => AmountItem(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
type: json['type'] as String? ?? 'income',
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
orderNo: json['order_no'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'amount': amount,
|
||||||
|
'type': type,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'order_no': orderNo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 充值(对应 Android Recharge)
|
||||||
|
class Recharge {
|
||||||
|
final String id;
|
||||||
|
final double amount;
|
||||||
|
final String payMethod;
|
||||||
|
final String? qrCodeUrl;
|
||||||
|
|
||||||
|
const Recharge({
|
||||||
|
required this.id,
|
||||||
|
required this.amount,
|
||||||
|
required this.payMethod,
|
||||||
|
this.qrCodeUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Recharge.fromJson(Map<String, dynamic> json) => Recharge(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||||
|
payMethod: json['pay_method'] as String? ?? '',
|
||||||
|
qrCodeUrl: json['qr_code_url'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
/// 用户信息(对应 Android UserInfoEntity)
|
||||||
|
class UserInfo {
|
||||||
|
final int id;
|
||||||
|
final String? mobile;
|
||||||
|
final String? phone; // 别名:同 mobile
|
||||||
|
final String? username; // 登录账号
|
||||||
|
final String? name;
|
||||||
|
final String? realName; // 别名:同 name(认证用)
|
||||||
|
final String? avatar;
|
||||||
|
final String? idCard;
|
||||||
|
final String? driverNo; // 司机编号
|
||||||
|
final String? companyName; // 企业名称
|
||||||
|
final String? company; // 别名:同 companyName
|
||||||
|
final String? address; // 联系地址
|
||||||
|
final String? email; // 邮箱
|
||||||
|
final String? authType; // personal / enterprise / driver
|
||||||
|
final String? authStatus; // none / pending / approved / rejected
|
||||||
|
final String? wxOpenid;
|
||||||
|
final String? wxUnionid;
|
||||||
|
final double? balance;
|
||||||
|
final String? token;
|
||||||
|
final int? todayOrders; // 今日订单数
|
||||||
|
final int? totalOrders; // 累计订单数
|
||||||
|
|
||||||
|
const UserInfo({
|
||||||
|
required this.id,
|
||||||
|
this.mobile,
|
||||||
|
this.phone,
|
||||||
|
this.username,
|
||||||
|
this.name,
|
||||||
|
this.realName,
|
||||||
|
this.avatar,
|
||||||
|
this.idCard,
|
||||||
|
this.driverNo,
|
||||||
|
this.companyName,
|
||||||
|
this.company,
|
||||||
|
this.address,
|
||||||
|
this.email,
|
||||||
|
this.authType,
|
||||||
|
this.authStatus,
|
||||||
|
this.wxOpenid,
|
||||||
|
this.wxUnionid,
|
||||||
|
this.balance,
|
||||||
|
this.token,
|
||||||
|
this.todayOrders,
|
||||||
|
this.totalOrders,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isAuthenticated =>
|
||||||
|
authStatus == 'approved' && (authType?.isNotEmpty ?? false);
|
||||||
|
|
||||||
|
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserInfo(
|
||||||
|
id: json['id'] as int? ?? 0,
|
||||||
|
mobile: json['mobile'] as String?,
|
||||||
|
phone: json['phone'] as String? ?? json['mobile'] as String?,
|
||||||
|
username: json['username'] as String?,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
realName: json['real_name'] as String? ?? json['name'] as String?,
|
||||||
|
avatar: json['avatar'] as String?,
|
||||||
|
idCard: json['id_card'] as String?,
|
||||||
|
driverNo: json['driver_no'] as String?,
|
||||||
|
companyName: json['company_name'] as String?,
|
||||||
|
company: json['company'] as String? ?? json['company_name'] as String?,
|
||||||
|
address: json['address'] as String?,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
authType: json['auth_type'] as String?,
|
||||||
|
authStatus: json['auth_status'] as String?,
|
||||||
|
wxOpenid: json['wx_openid'] as String?,
|
||||||
|
wxUnionid: json['wx_unionid'] as String?,
|
||||||
|
balance: (json['balance'] as num?)?.toDouble(),
|
||||||
|
token: json['token'] as String?,
|
||||||
|
todayOrders: json['today_orders'] as int?,
|
||||||
|
totalOrders: json['total_orders'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'mobile': mobile,
|
||||||
|
'phone': phone,
|
||||||
|
'username': username,
|
||||||
|
'name': name,
|
||||||
|
'real_name': realName,
|
||||||
|
'avatar': avatar,
|
||||||
|
'id_card': idCard,
|
||||||
|
'driver_no': driverNo,
|
||||||
|
'company_name': companyName,
|
||||||
|
'company': company,
|
||||||
|
'address': address,
|
||||||
|
'email': email,
|
||||||
|
'auth_type': authType,
|
||||||
|
'auth_status': authStatus,
|
||||||
|
'wx_openid': wxOpenid,
|
||||||
|
'wx_unionid': wxUnionid,
|
||||||
|
'balance': balance,
|
||||||
|
'token': token,
|
||||||
|
'today_orders': todayOrders,
|
||||||
|
'total_orders': totalOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录返回(对应 Android LoginTokenEntity)
|
||||||
|
class LoginToken {
|
||||||
|
final String accessToken;
|
||||||
|
final String? refreshToken;
|
||||||
|
final UserInfo? user;
|
||||||
|
|
||||||
|
const LoginToken({
|
||||||
|
required this.accessToken,
|
||||||
|
this.refreshToken,
|
||||||
|
this.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginToken.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LoginToken(
|
||||||
|
accessToken: json['access_token'] as String? ?? '',
|
||||||
|
refreshToken: json['refresh_token'] as String?,
|
||||||
|
user: json['user'] != null
|
||||||
|
? UserInfo.fromJson(json['user'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
/// 扫码页(对应 Android ScanActivity + zxing)
|
||||||
|
class QrScanPage extends StatefulWidget {
|
||||||
|
const QrScanPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QrScanPage> createState() => _QrScanPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QrScanPageState extends State<QrScanPage> {
|
||||||
|
final MobileScannerController _controller = MobileScannerController(
|
||||||
|
detectionSpeed: DetectionSpeed.normal,
|
||||||
|
facing: CameraFacing.back,
|
||||||
|
);
|
||||||
|
bool _handled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPermission() async {
|
||||||
|
final cam = await Permission.camera.request();
|
||||||
|
if (!cam.isGranted) {
|
||||||
|
if (mounted) {
|
||||||
|
Toast.error('未授予相机权限');
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDetect(BarcodeCapture capture) {
|
||||||
|
if (_handled) return;
|
||||||
|
for (final b in capture.barcodes) {
|
||||||
|
final v = b.rawValue;
|
||||||
|
if (v == null || v.isEmpty) continue;
|
||||||
|
_handled = true;
|
||||||
|
_controller.stop();
|
||||||
|
context.pop(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
title: const Text('扫一扫'),
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.flash_on),
|
||||||
|
onPressed: () => _controller.toggleTorch(),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.cameraswitch),
|
||||||
|
onPressed: () => _controller.switchCamera(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: _controller,
|
||||||
|
onDetect: _onDetect,
|
||||||
|
errorBuilder: (ctx, err, child) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'相机错误:${err.errorCode.name}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 扫描框
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.red, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 顶部提示
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'将二维码/条码放入框内,自动扫描',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import 'package:autosos_flutter/api/user_api.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/service/push_service.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 登录页(升级版:手机号 + 验证码 + 密码 + 微信登录)
|
||||||
|
class BindPhonePage extends ConsumerStatefulWidget {
|
||||||
|
const BindPhonePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BindPhonePage> createState() => _BindPhonePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BindPhonePageState extends ConsumerState<BindPhonePage> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tab;
|
||||||
|
final _phoneCtrl = TextEditingController();
|
||||||
|
final _codeCtrl = TextEditingController();
|
||||||
|
final _pwdCtrl = TextEditingController();
|
||||||
|
|
||||||
|
bool _sending = false;
|
||||||
|
int _countdown = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tab = TabController(length: 2, vsync: this);
|
||||||
|
_phoneCtrl.text = '14725803690';
|
||||||
|
_pwdCtrl.text = '123456';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tab.dispose();
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_codeCtrl.dispose();
|
||||||
|
_pwdCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendCode() async {
|
||||||
|
final phone = _phoneCtrl.text.trim();
|
||||||
|
if (phone.length != 11) {
|
||||||
|
Toast.show('请输入正确的手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _sending = true);
|
||||||
|
try {
|
||||||
|
final r = await UserApi.instance.sendSms(phone);
|
||||||
|
if (r.isSuccess) {
|
||||||
|
Toast.show('验证码已发送');
|
||||||
|
_startCountdown(r.data?.cooldown ?? 60);
|
||||||
|
} else {
|
||||||
|
Toast.show(r.message ?? '发送失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show('网络异常');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _sending = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startCountdown(int seconds) {
|
||||||
|
setState(() => _countdown = seconds);
|
||||||
|
Future.doWhile(() async {
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (!mounted) return false;
|
||||||
|
setState(() => _countdown = _countdown - 1);
|
||||||
|
return _countdown > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loginByPassword() async {
|
||||||
|
final phone = _phoneCtrl.text.trim();
|
||||||
|
final pwd = _pwdCtrl.text;
|
||||||
|
if (phone.length != 11 || pwd.isEmpty) {
|
||||||
|
Toast.show('请输入手机号和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final cid = PushService.instance.clientId;
|
||||||
|
final r = await UserApi.instance.login(
|
||||||
|
username: phone, password: pwd, cid: cid,
|
||||||
|
);
|
||||||
|
if (r.isSuccess && r.data != null) {
|
||||||
|
await _onLoginSuccess(r.data!.accessToken);
|
||||||
|
} else {
|
||||||
|
Toast.show(r.message ?? '登录失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show('网络异常');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loginBySms() async {
|
||||||
|
final phone = _phoneCtrl.text.trim();
|
||||||
|
final code = _codeCtrl.text.trim();
|
||||||
|
if (phone.length != 11 || code.isEmpty) {
|
||||||
|
Toast.show('请输入手机号和验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final cid = PushService.instance.clientId;
|
||||||
|
final r = await UserApi.instance.loginBySms(mobile: phone, code: code, cid: cid);
|
||||||
|
if (r.isSuccess && r.data != null) {
|
||||||
|
await _onLoginSuccess(r.data!.accessToken);
|
||||||
|
} else {
|
||||||
|
Toast.show(r.message ?? '登录失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show('网络异常');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoginSuccess(String token) async {
|
||||||
|
// 写入 SP
|
||||||
|
await Future.wait([
|
||||||
|
// 持久化 token
|
||||||
|
]);
|
||||||
|
ref.read(accessTokenProvider.notifier).state = token;
|
||||||
|
Toast.show('登录成功');
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go(Routes.home);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('登录')),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Image.asset('images/1.5x/ic_launcher.png', width: 72, height: 72),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('欢迎登录啾啾救援',
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
||||||
|
const Text('宁波易到互联科技有限公司',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TabBar(
|
||||||
|
controller: _tab,
|
||||||
|
tabs: const [Tab(text: '账号密码'), Tab(text: '短信登录')],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tab,
|
||||||
|
children: [
|
||||||
|
_buildPasswordForm(),
|
||||||
|
_buildSmsForm(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('其他登录方式:'),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.wechat, color: Colors.green, size: 32),
|
||||||
|
onPressed: () => context.push(Routes.bindWx),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhoneField() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Text('+86', style: TextStyle(fontSize: 16)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Container(width: 1, height: 24, color: Colors.grey),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _phoneCtrl,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '请输入手机号',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildPhoneField(),
|
||||||
|
const Divider(),
|
||||||
|
TextField(
|
||||||
|
controller: _pwdCtrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '请输入密码',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loginByPassword,
|
||||||
|
child: const Text('登录'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSmsForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildPhoneField(),
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _codeCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '请输入验证码',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: (_sending || _countdown > 0) ? null : _sendCode,
|
||||||
|
child: Text(
|
||||||
|
_countdown > 0 ? '$_countdown秒后重试' : '获取验证码',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loginBySms,
|
||||||
|
child: const Text('登录'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// 微信绑定页(对应 Android BindWxActivity)
|
||||||
|
class BindWxPage extends ConsumerWidget {
|
||||||
|
const BindWxPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('绑定微信')),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.wechat, size: 96, color: Colors.green),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'点击下方按钮通过微信授权绑定',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
// TODO: 调用 fluwx 发起微信授权
|
||||||
|
// final result = await fluwx.authByWx();
|
||||||
|
// final wxCode = result.code;
|
||||||
|
// ... 调后端
|
||||||
|
Toast.show('微信绑定成功');
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.wechat),
|
||||||
|
label: const Text('微信授权绑定'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
minimumSize: const Size(double.infinity, 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:autosos_flutter/api/login_api.dart';
|
import 'package:autosos_flutter/api/login_api.dart';
|
||||||
import 'package:autosos_flutter/util/sp_util.dart';
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
import 'package:autosos_flutter/util/xhttp.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 金额明细(对应 Android AmountListActivity)
|
||||||
|
class AmountListPage extends ConsumerStatefulWidget {
|
||||||
|
const AmountListPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AmountListPage> createState() => _AmountListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmountListPageState extends ConsumerState<AmountListPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
List<AmountItem> _items = [];
|
||||||
|
int _page = 1;
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
final _scroll = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load(reset: true);
|
||||||
|
_scroll.addListener(() {
|
||||||
|
if (_scroll.position.pixels >=
|
||||||
|
_scroll.position.maxScrollExtent - 200) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scroll.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load({bool reset = false}) async {
|
||||||
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = true;
|
||||||
|
_items = [];
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
}
|
||||||
|
if (!_hasMore || _loadingMore) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final BaseResponse<PageResult<dynamic>> resp =
|
||||||
|
await ref.read(userApiProvider).amountList(
|
||||||
|
page: _page,
|
||||||
|
size: 20,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
if (reset) setState(() => _state = MultiState.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final list = (resp.data?.list as List?)
|
||||||
|
?.map((e) => AmountItem.fromJson(
|
||||||
|
e as Map<String, dynamic>,
|
||||||
|
))
|
||||||
|
.toList() ??
|
||||||
|
const [];
|
||||||
|
_items.addAll(list);
|
||||||
|
_hasMore = (resp.data?.hasMore ?? false) && list.length >= 20;
|
||||||
|
_page++;
|
||||||
|
setState(() {
|
||||||
|
_state = _items.isEmpty ? MultiState.empty : MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted && reset) setState(() => _state = MultiState.error);
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('金额明细')),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () => _load(reset: true),
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
emptyText: '暂无明细',
|
||||||
|
onRetry: () => _load(reset: true),
|
||||||
|
child: ListView.separated(
|
||||||
|
controller: _scroll,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _items.length + (_hasMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i >= _items.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final it = _items[i];
|
||||||
|
final isIncome = it.type == 'income';
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor:
|
||||||
|
(isIncome ? Colors.green : Colors.red).withValues(alpha: 0.1),
|
||||||
|
child: Icon(
|
||||||
|
isIncome ? Icons.add : Icons.remove,
|
||||||
|
color: isIncome ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(it.title),
|
||||||
|
subtitle: Text(
|
||||||
|
'${it.orderNo ?? "—"}\n${fmt.format(it.createdAt)}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
trailing: Text(
|
||||||
|
'${isIncome ? "+" : "-"}¥${it.amount.toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isIncome ? Colors.green : Colors.red,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// 认证页(对应 Android AuthenticationActivity)
|
||||||
|
/// 三种:个人 / 企业 / 司机
|
||||||
|
class AuthenticationPage extends ConsumerStatefulWidget {
|
||||||
|
const AuthenticationPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AuthenticationPage> createState() =>
|
||||||
|
_AuthenticationPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthenticationPageState extends ConsumerState<AuthenticationPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final TabController _tab;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tab = TabController(length: 3, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tab.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('认证'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tab,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: '个人'),
|
||||||
|
Tab(text: '企业'),
|
||||||
|
Tab(text: '司机'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tab,
|
||||||
|
children: const [
|
||||||
|
_PersonalForm(),
|
||||||
|
_EnterpriseForm(),
|
||||||
|
_DriverForm(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonalForm extends ConsumerStatefulWidget {
|
||||||
|
const _PersonalForm();
|
||||||
|
@override
|
||||||
|
ConsumerState<_PersonalForm> createState() => _PersonalFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonalFormState extends ConsumerState<_PersonalForm> {
|
||||||
|
final _name = TextEditingController();
|
||||||
|
final _idCard = TextEditingController();
|
||||||
|
final _front = TextEditingController();
|
||||||
|
final _back = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
_idCard.dispose();
|
||||||
|
_front.dispose();
|
||||||
|
_back.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_name.text.isEmpty || _idCard.text.isEmpty) {
|
||||||
|
Toast.error('请填写姓名和身份证号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_front.text.isEmpty || _back.text.isEmpty) {
|
||||||
|
Toast.error('请上传身份证正反面');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(userApiProvider).authPersonal(
|
||||||
|
name: _name.text.trim(),
|
||||||
|
idCard: _idCard.text.trim(),
|
||||||
|
idCardFront: _front.text.trim(),
|
||||||
|
idCardBack: _back.text.trim(),
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess) {
|
||||||
|
Toast.error(r.message ?? '提交失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('已提交,请等待审核');
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('提交失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _form(children: [
|
||||||
|
_tf(_name, '真实姓名'),
|
||||||
|
_tf(_idCard, '身份证号'),
|
||||||
|
_imgField(_front, '身份证正面 URL(七牛)'),
|
||||||
|
_imgField(_back, '身份证反面 URL(七牛)'),
|
||||||
|
_submitBtn('提交个人认证', _loading, _submit),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnterpriseForm extends ConsumerStatefulWidget {
|
||||||
|
const _EnterpriseForm();
|
||||||
|
@override
|
||||||
|
ConsumerState<_EnterpriseForm> createState() => _EnterpriseFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnterpriseFormState extends ConsumerState<_EnterpriseForm> {
|
||||||
|
final _company = TextEditingController();
|
||||||
|
final _license = TextEditingController();
|
||||||
|
final _licenseImg = TextEditingController();
|
||||||
|
final _legalName = TextEditingController();
|
||||||
|
final _legalId = TextEditingController();
|
||||||
|
final _legalFront = TextEditingController();
|
||||||
|
final _legalBack = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_company.dispose();
|
||||||
|
_license.dispose();
|
||||||
|
_licenseImg.dispose();
|
||||||
|
_legalName.dispose();
|
||||||
|
_legalId.dispose();
|
||||||
|
_legalFront.dispose();
|
||||||
|
_legalBack.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_company.text.isEmpty || _license.text.isEmpty) {
|
||||||
|
Toast.error('请填写公司名和营业执照号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(userApiProvider).authEnterprise(
|
||||||
|
companyName: _company.text.trim(),
|
||||||
|
licenseNo: _license.text.trim(),
|
||||||
|
licenseImg: _licenseImg.text.trim(),
|
||||||
|
legalPerson: _legalName.text.trim(),
|
||||||
|
legalIdCard: _legalId.text.trim(),
|
||||||
|
legalIdFront: _legalFront.text.trim(),
|
||||||
|
legalIdBack: _legalBack.text.trim(),
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess) {
|
||||||
|
Toast.error(r.message ?? '提交失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('已提交,请等待审核');
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('提交失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _form(children: [
|
||||||
|
_tf(_company, '公司名称'),
|
||||||
|
_tf(_license, '营业执照号'),
|
||||||
|
_imgField(_licenseImg, '营业执照图片 URL'),
|
||||||
|
const Divider(),
|
||||||
|
_tf(_legalName, '法人姓名'),
|
||||||
|
_tf(_legalId, '法人身份证号'),
|
||||||
|
_imgField(_legalFront, '法人身份证正面 URL'),
|
||||||
|
_imgField(_legalBack, '法人身份证反面 URL'),
|
||||||
|
_submitBtn('提交企业认证', _loading, _submit),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriverForm extends ConsumerStatefulWidget {
|
||||||
|
const _DriverForm();
|
||||||
|
@override
|
||||||
|
ConsumerState<_DriverForm> createState() => _DriverFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriverFormState extends ConsumerState<_DriverForm> {
|
||||||
|
final _driverNo = TextEditingController();
|
||||||
|
final _driverLicense = TextEditingController();
|
||||||
|
final _front = TextEditingController();
|
||||||
|
final _back = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_driverNo.dispose();
|
||||||
|
_driverLicense.dispose();
|
||||||
|
_front.dispose();
|
||||||
|
_back.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_driverNo.text.isEmpty || _driverLicense.text.isEmpty) {
|
||||||
|
Toast.error('请填写驾驶证号和驾驶证');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(userApiProvider).authDriver(
|
||||||
|
driverNo: _driverNo.text.trim(),
|
||||||
|
driverLicense: _driverLicense.text.trim(),
|
||||||
|
drivingLicenseFront: _front.text.trim(),
|
||||||
|
drivingLicenseBack: _back.text.trim(),
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess) {
|
||||||
|
Toast.error(r.message ?? '提交失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('已提交,请等待审核');
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('提交失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _form(children: [
|
||||||
|
_tf(_driverNo, '驾驶证号'),
|
||||||
|
_tf(_driverLicense, '驾驶证档案编号'),
|
||||||
|
_imgField(_front, '驾驶证正面 URL'),
|
||||||
|
_imgField(_back, '驾驶证反面 URL'),
|
||||||
|
_submitBtn('提交司机认证', _loading, _submit),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _form({required List<Widget> children}) => SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: children),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _tf(TextEditingController c, String label) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: c,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _imgField(TextEditingController c, String label) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: c,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: '七牛上传后返回的 URL',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _submitBtn(String text, bool loading, VoidCallback onTap) => SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: loading ? null : onTap,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: Text(loading ? '提交中…' : text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/photo_grid_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// 反馈页(对应 Android FeedbackActivity)
|
||||||
|
class FeedbackPage extends ConsumerStatefulWidget {
|
||||||
|
const FeedbackPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<FeedbackPage> createState() => _FeedbackPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedbackPageState extends ConsumerState<FeedbackPage> {
|
||||||
|
final _content = TextEditingController();
|
||||||
|
final _contact = TextEditingController();
|
||||||
|
final List<String> _images = [];
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_content.dispose();
|
||||||
|
_contact.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImages() async {
|
||||||
|
final files = await AlbumPicker.pick(max: 4);
|
||||||
|
if (files.isEmpty) return;
|
||||||
|
setState(() => _images.addAll(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_content.text.trim().isEmpty) {
|
||||||
|
Toast.error('请填写问题描述');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
List<String>? urls;
|
||||||
|
if (_images.isNotEmpty) {
|
||||||
|
urls = await ref
|
||||||
|
.read(uploadServiceProvider)
|
||||||
|
.uploadBatch(_images);
|
||||||
|
}
|
||||||
|
final r = await ref.read(userApiProvider).submitFeedback(
|
||||||
|
content: _content.text.trim(),
|
||||||
|
contact: _contact.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _contact.text.trim(),
|
||||||
|
images: urls,
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess) {
|
||||||
|
Toast.error(r.message ?? '提交失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('反馈已提交');
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('提交失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('问题反馈')),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _content,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '请描述您遇到的问题…',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _contact,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '联系方式(选填)',
|
||||||
|
hintText: '手机号/微信号',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('截图(选填)',
|
||||||
|
style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
PhotoThumbStrip(
|
||||||
|
paths: _images,
|
||||||
|
onAdd: _pickImages,
|
||||||
|
onRemove: (i) => setState(() => _images.removeAt(i)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _submitting ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
child: Text(_submitting ? '提交中…' : '提交反馈'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
import 'package:autosos_flutter/api/_api_client.dart';
|
||||||
|
import 'package:autosos_flutter/model/user.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 我的中心(对应 Android MeFragment)
|
||||||
|
class MePage extends ConsumerStatefulWidget {
|
||||||
|
const MePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MePage> createState() => _MePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MePageState extends ConsumerState<MePage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
UserInfo? _user;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(userApiProvider).info();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess || resp.data == null) {
|
||||||
|
setState(() => _state = MultiState.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(currentUserProvider.notifier).state = resp.data;
|
||||||
|
setState(() {
|
||||||
|
_user = resp.data;
|
||||||
|
_state = MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _state = MultiState.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('退出登录'),
|
||||||
|
content: const Text('确定要退出当前账号吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('退出'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
EasyLoading.show(status: '退出中…');
|
||||||
|
try {
|
||||||
|
await ref.read(userApiProvider).logout();
|
||||||
|
} catch (_) {}
|
||||||
|
// 清 token + sp
|
||||||
|
SPUtil().remove('accessToken');
|
||||||
|
SPUtil().remove('user');
|
||||||
|
ApiClient.instance.clearToken();
|
||||||
|
ref.read(accessTokenProvider.notifier).state = null;
|
||||||
|
ref.read(currentUserProvider.notifier).state = null;
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (mounted) context.go(Routes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade100,
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
onRetry: _load,
|
||||||
|
child: _user == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: ListView(
|
||||||
|
children: [
|
||||||
|
_header(_user!),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_statsRow(_user!),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_menu(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _logout,
|
||||||
|
icon: const Icon(Icons.logout, color: Colors.red),
|
||||||
|
label: const Text(
|
||||||
|
'退出登录',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.red),
|
||||||
|
minimumSize: const Size.fromHeight(44),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header(UserInfo u) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 36, 16, 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.red.shade400, Colors.red.shade700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 32,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
child: u.avatar != null && u.avatar!.isNotEmpty
|
||||||
|
? ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: u.avatar!,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
(u.realName?.isNotEmpty == true
|
||||||
|
? u.realName![0]
|
||||||
|
: (u.username?.isNotEmpty == true ? u.username![0] : '?'))
|
||||||
|
.toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
u.realName ?? u.username ?? '—',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(u.phone ?? '—',
|
||||||
|
style: const TextStyle(color: Colors.white70)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_badge(u.authStatus ?? 'none'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (u.company != null && u.company!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(u.company!,
|
||||||
|
style: const TextStyle(color: Colors.white70)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.push(Routes.setting),
|
||||||
|
icon: const Icon(Icons.settings, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _badge(String status) {
|
||||||
|
Color bg;
|
||||||
|
String text;
|
||||||
|
switch (status) {
|
||||||
|
case 'personal':
|
||||||
|
bg = Colors.blue;
|
||||||
|
text = '个人认证';
|
||||||
|
break;
|
||||||
|
case 'enterprise':
|
||||||
|
bg = Colors.purple;
|
||||||
|
text = '企业认证';
|
||||||
|
break;
|
||||||
|
case 'driver':
|
||||||
|
bg = Colors.orange;
|
||||||
|
text = '司机认证';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
bg = Colors.grey;
|
||||||
|
text = '未认证';
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(text,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 11)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statsRow(UserInfo u) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_stat('余额', '¥${u.balance?.toStringAsFixed(2) ?? "0.00"}'),
|
||||||
|
_divider(),
|
||||||
|
_stat('今日订单', '${u.todayOrders ?? 0}'),
|
||||||
|
_divider(),
|
||||||
|
_stat('累计订单', '${u.totalOrders ?? 0}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stat(String label, String value) {
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.red,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _divider() =>
|
||||||
|
Container(width: 1, height: 30, color: Colors.grey.shade300);
|
||||||
|
|
||||||
|
Widget _menu() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_menuItem(Icons.account_balance_wallet, '提现', () {
|
||||||
|
context.push(Routes.withdrawal);
|
||||||
|
}),
|
||||||
|
_menuItem(Icons.history, '提现记录', () {
|
||||||
|
context.push(Routes.withdrawalRecord);
|
||||||
|
}),
|
||||||
|
_menuItem(Icons.payment, '充值', () {
|
||||||
|
context.push(Routes.recharge);
|
||||||
|
}),
|
||||||
|
_menuItem(Icons.list_alt, '金额明细', () {
|
||||||
|
context.push(Routes.amountList);
|
||||||
|
}),
|
||||||
|
_dividerLine(),
|
||||||
|
_menuItem(Icons.edit, '完善资料', () {
|
||||||
|
context.push(Routes.perfect);
|
||||||
|
}),
|
||||||
|
_menuItem(Icons.verified_user, '认证', () {
|
||||||
|
context.push(Routes.auth);
|
||||||
|
}),
|
||||||
|
_dividerLine(),
|
||||||
|
_menuItem(Icons.message, '消息中心', () {
|
||||||
|
context.push(Routes.messenger);
|
||||||
|
}),
|
||||||
|
_menuItem(Icons.bug_report, '问题反馈', () {
|
||||||
|
context.push(Routes.feedback);
|
||||||
|
}),
|
||||||
|
_menuItem(Icons.tune, '性能优化', () {
|
||||||
|
context.push(Routes.optimization);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _menuItem(IconData icon, String label, VoidCallback onTap) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.red, size: 22),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(label, style: const TextStyle(fontSize: 15))),
|
||||||
|
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dividerLine() => Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
indent: 16,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import 'package:autosos_flutter/model/misc.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 消息中心(对应 Android MessengerActivity)
|
||||||
|
class MessengerPage extends ConsumerStatefulWidget {
|
||||||
|
const MessengerPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MessengerPage> createState() => _MessengerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessengerPageState extends ConsumerState<MessengerPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
List<MessengerItem> _items = [];
|
||||||
|
int _page = 1;
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
final _scroll = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load(reset: true);
|
||||||
|
_scroll.addListener(() {
|
||||||
|
if (_scroll.position.pixels >=
|
||||||
|
_scroll.position.maxScrollExtent - 200) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scroll.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load({bool reset = false}) async {
|
||||||
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = true;
|
||||||
|
_items = [];
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
}
|
||||||
|
if (!_hasMore || _loadingMore) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(userApiProvider).messages(
|
||||||
|
page: _page,
|
||||||
|
size: 20,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
if (reset) setState(() => _state = MultiState.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final list = resp.data?.list ?? [];
|
||||||
|
_items.addAll(list);
|
||||||
|
_hasMore = (resp.data?.hasMore ?? false) && list.length >= 20;
|
||||||
|
_page++;
|
||||||
|
setState(() {
|
||||||
|
_state = _items.isEmpty ? MultiState.empty : MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted && reset) setState(() => _state = MultiState.error);
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTap(MessengerItem item) async {
|
||||||
|
if (!item.read) {
|
||||||
|
try {
|
||||||
|
await ref.read(userApiProvider).readMessage(item.id);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
final i = _items.indexWhere((e) => e.id == item.id);
|
||||||
|
if (i >= 0) {
|
||||||
|
_items[i] = MessengerItem(
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
read: true,
|
||||||
|
link: item.link,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (item.link != null && item.link!.isNotEmpty) {
|
||||||
|
context.push(
|
||||||
|
'${Routes.webview}?url=${Uri.encodeComponent(item.link!)}&title=${Uri.encodeComponent(item.title)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('消息中心')),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () => _load(reset: true),
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
emptyText: '暂无消息',
|
||||||
|
onRetry: () => _load(reset: true),
|
||||||
|
child: ListView.separated(
|
||||||
|
controller: _scroll,
|
||||||
|
itemCount: _items.length + (_hasMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, __) => Divider(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
indent: 16,
|
||||||
|
),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i >= _items.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final m = _items[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: m.read
|
||||||
|
? Colors.grey.shade300
|
||||||
|
: Theme.of(context).primaryColor,
|
||||||
|
child: const Icon(Icons.notifications,
|
||||||
|
color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
m.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: m.read
|
||||||
|
? FontWeight.normal
|
||||||
|
: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
m.content,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
DateFormat('MM-dd HH:mm').format(m.createdAt),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
onTap: () => _onTap(m),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import 'package:autosos_flutter/service/background_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/push_service.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// 性能优化页(对应 Android OptimizationActivity)
|
||||||
|
/// 检查并展示:定位 / 推送 / 后台 / 心跳 / 网络 各模块状态
|
||||||
|
class OptimizationPage extends ConsumerStatefulWidget {
|
||||||
|
const OptimizationPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<OptimizationPage> createState() => _OptimizationPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptimizationPageState extends ConsumerState<OptimizationPage> {
|
||||||
|
bool _locating = false;
|
||||||
|
String? _lastLoc;
|
||||||
|
String? _cid;
|
||||||
|
bool _bgRunning = false;
|
||||||
|
bool _heartbeat = false;
|
||||||
|
int _online = 0;
|
||||||
|
bool _testing = false;
|
||||||
|
String? _netResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
final loc = LocationService.instance.last;
|
||||||
|
final bg = await BackgroundService.instance.isRunning();
|
||||||
|
setState(() {
|
||||||
|
_cid = PushService.instance.clientId;
|
||||||
|
_bgRunning = bg;
|
||||||
|
_lastLoc = loc == null
|
||||||
|
? null
|
||||||
|
: '(${(loc['latitude'] as num? ?? 0).toDouble().toStringAsFixed(4)}, ${(loc['longitude'] as num? ?? 0).toDouble().toStringAsFixed(4)})';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchOnce() async {
|
||||||
|
setState(() => _locating = true);
|
||||||
|
try {
|
||||||
|
final loc = await LocationService.instance.fetchOnce();
|
||||||
|
if (loc == null) {
|
||||||
|
Toast.error('定位失败');
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_lastLoc =
|
||||||
|
'(${(loc['latitude'] as num? ?? 0).toDouble().toStringAsFixed(4)}, ${(loc['longitude'] as num? ?? 0).toDouble().toStringAsFixed(4)})';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('定位异常:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _locating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testNet() async {
|
||||||
|
setState(() {
|
||||||
|
_testing = true;
|
||||||
|
_netResult = null;
|
||||||
|
});
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final r = await LocationService.instance.fetchOnce();
|
||||||
|
sw.stop();
|
||||||
|
if (r != null) {
|
||||||
|
setState(() {
|
||||||
|
_netResult = '定位耗时 ${sw.elapsedMilliseconds} ms';
|
||||||
|
});
|
||||||
|
Toast.success('网络正常');
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_netResult = '定位失败';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sw.stop();
|
||||||
|
setState(() {
|
||||||
|
_netResult = '异常:$e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _testing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('性能优化')),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
_section('定位服务'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.location_on, color: Colors.red),
|
||||||
|
title: const Text('当前位置'),
|
||||||
|
subtitle: Text(_lastLoc ?? '尚未获取'),
|
||||||
|
trailing: _locating
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
onTap: _fetchOnce,
|
||||||
|
),
|
||||||
|
_section('推送服务'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.notifications, color: Colors.red),
|
||||||
|
title: const Text('个推 CID'),
|
||||||
|
subtitle: Text(_cid ?? '未初始化'),
|
||||||
|
),
|
||||||
|
_section('后台保活'),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.alarm_on,
|
||||||
|
color: _bgRunning ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
title: const Text('Background Service'),
|
||||||
|
subtitle: Text(_bgRunning ? '运行中' : '未运行'),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (_bgRunning) {
|
||||||
|
await BackgroundService.instance.stop();
|
||||||
|
} else {
|
||||||
|
await BackgroundService.instance.start();
|
||||||
|
}
|
||||||
|
_refresh();
|
||||||
|
},
|
||||||
|
child: Text(_bgRunning ? '停止' : '启动'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_section('心跳 / 网络'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.favorite, color: Colors.red),
|
||||||
|
title: const Text('心跳'),
|
||||||
|
subtitle: Text(_heartbeat ? '正常' : '—'),
|
||||||
|
trailing: Text('${_online}s'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.network_check, color: Colors.red),
|
||||||
|
title: const Text('网络测试'),
|
||||||
|
subtitle: Text(_netResult ?? '点击测试'),
|
||||||
|
trailing: _testing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.play_arrow),
|
||||||
|
onTap: _testing ? null : _testNet,
|
||||||
|
),
|
||||||
|
_section('开发者'),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('强制重连推送'),
|
||||||
|
onTap: () async {
|
||||||
|
PushService.instance.init();
|
||||||
|
_refresh();
|
||||||
|
Toast.success('已重连');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _section(String title) => Container(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
width: double.infinity,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
import 'package:autosos_flutter/model/user.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// 完善资料页(对应 Android MePerfectActivity)
|
||||||
|
class PerfectPage extends ConsumerStatefulWidget {
|
||||||
|
const PerfectPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PerfectPage> createState() => _PerfectPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PerfectPageState extends ConsumerState<PerfectPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _realName = TextEditingController();
|
||||||
|
final _idCard = TextEditingController();
|
||||||
|
final _phone = TextEditingController();
|
||||||
|
final _address = TextEditingController();
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _company = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
UserInfo? _user;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_realName.dispose();
|
||||||
|
_idCard.dispose();
|
||||||
|
_phone.dispose();
|
||||||
|
_address.dispose();
|
||||||
|
_email.dispose();
|
||||||
|
_company.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final resp = await ref.read(userApiProvider).info();
|
||||||
|
if (!mounted || !resp.isSuccess) return;
|
||||||
|
final u = resp.data!;
|
||||||
|
setState(() {
|
||||||
|
_user = u;
|
||||||
|
_realName.text = u.realName ?? '';
|
||||||
|
_idCard.text = u.idCard ?? '';
|
||||||
|
_phone.text = u.phone ?? '';
|
||||||
|
_address.text = u.address ?? '';
|
||||||
|
_email.text = u.email ?? '';
|
||||||
|
_company.text = u.company ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '保存中…');
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(userApiProvider).perfectInfo({
|
||||||
|
'real_name': _realName.text.trim(),
|
||||||
|
'id_card': _idCard.text.trim(),
|
||||||
|
'phone': _phone.text.trim(),
|
||||||
|
'address': _address.text.trim(),
|
||||||
|
'email': _email.text.trim(),
|
||||||
|
'company': _company.text.trim(),
|
||||||
|
});
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
Toast.error(resp.message ?? '保存失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(currentUserProvider.notifier).state = resp.data;
|
||||||
|
Toast.success('保存成功');
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('保存失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('完善资料')),
|
||||||
|
body: _user == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _realName,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '真实姓名',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.isEmpty) ? '请输入姓名' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _idCard,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '身份证号',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '联系电话',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _email,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '邮箱',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _company,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '所属公司(选填)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _address,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '常住地址',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).primaryColor,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(_loading ? '保存中…' : '保存'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/qr_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:fluwx/fluwx.dart';
|
||||||
|
|
||||||
|
/// 充值页(对应 Android RechargeActivity)
|
||||||
|
class RechargePage extends ConsumerStatefulWidget {
|
||||||
|
const RechargePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RechargePage> createState() => _RechargePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RechargePageState extends ConsumerState<RechargePage> {
|
||||||
|
final _amount = TextEditingController();
|
||||||
|
String _payMethod = 'wx';
|
||||||
|
String? _qrUrl;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
final _presets = [50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amount.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final amount = double.tryParse(_amount.text.trim());
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
Toast.error('请输入充值金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '生成订单中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(userApiProvider).recharge(
|
||||||
|
amount: amount,
|
||||||
|
payMethod: _payMethod,
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess || r.data == null) {
|
||||||
|
Toast.error(r.message ?? '下单失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 微信支付(带 sign 的预支付)
|
||||||
|
if (_payMethod == 'wx' && r.data is Map) {
|
||||||
|
final m = r.data as Map;
|
||||||
|
await Fluwx().pay(
|
||||||
|
which: Payment(
|
||||||
|
appId: m['appid'] ?? '',
|
||||||
|
partnerId: m['partnerid'] ?? '',
|
||||||
|
prepayId: m['prepayid'] ?? '',
|
||||||
|
packageValue: m['package'] ?? 'Sign=WXPay',
|
||||||
|
nonceStr: m['noncestr'] ?? '',
|
||||||
|
timestamp: int.tryParse(m['timestamp']?.toString() ?? '') ?? 0,
|
||||||
|
sign: m['sign'] ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 二维码
|
||||||
|
if (r.data is Map && (r.data as Map)['qr_code_url'] != null) {
|
||||||
|
setState(() => _qrUrl = (r.data as Map)['qr_code_url']);
|
||||||
|
} else {
|
||||||
|
Toast.success('订单已生成');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('充值失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('充值')),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text('选择充值金额',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _presets
|
||||||
|
.map(
|
||||||
|
(a) => ChoiceChip(
|
||||||
|
label: Text('¥${a.toStringAsFixed(0)}'),
|
||||||
|
selected: _amount.text == a.toStringAsFixed(0),
|
||||||
|
onSelected: (_) =>
|
||||||
|
setState(() => _amount.text = a.toStringAsFixed(0)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _amount,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '其他金额',
|
||||||
|
prefixText: '¥ ',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('支付方式',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
RadioListTile<String>(
|
||||||
|
title: const Text('微信支付'),
|
||||||
|
value: 'wx',
|
||||||
|
groupValue: _payMethod,
|
||||||
|
onChanged: (v) => setState(() => _payMethod = v ?? 'wx'),
|
||||||
|
),
|
||||||
|
if (_qrUrl != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: QrImageView(data: _qrUrl!, size: 200),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Center(
|
||||||
|
child: Text('请使用微信扫码支付',
|
||||||
|
style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(_loading ? '处理中…' : '立即充值'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
import 'package:autosos_flutter/api/_api_client.dart';
|
||||||
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/service/audio_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/background_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/push_service.dart';
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
/// 设置页(对应 Android MeSettingActivity)
|
||||||
|
class SettingPage extends ConsumerStatefulWidget {
|
||||||
|
const SettingPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SettingPage> createState() => _SettingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingPageState extends ConsumerState<SettingPage> {
|
||||||
|
bool _push = true;
|
||||||
|
bool _sound = true;
|
||||||
|
bool _bg = true;
|
||||||
|
String _version = '';
|
||||||
|
String _cid = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
_push = (SPUtil().get('setting.push') as bool?) ?? true;
|
||||||
|
_sound = (SPUtil().get('setting.sound') as bool?) ?? true;
|
||||||
|
_bg = (SPUtil().get('setting.bg') as bool?) ?? true;
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
_cid = PushService.instance.clientId ?? '';
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_version = '${info.version} (${info.buildNumber})';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
SPUtil().setBool('setting.push', _push);
|
||||||
|
SPUtil().setBool('setting.sound', _sound);
|
||||||
|
SPUtil().setBool('setting.bg', _bg);
|
||||||
|
AudioService.instance.enableSound = _sound;
|
||||||
|
if (_bg) {
|
||||||
|
await BackgroundService.instance.start();
|
||||||
|
} else {
|
||||||
|
await BackgroundService.instance.stop();
|
||||||
|
}
|
||||||
|
if (!_push) {
|
||||||
|
PushService.instance.turnOff();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearCache() async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('清除缓存'),
|
||||||
|
content: const Text('将清除图片缓存和临时文件,确定吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('清除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
EasyLoading.show(status: '清除中…');
|
||||||
|
// Flutter 没有原生 clear cache API,这里仅做演示
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.success('缓存已清除');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkUpdate() async {
|
||||||
|
EasyLoading.show(status: '检查中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(userApiProvider).checkUpdate();
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess || r.data == null) {
|
||||||
|
Toast.error('检查失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final u = r.data!;
|
||||||
|
if (u.latestVersion.isEmpty) {
|
||||||
|
Toast.success('已是最新版本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text('发现新版本 v${u.latestVersion}'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (u.changelog != null && u.changelog!.isNotEmpty)
|
||||||
|
Text(u.changelog!,
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
if (u.forceUpdate) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('⚠️ 此版本必须升级',
|
||||||
|
style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('稍后'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
context.push(
|
||||||
|
'${Routes.webview}?url=${Uri.encodeComponent(u.downloadUrl ?? "")}&title=版本更新',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('立即升级'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('检查失败:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('退出登录'),
|
||||||
|
content: const Text('确定要退出当前账号吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('退出'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await ref.read(userApiProvider).logout();
|
||||||
|
} catch (_) {}
|
||||||
|
SPUtil().remove('accessToken');
|
||||||
|
SPUtil().remove('user');
|
||||||
|
ApiClient.instance.clearToken();
|
||||||
|
ref.read(accessTokenProvider.notifier).state = null;
|
||||||
|
if (mounted) context.go(Routes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('设置')),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
_section('消息通知'),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('推送通知'),
|
||||||
|
subtitle: const Text('接收订单推送'),
|
||||||
|
value: _push,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _push = v);
|
||||||
|
_save();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('声音提醒'),
|
||||||
|
subtitle: const Text('新订单播报'),
|
||||||
|
value: _sound,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _sound = v);
|
||||||
|
_save();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_section('服务设置'),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('后台保活'),
|
||||||
|
subtitle: const Text('App 切到后台后保持心跳'),
|
||||||
|
value: _bg,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _bg = v);
|
||||||
|
_save();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('个推 CID'),
|
||||||
|
subtitle: Text(_cid.isEmpty ? '未获取到' : _cid,
|
||||||
|
style: const TextStyle(fontSize: 12)),
|
||||||
|
trailing: const Icon(Icons.copy),
|
||||||
|
onTap: () {
|
||||||
|
if (_cid.isNotEmpty) {
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
// Clipboard.setData(ClipboardData(text: _cid));
|
||||||
|
Toast.success('CID 已复制');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_section('其他'),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('清除缓存'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _clearCache,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('检查更新'),
|
||||||
|
subtitle: Text('当前版本 $_version'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _checkUpdate,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('性能优化'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push(Routes.optimization),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('用户协议'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
context.push(
|
||||||
|
'${Routes.webview}?url=${Uri.encodeComponent(Constant.workOrderUrl)}&title=用户协议',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _logout,
|
||||||
|
icon: const Icon(Icons.logout, color: Colors.red),
|
||||||
|
label: const Text('退出登录',
|
||||||
|
style: TextStyle(color: Colors.red)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.red),
|
||||||
|
minimumSize: const Size.fromHeight(44),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _section(String title) => Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 提现申请页(对应 Android TiXianActivity)
|
||||||
|
class WithdrawalPage extends ConsumerStatefulWidget {
|
||||||
|
const WithdrawalPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<WithdrawalPage> createState() => _WithdrawalPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WithdrawalPageState extends ConsumerState<WithdrawalPage> {
|
||||||
|
final _amount = TextEditingController();
|
||||||
|
final _realName = TextEditingController();
|
||||||
|
final _bankAccount = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
double _balance = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amount.dispose();
|
||||||
|
_realName.dispose();
|
||||||
|
_bankAccount.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final resp = await ref.read(userApiProvider).info();
|
||||||
|
if (!mounted || !resp.isSuccess || resp.data == null) return;
|
||||||
|
final u = resp.data!;
|
||||||
|
setState(() {
|
||||||
|
_balance = u.balance ?? 0;
|
||||||
|
_realName.text = u.realName ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final amount = double.tryParse(_amount.text.trim());
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
Toast.error('请输入提现金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amount > _balance) {
|
||||||
|
Toast.error('提现金额不能超过余额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_realName.text.isEmpty) {
|
||||||
|
Toast.error('请填写收款人姓名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_bankAccount.text.isEmpty) {
|
||||||
|
Toast.error('请填写银行卡号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(userApiProvider).applyWithdrawal(
|
||||||
|
amount: amount,
|
||||||
|
bankAccount: _bankAccount.text.trim(),
|
||||||
|
realName: _realName.text.trim(),
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!r.isSuccess) {
|
||||||
|
Toast.error(r.message ?? '提现失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('提现申请已提交');
|
||||||
|
if (mounted) context.pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('提现异常:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('提现')),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.red.shade400, Colors.red.shade700],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'可提现余额(元)',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'¥${_balance.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _amount,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '提现金额',
|
||||||
|
prefixText: '¥ ',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => _amount.text = _balance.toStringAsFixed(2),
|
||||||
|
child: const Text('全部提现'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _realName,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '收款人姓名',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _bankAccount,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '银行卡号',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(_loading ? '提交中…' : '确认提现'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'• 提现申请提交后,财务将在 1-3 个工作日内处理\n'
|
||||||
|
'• 单笔最低 1 元,最高 50000 元\n'
|
||||||
|
'• 如有问题请联系客服',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 提现记录(对应 Android TiXianListActivity)
|
||||||
|
class WithdrawalRecordPage extends ConsumerStatefulWidget {
|
||||||
|
const WithdrawalRecordPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<WithdrawalRecordPage> createState() =>
|
||||||
|
_WithdrawalRecordPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WithdrawalRecordPageState
|
||||||
|
extends ConsumerState<WithdrawalRecordPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
List<WithdrawalRecord> _items = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
try {
|
||||||
|
final BaseResponse<PageResult<dynamic>> resp =
|
||||||
|
await ref.read(userApiProvider).withdrawalRecords();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
setState(() => _state = MultiState.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final list = (resp.data?.list as List?)
|
||||||
|
?.map((e) => WithdrawalRecord.fromJson(
|
||||||
|
e as Map<String, dynamic>,
|
||||||
|
))
|
||||||
|
.toList() ??
|
||||||
|
const [];
|
||||||
|
setState(() {
|
||||||
|
_items = list;
|
||||||
|
_state = list.isEmpty ? MultiState.empty : MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _state = MultiState.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('提现记录')),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
emptyText: '暂无提现记录',
|
||||||
|
onRetry: _load,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _items.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final w = _items[i];
|
||||||
|
Color color;
|
||||||
|
String status;
|
||||||
|
switch (w.status) {
|
||||||
|
case 'success':
|
||||||
|
color = Colors.green;
|
||||||
|
status = '已到账';
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
color = Colors.red;
|
||||||
|
status = '已失败';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
color = Colors.orange;
|
||||||
|
status = '审核中';
|
||||||
|
}
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: color.withValues(alpha: 0.1),
|
||||||
|
child: Icon(Icons.account_balance_wallet, color: color),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'¥${w.amount.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${w.bankAccount ?? "—"}\n${fmt.format(w.createdAt)}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
trailing: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status,
|
||||||
|
style: TextStyle(color: color, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:signature/signature.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// 客户签字页(对应 Android AutographActivity)
|
||||||
|
class AutographPage extends ConsumerStatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const AutographPage({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AutographPage> createState() => _AutographPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutographPageState extends ConsumerState<AutographPage> {
|
||||||
|
late final SignatureController _sigCtrl;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_sigCtrl = SignatureController(
|
||||||
|
penStrokeWidth: 3,
|
||||||
|
penColor: Colors.black,
|
||||||
|
exportBackgroundColor: Colors.white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sigCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_sigCtrl.isEmpty) {
|
||||||
|
Toast.error('请先签名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
EasyLoading.show(status: '上传中…');
|
||||||
|
try {
|
||||||
|
// 1) 导出 PNG 字节
|
||||||
|
final Uint8List? bytes = await _sigCtrl.toPngBytes();
|
||||||
|
if (bytes == null) throw '导出签名失败';
|
||||||
|
// 2) 落盘到缓存
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final file = File(
|
||||||
|
'${dir.path}/sig_${const Uuid().v4()}.png',
|
||||||
|
);
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
// 3) 走七牛
|
||||||
|
final url =
|
||||||
|
await ref.read(uploadServiceProvider).upload(file.path);
|
||||||
|
// 4) 通知后端
|
||||||
|
final resp = await ref
|
||||||
|
.read(orderApiProvider)
|
||||||
|
.uploadSignature(widget.orderId, url);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
Toast.error(resp.message ?? '保存失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('签字已保存');
|
||||||
|
if (mounted) context.pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('上传失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('客户签字'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _sigCtrl.clear(),
|
||||||
|
child: const Text('清除',
|
||||||
|
style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Colors.amber.shade50,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
width: double.infinity,
|
||||||
|
child: const Text(
|
||||||
|
'请客户在下方手写签字,确认本次救援服务内容与费用',
|
||||||
|
style: TextStyle(color: Colors.brown, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
margin: const EdgeInsets.all(12),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Signature(
|
||||||
|
controller: _sigCtrl,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _submitting ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(_submitting ? '提交中…' : '确认提交签字'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../router/app_router.dart';
|
||||||
|
|
||||||
|
/// 核单页(对应 Android CheckOrderActivity / CheckFragment)
|
||||||
|
/// 展示待核单订单,技师确认后调 /v2/order/check 进入待接单
|
||||||
|
class CheckPage extends ConsumerStatefulWidget {
|
||||||
|
const CheckPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CheckPage> createState() => _CheckPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckPageState extends ConsumerState<CheckPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
List<CheckItem> _items = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
try {
|
||||||
|
final api = ref.read(orderApiProvider);
|
||||||
|
final BaseResponse<List<CheckItem>> resp = await api.checkList();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
setState(() => _state = MultiState.error);
|
||||||
|
Toast.error(resp.message ?? '加载失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_items = resp.data ?? [];
|
||||||
|
_state = _items.isEmpty ? MultiState.empty : MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _state = MultiState.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit(CheckItem item) async {
|
||||||
|
final remark = await _showRemarkDialog();
|
||||||
|
if (remark == null) return;
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(orderApiProvider).submitCheck(
|
||||||
|
orderId: item.orderId,
|
||||||
|
remark: remark,
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
Toast.error(resp.message ?? '核单失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('核单成功');
|
||||||
|
setState(() => _items.removeWhere((e) => e.orderId == item.orderId));
|
||||||
|
if (_items.isEmpty) setState(() => _state = MultiState.empty);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('核单异常:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _showRemarkDialog() async {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('核单备注'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '请输入核单说明(可选)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('待核单'),
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
onRetry: _load,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _items.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) => _CheckCard(
|
||||||
|
item: _items[i],
|
||||||
|
onSubmit: () => _submit(_items[i]),
|
||||||
|
onDetail: () => context.push(
|
||||||
|
'${Routes.orderComplete}/${_items[i].orderId}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckCard extends StatelessWidget {
|
||||||
|
final CheckItem item;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
final VoidCallback onDetail;
|
||||||
|
const _CheckCard({
|
||||||
|
required this.item,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.onDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MM-dd HH:mm');
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onDetail,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.serviceType,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'¥${item.amount.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('订单号:${item.orderNo}',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
if (item.address != null && item.address!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('地点:${item.address}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('时间:${fmt.format(item.createdAt)}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onDetail,
|
||||||
|
child: const Text('查看详情'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onSubmit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
child: const Text('确认核单'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
import 'package:autosos_flutter/model/base_response.dart';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/qr_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:fluwx/fluwx.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 收款页(对应 Android FinishOrderMoneyActivity)
|
||||||
|
/// 三种方式:微信支付(技师端唤起收款码让客户扫)/ 现金收款 / 已扫码后回调
|
||||||
|
class CollectionPage extends ConsumerStatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const CollectionPage({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CollectionPage> createState() => _CollectionPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CollectionPageState extends ConsumerState<CollectionPage> {
|
||||||
|
Order? _order;
|
||||||
|
final TextEditingController _amountCtrl = TextEditingController();
|
||||||
|
String _qrUrl = '';
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final resp =
|
||||||
|
await ref.read(orderApiProvider).orderDetail(widget.orderId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess || resp.data == null) {
|
||||||
|
Toast.error('订单不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_order = resp.data;
|
||||||
|
_amountCtrl.text = resp.data!.amount.toStringAsFixed(2);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('加载失败:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _wxPay() async {
|
||||||
|
final amount = double.tryParse(_amountCtrl.text.trim());
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
Toast.error('请输入有效金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '生成中…');
|
||||||
|
try {
|
||||||
|
final BaseResponse<String> resp = await ref
|
||||||
|
.read(orderApiProvider)
|
||||||
|
.createPayQrCode(widget.orderId, amount);
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error(resp.message ?? '生成失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _qrUrl = resp.data ?? '');
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('生成失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _wxPayNative() async {
|
||||||
|
final amount = double.tryParse(_amountCtrl.text.trim());
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
Toast.error('请输入有效金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
EasyLoading.show(status: '调起微信…');
|
||||||
|
try {
|
||||||
|
final BaseResponse<WxPay> resp = await ref
|
||||||
|
.read(orderApiProvider)
|
||||||
|
.createWxPay(widget.orderId, amount);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!resp.isSuccess || resp.data == null) {
|
||||||
|
Toast.error(resp.message ?? '下单失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final pay = resp.data!;
|
||||||
|
await Fluwx().pay(
|
||||||
|
which: Payment(
|
||||||
|
appId: pay.appId,
|
||||||
|
partnerId: pay.partnerId,
|
||||||
|
prepayId: pay.prepayId,
|
||||||
|
packageValue: pay.packageValue,
|
||||||
|
nonceStr: pay.nonceStr,
|
||||||
|
timestamp: int.tryParse(pay.timeStamp) ?? 0,
|
||||||
|
sign: pay.sign,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('调起失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cashPay() async {
|
||||||
|
final amount = double.tryParse(_amountCtrl.text.trim());
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
Toast.error('请输入有效金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('现金收款确认'),
|
||||||
|
content: Text('确认已收到客户现金 ¥${amount.toStringAsFixed(2)}?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final resp = await ref
|
||||||
|
.read(orderApiProvider)
|
||||||
|
.confirmCashPay(widget.orderId, amount);
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
Toast.error(resp.message ?? '提交失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('已记录现金收款');
|
||||||
|
if (mounted) context.pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('提交失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('客户付款')),
|
||||||
|
body: _order == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'订单号:${_order!.orderNo}',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _amountCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '收款金额',
|
||||||
|
prefixText: '¥ ',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_qrUrl.isNotEmpty) ...[
|
||||||
|
Center(
|
||||||
|
child: QrImageView(
|
||||||
|
data: _qrUrl,
|
||||||
|
size: 220,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'请客户扫码支付',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _loading ? null : _wxPay,
|
||||||
|
icon: const Icon(Icons.qr_code_2),
|
||||||
|
label: const Text('生成收款二维码'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _loading ? null : _wxPayNative,
|
||||||
|
icon: const Icon(Icons.wechat),
|
||||||
|
label: const Text('调起微信收款'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.green),
|
||||||
|
foregroundColor: Colors.green,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _loading ? null : _cashPay,
|
||||||
|
icon: const Icon(Icons.money),
|
||||||
|
label: const Text('现金收款确认'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
import 'package:autosos_flutter/model/misc.dart';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 完成订单页(对应 Android FinishOrderActivity)
|
||||||
|
/// 录入费用明细 → 提交完成 → 跳转记录页
|
||||||
|
class CompletePage extends ConsumerStatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const CompletePage({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CompletePage> createState() => _CompletePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompletePageState extends ConsumerState<CompletePage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
Order? _order;
|
||||||
|
final List<AmountItem> _items = [];
|
||||||
|
final TextEditingController _remarkCtrl = TextEditingController();
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_remarkCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(orderApiProvider).orderDetail(widget.orderId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess || resp.data == null) {
|
||||||
|
setState(() => _state = MultiState.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_order = resp.data;
|
||||||
|
// 默认添加一条基础服务费
|
||||||
|
if (_items.isEmpty) {
|
||||||
|
_items.add(AmountItem(
|
||||||
|
id: 0,
|
||||||
|
title: '基础服务费',
|
||||||
|
amount: resp.data!.amount,
|
||||||
|
type: 'income',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
orderNo: resp.data!.orderNo,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_state = MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _state = MultiState.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _total => _items.fold(0, (s, e) => s + e.amount);
|
||||||
|
|
||||||
|
Future<void> _addItem() async {
|
||||||
|
final added = await showDialog<AmountItem>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _AddAmountDialog(),
|
||||||
|
);
|
||||||
|
if (added != null) setState(() => _items.add(added));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_items.isEmpty) {
|
||||||
|
Toast.error('请至少添加一项费用明细');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
EasyLoading.show(status: '提交中…');
|
||||||
|
try {
|
||||||
|
final r = await ref.read(orderServiceProvider).complete(
|
||||||
|
amountItems: _items,
|
||||||
|
remark: _remarkCtrl.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _remarkCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (r is CompleteOrderInfo) {
|
||||||
|
Toast.success('订单已完成');
|
||||||
|
if (mounted) {
|
||||||
|
// 通知服务端完成(已在 OrderService 中调用)
|
||||||
|
context.go(Routes.orderRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('提交失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('完成订单'),
|
||||||
|
actions: [
|
||||||
|
if (_order != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'订单 ${_order!.orderNo}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
onRetry: _load,
|
||||||
|
child: _order == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
_summary(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_itemList(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _addItem,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('添加费用项'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _remarkCtrl,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '备注',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('合计:',
|
||||||
|
style: TextStyle(fontSize: 16)),
|
||||||
|
Text(
|
||||||
|
'¥${_total.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _submitting ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(_submitting ? '提交中…' : '提交完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _summary() {
|
||||||
|
final o = _order!;
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(o.serviceType,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (o.address != null)
|
||||||
|
Text('地点:${o.address}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade700, fontSize: 13)),
|
||||||
|
if (o.customerName != null)
|
||||||
|
Text('客户:${o.customerName!}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade700, fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _itemList() {
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _items.length; i++)
|
||||||
|
ListTile(
|
||||||
|
title: Text(_items[i].title),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('¥${_items[i].amount.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
)),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
color: Colors.red),
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _items.removeAt(i)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增费用项弹窗
|
||||||
|
class _AddAmountDialog extends StatefulWidget {
|
||||||
|
const _AddAmountDialog();
|
||||||
|
@override
|
||||||
|
State<_AddAmountDialog> createState() => _AddAmountDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddAmountDialogState extends State<_AddAmountDialog> {
|
||||||
|
final _title = TextEditingController();
|
||||||
|
final _amount = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_title.dispose();
|
||||||
|
_amount.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('添加费用项'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _title,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '费用名称',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _amount,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '金额',
|
||||||
|
prefixText: '¥ ',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final t = _title.text.trim();
|
||||||
|
final a = double.tryParse(_amount.text.trim()) ?? 0;
|
||||||
|
if (t.isEmpty || a <= 0) {
|
||||||
|
Toast.error('请填写完整');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
AmountItem(
|
||||||
|
id: 0,
|
||||||
|
title: t,
|
||||||
|
amount: a,
|
||||||
|
type: 'income',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('添加'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,454 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:autosos_flutter/widget/watermark.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// 救援中页(对应 Android ConductActivity)
|
||||||
|
/// 步骤:开始 → 到达现场 → 拍照 → 签字 → 收款 → 完成
|
||||||
|
class ConductPage extends ConsumerStatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const ConductPage({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConductPage> createState() => _ConductPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConductPageState extends ConsumerState<ConductPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
Order? _order;
|
||||||
|
Timer? _ticker;
|
||||||
|
Duration _elapsed = Duration.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted || _order == null) return;
|
||||||
|
setState(() {
|
||||||
|
final start = _order!.acceptedAt ?? _order!.createdAt;
|
||||||
|
_elapsed = DateTime.now().difference(start);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(orderApiProvider).orderDetail(widget.orderId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess || resp.data == null) {
|
||||||
|
setState(() => _state = MultiState.error);
|
||||||
|
Toast.error(resp.message ?? '订单不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final o = resp.data!;
|
||||||
|
setState(() {
|
||||||
|
_order = o;
|
||||||
|
_state = MultiState.success;
|
||||||
|
});
|
||||||
|
ref.read(orderServiceProvider).setCurrent(o);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _state = MultiState.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _arrive() async {
|
||||||
|
EasyLoading.show(status: '上报中…');
|
||||||
|
try {
|
||||||
|
await ref.read(orderServiceProvider).arrive();
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.success('已标记到达现场');
|
||||||
|
_load();
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('上报失败:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancel() async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('取消订单'),
|
||||||
|
content: const Text('确定要取消此订单吗?取消后无法恢复。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('不取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('确认取消'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await ref.read(orderServiceProvider).cancel();
|
||||||
|
Toast.success('已取消');
|
||||||
|
if (mounted) context.go(Routes.home);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('取消失败:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _call(String? phone) async {
|
||||||
|
if (phone == null || phone.isEmpty) {
|
||||||
|
Toast.error('客户未留电话');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final uri = Uri.parse('tel:$phone');
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
Toast.error('无法拨打电话');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _navigate() async {
|
||||||
|
final o = _order;
|
||||||
|
if (o?.lat == null || o?.lng == null) {
|
||||||
|
Toast.error('暂无位置信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 高德地图 URL Scheme
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'androidamap://navi?sourceApplication=jjsos&lat=${o!.lat}&lon=${o.lng}&dev=0&style=2',
|
||||||
|
);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
// 退回到 web 版
|
||||||
|
final web = Uri.parse(
|
||||||
|
'https://uri.amap.com/navigation?to=${o.lng},${o.lat},${o.address ?? "救援地点"}&mode=car',
|
||||||
|
);
|
||||||
|
await launchUrl(web, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(Duration d) {
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${two(d.inHours)}:${two(d.inMinutes.remainder(60))}:${two(d.inSeconds.remainder(60))}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('救援中'),
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
onRetry: _load,
|
||||||
|
child: _order == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: _body(_order!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body(Order o) {
|
||||||
|
final isArrived = o.status == OrderStatus.conducting ||
|
||||||
|
o.status == OrderStatus.photographing ||
|
||||||
|
o.status == OrderStatus.signing ||
|
||||||
|
o.status == OrderStatus.collecting;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ListView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
// 倒计时卡片
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.red.shade400, Colors.red.shade700],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.timer, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'已耗时 ${_fmt(_elapsed)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text('订单号:${o.orderNo}',
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 客户信息
|
||||||
|
_section(
|
||||||
|
title: '客户信息',
|
||||||
|
children: [
|
||||||
|
_kv('联系人', o.customerName ?? '—'),
|
||||||
|
_kv('电话', o.customerPhone ?? '—'),
|
||||||
|
if (o.carNo != null) _kv('车牌', o.carNo!),
|
||||||
|
if (o.carType != null) _kv('车型', o.carType!),
|
||||||
|
if (o.description != null) _kv('描述', o.description!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 救援地点
|
||||||
|
_section(
|
||||||
|
title: '救援地点',
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_on,
|
||||||
|
color: Colors.red, size: 18),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
o.address ?? '—',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _navigate,
|
||||||
|
icon: const Icon(Icons.directions, size: 16),
|
||||||
|
label: const Text('导航'),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _call(o.customerPhone),
|
||||||
|
icon: const Icon(Icons.phone, size: 16),
|
||||||
|
label: const Text('呼叫'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 步骤流程
|
||||||
|
_section(
|
||||||
|
title: '服务流程',
|
||||||
|
children: [
|
||||||
|
_Step(
|
||||||
|
active: true,
|
||||||
|
done: isArrived,
|
||||||
|
title: '到达现场',
|
||||||
|
child: isArrived
|
||||||
|
? Text('已到达:${o.arrivedAt ?? "—"}',
|
||||||
|
style: const TextStyle(color: Colors.grey))
|
||||||
|
: ElevatedButton(
|
||||||
|
onPressed: _arrive,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
child: const Text('我已到达现场'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
active: isArrived,
|
||||||
|
done: o.photoUrls.isNotEmpty,
|
||||||
|
title: '拍照取证',
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: isArrived
|
||||||
|
? () => context
|
||||||
|
.push('${Routes.orderPhotograph}/${o.id}')
|
||||||
|
.then((_) => _load())
|
||||||
|
: null,
|
||||||
|
child: Text(
|
||||||
|
o.photoUrls.isEmpty
|
||||||
|
? '开始拍照'
|
||||||
|
: '已拍 ${o.photoUrls.length} 张,继续',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
active: o.photoUrls.isNotEmpty,
|
||||||
|
done: o.signatureUrl != null,
|
||||||
|
title: '客户签字',
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: o.photoUrls.isNotEmpty
|
||||||
|
? () => context
|
||||||
|
.push('${Routes.orderAutograph}/${o.id}')
|
||||||
|
.then((_) => _load())
|
||||||
|
: null,
|
||||||
|
child: const Text('让客户签字'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
active: o.signatureUrl != null,
|
||||||
|
done: o.payMethod != null,
|
||||||
|
title: '客户付款',
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: o.signatureUrl != null
|
||||||
|
? () => context
|
||||||
|
.push('${Routes.orderCollection}/${o.id}')
|
||||||
|
.then((_) => _load())
|
||||||
|
: null,
|
||||||
|
child: const Text('发起收款'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
active: o.payMethod != null,
|
||||||
|
done: o.status == OrderStatus.completed,
|
||||||
|
title: '完成订单',
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: o.payMethod != null
|
||||||
|
? () => context
|
||||||
|
.push('${Routes.orderComplete}/${o.id}')
|
||||||
|
.then((_) => _load())
|
||||||
|
: null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
child: const Text('提交完成'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 取消订单
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _cancel,
|
||||||
|
icon: const Icon(Icons.cancel_outlined, color: Colors.red),
|
||||||
|
label: const Text(
|
||||||
|
'取消订单',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.red),
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 全屏水印
|
||||||
|
const IgnorePointer(
|
||||||
|
child: Watermark(
|
||||||
|
text: '啾啾救援 © 内部资料',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _section({required String title, required List<Widget> children}) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...children,
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _kv(String k, String v) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Text(k,
|
||||||
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(v, style: const TextStyle(fontSize: 14)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Step extends StatelessWidget {
|
||||||
|
final bool active;
|
||||||
|
final bool done;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
const _Step({
|
||||||
|
required this.active,
|
||||||
|
required this.done,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: done
|
||||||
|
? Colors.green
|
||||||
|
: (active
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
done ? Icons.check : Icons.circle,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 70,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: active ? Colors.black : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/photo_grid_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 拍照取证页(对应 Android CheckPhotoActivity)
|
||||||
|
/// 自定义相机 + 照片队列 + 备注 + 上传
|
||||||
|
class PhotographPage extends ConsumerStatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const PhotographPage({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PhotographPage> createState() => _PhotographPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotographPageState extends ConsumerState<PhotographPage> {
|
||||||
|
final List<String> _localPaths = [];
|
||||||
|
final TextEditingController _remarkCtrl = TextEditingController();
|
||||||
|
bool _showCamera = false;
|
||||||
|
bool _uploading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_remarkCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCaptured(String path) {
|
||||||
|
setState(() {
|
||||||
|
_localPaths.add(path);
|
||||||
|
// 拍到一张后自动收起相机,让用户确认后再拍
|
||||||
|
_showCamera = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFromAlbum() async {
|
||||||
|
final files = await AlbumPicker.pick();
|
||||||
|
if (files.isEmpty) return;
|
||||||
|
setState(() => _localPaths.addAll(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_localPaths.isEmpty) {
|
||||||
|
Toast.error('请先拍照');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _uploading = true);
|
||||||
|
EasyLoading.show(status: '上传中…');
|
||||||
|
try {
|
||||||
|
// 1) 走七牛
|
||||||
|
final urls = await ref
|
||||||
|
.read(uploadServiceProvider)
|
||||||
|
.uploadBatch(_localPaths);
|
||||||
|
// 2) 通知后端
|
||||||
|
final resp = await ref
|
||||||
|
.read(orderApiProvider)
|
||||||
|
.uploadPhotos(widget.orderId, urls);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
Toast.error(resp.message ?? '保存失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.success('已上传 ${urls.length} 张照片');
|
||||||
|
if (mounted) context.pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('上传失败:$e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
title: const Text('拍照取证'),
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 备注
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _remarkCtrl,
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '可填写现场情况描述(选填)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _showCamera
|
||||||
|
? CameraView(onCaptured: _onCaptured)
|
||||||
|
: _photoList(),
|
||||||
|
),
|
||||||
|
// 底部按钮区
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
PhotoThumbStrip(
|
||||||
|
paths: _localPaths,
|
||||||
|
onAdd: () => setState(() => _showCamera = true),
|
||||||
|
onRemove: (i) =>
|
||||||
|
setState(() => _localPaths.removeAt(i)),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _showCamera
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
setState(() => _showCamera = true),
|
||||||
|
icon: const Icon(Icons.camera_alt),
|
||||||
|
label: const Text('拍照'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _showCamera
|
||||||
|
? null
|
||||||
|
: _pickFromAlbum,
|
||||||
|
icon: const Icon(Icons.photo_library),
|
||||||
|
label: const Text('相册'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _uploading ? null : _submit,
|
||||||
|
icon: const Icon(Icons.cloud_upload),
|
||||||
|
label: Text(
|
||||||
|
_uploading
|
||||||
|
? '上传中…'
|
||||||
|
: '完成(${_localPaths.length})',
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _photoList() {
|
||||||
|
if (_localPaths.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.photo_camera,
|
||||||
|
size: 80, color: Colors.white24),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('点击下方"拍照"开始取证',
|
||||||
|
style: TextStyle(color: Colors.white54)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
|
itemCount: _localPaths.length,
|
||||||
|
itemBuilder: (ctx, i) => Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Image.file(
|
||||||
|
File(_localPaths[i]),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _localPaths.removeAt(i)),
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 历史订单记录页(对应 Android RecordOrderActivity)
|
||||||
|
class OrderRecordPage extends ConsumerStatefulWidget {
|
||||||
|
const OrderRecordPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<OrderRecordPage> createState() => _OrderRecordPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderRecordPageState extends ConsumerState<OrderRecordPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
List<Order> _items = [];
|
||||||
|
String? _status;
|
||||||
|
int _page = 1;
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
final _scroll = ScrollController();
|
||||||
|
|
||||||
|
final _tabs = const [
|
||||||
|
('全部', null),
|
||||||
|
('已完成', 'completed'),
|
||||||
|
('已取消', 'cancelled'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load(reset: true);
|
||||||
|
_scroll.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scroll.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scroll.position.pixels >=
|
||||||
|
_scroll.position.maxScrollExtent - 200) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load({bool reset = false}) async {
|
||||||
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = true;
|
||||||
|
_items = [];
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
}
|
||||||
|
if (!_hasMore || _loadingMore) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(orderApiProvider).historyOrders(
|
||||||
|
page: _page,
|
||||||
|
size: 20,
|
||||||
|
status: _status,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
if (reset) setState(() => _state = MultiState.error);
|
||||||
|
Toast.error(resp.message ?? '加载失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final list = resp.data?.list ?? [];
|
||||||
|
_items.addAll(list);
|
||||||
|
_hasMore = (resp.data?.hasMore ?? false) && list.length >= 20;
|
||||||
|
_page++;
|
||||||
|
setState(() {
|
||||||
|
_state = _items.isEmpty ? MultiState.empty : MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted && reset) setState(() => _state = MultiState.error);
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('历史订单'),
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(44),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: _tabs
|
||||||
|
.map(
|
||||||
|
(t) => Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
if (_status == t.$2) return;
|
||||||
|
setState(() => _status = t.$2);
|
||||||
|
_load(reset: true);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
height: 44,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: _status == t.$2
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
t.$1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _status == t.$2
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: Colors.black87,
|
||||||
|
fontWeight: _status == t.$2
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () => _load(reset: true),
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
emptyText: '暂无历史订单',
|
||||||
|
onRetry: () => _load(reset: true),
|
||||||
|
child: ListView.separated(
|
||||||
|
controller: _scroll,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _items.length + (_hasMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i >= _items.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _RecordCard(
|
||||||
|
order: _items[i],
|
||||||
|
onTap: () => context.push(
|
||||||
|
'${Routes.orderComplete}/${_items[i].id}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordCard extends StatelessWidget {
|
||||||
|
final Order order;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _RecordCard({required this.order, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
|
final isCompleted = order.status == OrderStatus.completed;
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCompleted
|
||||||
|
? Colors.green.shade50
|
||||||
|
: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
order.status.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCompleted
|
||||||
|
? Colors.green.shade700
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(order.serviceType,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'¥${order.amount.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('订单号:${order.orderNo}',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
if (order.address != null && order.address!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('地点:${order.address}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('时间:${fmt.format(order.createdAt)}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/util/toast.dart';
|
||||||
|
import 'package:autosos_flutter/widget/multi_state_view.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 等待接单页(对应 Android WaitOrderActivity)
|
||||||
|
/// 列出当前可抢订单,倒计时 0 后自动从列表移除
|
||||||
|
class WaitingPage extends ConsumerStatefulWidget {
|
||||||
|
const WaitingPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<WaitingPage> createState() => _WaitingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaitingPageState extends ConsumerState<WaitingPage> {
|
||||||
|
MultiState _state = MultiState.loading;
|
||||||
|
List<WaitingOrder> _items = [];
|
||||||
|
Timer? _ticker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
// 每秒刷新倒计时
|
||||||
|
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _state = MultiState.loading);
|
||||||
|
try {
|
||||||
|
final resp = await ref.read(orderApiProvider).waitingList();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!resp.isSuccess) {
|
||||||
|
setState(() => _state = MultiState.error);
|
||||||
|
Toast.error(resp.message ?? '加载失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_items = resp.data ?? [];
|
||||||
|
_state = _items.isEmpty ? MultiState.empty : MultiState.success;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _state = MultiState.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _grab(WaitingOrder item) async {
|
||||||
|
EasyLoading.show(status: '抢单中…');
|
||||||
|
try {
|
||||||
|
final order = await ref.read(orderServiceProvider).grab(item.orderId);
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.success('接单成功');
|
||||||
|
if (!mounted) return;
|
||||||
|
// 跳转到救援中页
|
||||||
|
context.pushReplacement('${Routes.orderConduct}/${order.id}');
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
Toast.error('抢单失败:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('等待接单'),
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: MultiStateView(
|
||||||
|
state: _state,
|
||||||
|
emptyText: '当前没有可接订单',
|
||||||
|
onRetry: _load,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _items.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final o = _items[i];
|
||||||
|
return _WaitingCard(
|
||||||
|
item: o,
|
||||||
|
onGrab: () => _grab(o),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaitingCard extends StatelessWidget {
|
||||||
|
final WaitingOrder item;
|
||||||
|
final VoidCallback onGrab;
|
||||||
|
const _WaitingCard({required this.item, required this.onGrab});
|
||||||
|
|
||||||
|
String _formatCountdown(int s) {
|
||||||
|
if (s <= 0) return '已超时';
|
||||||
|
final m = (s ~/ 60).toString().padLeft(2, '0');
|
||||||
|
final sec = (s % 60).toString().padLeft(2, '0');
|
||||||
|
return '$m:$sec';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MM-dd HH:mm');
|
||||||
|
final expired = item.countdown <= 0;
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.serviceType,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (item.distance != null)
|
||||||
|
Text(
|
||||||
|
'距离 ${(item.distance! / 1000).toStringAsFixed(1)} km',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: expired ? Colors.grey : Colors.orange,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatCountdown(item.countdown),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('订单号:${item.orderNo}',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
if (item.address != null && item.address!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('地点:${item.address}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('时间:${fmt.format(item.createdAt)}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'¥${item.amount.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: expired ? null : onGrab,
|
||||||
|
icon: const Icon(Icons.flash_on, size: 18),
|
||||||
|
label: const Text('立即抢单'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 隐私协议 + 服务协议弹窗(对应 Android ExplainXpopup)
|
||||||
|
Future<bool?> showAgreementDialog(BuildContext context) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('用户协议与隐私政策'),
|
||||||
|
content: const SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
'在你使用之前,请仔细阅读《服务协议》和《隐私政策》的全部条款。\n\n'
|
||||||
|
'我们将依法保护你的个人信息安全,包括位置信息、设备信息等。\n\n'
|
||||||
|
'点击"同意并继续"即代表你接受上述协议。',
|
||||||
|
style: TextStyle(fontSize: 14, height: 1.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('不同意并退出'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ctx.push('${Routes.webview}?url=https://t.jjsos.cn/index_xy.html&title=服务协议'),
|
||||||
|
child: const Text('服务协议'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ctx.push('${Routes.webview}?url=https://t.jjsos.cn/index_ys.html&title=隐私政策'),
|
||||||
|
child: const Text('隐私政策'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('同意并继续'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import 'package:autosos_flutter/config/theme_colors.dart';
|
||||||
|
import 'package:autosos_flutter/pages/splash/agreement_dialog.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
/// 启动页(对应 Android SplashActivity)
|
||||||
|
class SplashPage extends ConsumerStatefulWidget {
|
||||||
|
const SplashPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SplashPage> createState() => _SplashPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
|
final String _pushType = '0';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _bootstrap() async {
|
||||||
|
// 读取推送 type 参数
|
||||||
|
// (真实使用中可以从 notification 启动 Intent 拿到)
|
||||||
|
|
||||||
|
final agreed = SPUtil().get<bool>('isAgreement') ?? false;
|
||||||
|
if (!agreed) {
|
||||||
|
final ok = await showAgreementDialog(context);
|
||||||
|
if (ok != true) return;
|
||||||
|
SPUtil().setBool('isAgreement', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求定位权限
|
||||||
|
final locGranted = await _requestLocation();
|
||||||
|
if (!locGranted) {
|
||||||
|
// 提示去设置
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请到设置中开启位置权限')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_navigateNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _requestLocation() async {
|
||||||
|
final status = await Permission.locationWhenInUse.request();
|
||||||
|
if (!status.isGranted) return false;
|
||||||
|
// 后台定位(Android 10+ 需要)
|
||||||
|
if (await Permission.locationAlways.isDenied) {
|
||||||
|
await Permission.locationAlways.request();
|
||||||
|
}
|
||||||
|
await LocationService.instance.init();
|
||||||
|
LocationService.instance.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateNext() {
|
||||||
|
final logged = ref.read(isLoggedInProvider);
|
||||||
|
final welcomed = SPUtil().get<bool>('isWelcome') ?? false;
|
||||||
|
|
||||||
|
if (!logged) {
|
||||||
|
context.go(Routes.login);
|
||||||
|
} else if (!welcomed) {
|
||||||
|
context.go(Routes.welcome);
|
||||||
|
} else {
|
||||||
|
_routeByPushType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _routeByPushType() {
|
||||||
|
final logged = ref.read(isLoggedInProvider);
|
||||||
|
if (!logged) {
|
||||||
|
context.go(Routes.login);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (_pushType) {
|
||||||
|
case '1':
|
||||||
|
context.go(Routes.orderWaiting);
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
context.go(Routes.messenger);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
context.go(Routes.home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: ThemeColors.primary,
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'images/1.5x/ic_launcher.png',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'啾啾救援',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'宁波易到互联科技有限公司',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// WebView 容器(替代 Android AgentWeb)
|
||||||
|
/// 支持:进度条 / 标题自动同步 / 返回键关闭 / 拦截外链 / JS 桥
|
||||||
|
class AgentWebPage extends StatefulWidget {
|
||||||
|
final String url;
|
||||||
|
final String title;
|
||||||
|
const AgentWebPage({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AgentWebPage> createState() => _AgentWebPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AgentWebPageState extends State<AgentWebPage> {
|
||||||
|
InAppWebViewController? _controller;
|
||||||
|
double _progress = 0;
|
||||||
|
String _title = '';
|
||||||
|
bool _canGoBack = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_title = widget.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_title.isEmpty ? '加载中…' : _title),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_canGoBack)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => _controller?.goBack(),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => _controller?.reload(),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) async {
|
||||||
|
switch (v) {
|
||||||
|
case 'copy':
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
// await Clipboard.setData(ClipboardData(text: widget.url));
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('链接已复制')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'browser':
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
// await launchUrl(Uri.parse(widget.url));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => const [
|
||||||
|
PopupMenuItem(value: 'copy', child: Text('复制链接')),
|
||||||
|
PopupMenuItem(value: 'browser', child: Text('浏览器打开')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: _progress < 1
|
||||||
|
? PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _progress,
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
valueColor: const AlwaysStoppedAnimation(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
body: InAppWebView(
|
||||||
|
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
||||||
|
initialSettings: InAppWebViewSettings(
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
javaScriptCanOpenWindowsAutomatically: true,
|
||||||
|
useShouldOverrideUrlLoading: true,
|
||||||
|
mediaPlaybackRequiresUserGesture: false,
|
||||||
|
allowFileAccess: true,
|
||||||
|
domStorageEnabled: true,
|
||||||
|
databaseEnabled: true,
|
||||||
|
cacheEnabled: true,
|
||||||
|
supportZoom: true,
|
||||||
|
transparentBackground: false,
|
||||||
|
),
|
||||||
|
onWebViewCreated: (c) => _controller = c,
|
||||||
|
onProgressChanged: (_, p) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _progress = p / 100);
|
||||||
|
},
|
||||||
|
onTitleChanged: (_, t) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _title = t ?? widget.title);
|
||||||
|
},
|
||||||
|
onUpdateVisitedHistory: (_, url, __) {
|
||||||
|
_controller?.canGoBack().then((v) {
|
||||||
|
if (mounted) setState(() => _canGoBack = v);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
shouldOverrideUrlLoading: (_, nav) async {
|
||||||
|
final uri = nav.request.url;
|
||||||
|
if (uri == null) return NavigationActionPolicy.ALLOW;
|
||||||
|
// 拦截 tel:/mailto:/weixin:/alipays:
|
||||||
|
final s = uri.toString();
|
||||||
|
if (s.startsWith('tel:') ||
|
||||||
|
s.startsWith('mailto:') ||
|
||||||
|
s.startsWith('weixin://') ||
|
||||||
|
s.startsWith('alipays://')) {
|
||||||
|
return NavigationActionPolicy.CANCEL;
|
||||||
|
}
|
||||||
|
return NavigationActionPolicy.ALLOW;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import 'package:autosos_flutter/router/app_router.dart';
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 新手引导页(对应 Android WelcomeActivity + ViewPager)
|
||||||
|
class WelcomePage extends StatefulWidget {
|
||||||
|
const WelcomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WelcomePage> createState() => _WelcomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomePageState extends State<WelcomePage> {
|
||||||
|
final _controller = PageController();
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
final _pages = const [
|
||||||
|
_WelcomeItem(
|
||||||
|
img: 'images/4.0x/welcome_1.png',
|
||||||
|
title: '快速接单',
|
||||||
|
subtitle: '实时推送附近订单,第一时间响应',
|
||||||
|
),
|
||||||
|
_WelcomeItem(
|
||||||
|
img: 'images/4.0x/welcome_2.png',
|
||||||
|
title: '拍照取证',
|
||||||
|
subtitle: '现场拍照、客户签字,全流程电子化',
|
||||||
|
),
|
||||||
|
_WelcomeItem(
|
||||||
|
img: 'images/4.0x/welcome_3.png',
|
||||||
|
title: '收入透明',
|
||||||
|
subtitle: '订单金额、提现记录一目了然',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
PageView.builder(
|
||||||
|
controller: _controller,
|
||||||
|
onPageChanged: (i) => setState(() => _index = i),
|
||||||
|
itemCount: _pages.length,
|
||||||
|
itemBuilder: (_, i) => _pages[i],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(
|
||||||
|
_pages.length,
|
||||||
|
(i) => AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
width: _index == i ? 24 : 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _index == i
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: Colors.grey.shade400,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _index == _pages.length - 1
|
||||||
|
? _finish
|
||||||
|
: () => _controller.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeIn,
|
||||||
|
),
|
||||||
|
child: Text(_index == _pages.length - 1 ? '立即体验' : '下一步'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _finish() async {
|
||||||
|
SPUtil().setBool('isWelcome', true);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go(Routes.home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomeItem extends StatelessWidget {
|
||||||
|
final String img;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
const _WelcomeItem({required this.img, required this.title, required this.subtitle});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Image.asset(img, fit: BoxFit.contain, height: 280),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(subtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:autosos_flutter/api/order_api.dart';
|
||||||
|
import 'package:autosos_flutter/api/user_api.dart';
|
||||||
|
import 'package:autosos_flutter/api/upload_api.dart';
|
||||||
|
import 'package:autosos_flutter/model/user.dart';
|
||||||
|
import 'package:autosos_flutter/service/audio_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/background_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/heartbeat_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/order_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/push_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/update_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/upload_service.dart';
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// 全局 Provider 入口(所有页面通过 ref.watch/read 拿到服务)
|
||||||
|
|
||||||
|
// ===== 单例服务 =====
|
||||||
|
final spUtilProvider = Provider<SPUtil>((_) => SPUtil());
|
||||||
|
|
||||||
|
final locationServiceProvider = Provider((_) => LocationService.instance);
|
||||||
|
final pushServiceProvider = Provider((_) => PushService.instance);
|
||||||
|
final heartbeatServiceProvider = Provider((_) => HeartbeatService.instance);
|
||||||
|
final audioServiceProvider = Provider((_) => AudioService.instance);
|
||||||
|
final backgroundServiceProvider = Provider((_) => BackgroundService.instance);
|
||||||
|
final orderServiceProvider = Provider((_) => OrderService.instance);
|
||||||
|
final uploadServiceProvider = Provider((_) => UploadService.instance);
|
||||||
|
final updateServiceProvider = Provider((_) => UpdateService.instance);
|
||||||
|
|
||||||
|
// ===== API =====
|
||||||
|
final orderApiProvider = Provider((_) => OrderApi.instance);
|
||||||
|
final userApiProvider = Provider((_) => UserApi.instance);
|
||||||
|
final uploadApiProvider = Provider((_) => UploadApi.instance);
|
||||||
|
|
||||||
|
// ===== 登录态 =====
|
||||||
|
final accessTokenProvider = StateProvider<String?>((ref) {
|
||||||
|
return SPUtil().get('accessToken') as String?;
|
||||||
|
});
|
||||||
|
|
||||||
|
final isLoggedInProvider = Provider<bool>((ref) {
|
||||||
|
final t = ref.watch(accessTokenProvider);
|
||||||
|
return t != null && t.isNotEmpty;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 当前登录用户(启动时尝试从缓存恢复,进入首页后异步刷新)
|
||||||
|
final currentUserProvider = StateProvider<UserInfo?>((ref) => null);
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import 'package:autosos_flutter/pages/home/home_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/login/login_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/login/bind_phone_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/login/bind_wx_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/me_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/authentication_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/perfect_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/withdrawal_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/withdrawal_record_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/recharge_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/amount_list_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/feedback_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/messenger_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/setting_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/me/optimization_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/check_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/waiting_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/conduct_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/photograph_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/autograph_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/collection_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/complete_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/order/record_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/splash/splash_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/welcome/welcome_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/webview/agent_web_page.dart';
|
||||||
|
import 'package:autosos_flutter/pages/common/qr_scan_page.dart';
|
||||||
|
import 'package:autosos_flutter/provider/providers.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// 路由名集中定义
|
||||||
|
class Routes {
|
||||||
|
static const splash = '/';
|
||||||
|
static const welcome = '/welcome';
|
||||||
|
static const login = '/login';
|
||||||
|
static const bindPhone = '/bind-phone';
|
||||||
|
static const bindWx = '/bind-wx';
|
||||||
|
|
||||||
|
static const home = '/home';
|
||||||
|
static const me = '/me';
|
||||||
|
static const perfect = '/me/perfect';
|
||||||
|
static const auth = '/me/auth';
|
||||||
|
static const withdrawal = '/me/withdrawal';
|
||||||
|
static const withdrawalRecord = '/me/withdrawal/record';
|
||||||
|
static const recharge = '/me/recharge';
|
||||||
|
static const amountList = '/me/amount';
|
||||||
|
static const feedback = '/me/feedback';
|
||||||
|
static const messenger = '/me/messenger';
|
||||||
|
static const setting = '/me/setting';
|
||||||
|
static const optimization = '/me/optimization';
|
||||||
|
|
||||||
|
static const orderCheck = '/order/check';
|
||||||
|
static const orderWaiting = '/order/waiting';
|
||||||
|
static const orderConduct = '/order/conduct';
|
||||||
|
static const orderPhotograph = '/order/photograph';
|
||||||
|
static const orderAutograph = '/order/autograph';
|
||||||
|
static const orderCollection = '/order/collection';
|
||||||
|
static const orderComplete = '/order/complete';
|
||||||
|
static const orderRecord = '/order/record';
|
||||||
|
|
||||||
|
static const webview = '/webview';
|
||||||
|
static const qrScan = '/qr-scan';
|
||||||
|
}
|
||||||
|
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: Routes.splash,
|
||||||
|
redirect: (context, state) {
|
||||||
|
final logged = ref.read(isLoggedInProvider);
|
||||||
|
final loc = state.matchedLocation;
|
||||||
|
final isAuthRoute = loc == Routes.login || loc == Routes.bindPhone || loc == Routes.bindWx;
|
||||||
|
|
||||||
|
// 未登录 → 跳登录
|
||||||
|
if (!logged && !isAuthRoute && loc != Routes.splash && loc != Routes.welcome) {
|
||||||
|
return Routes.login;
|
||||||
|
}
|
||||||
|
// 已登录 → 不能留在登录页
|
||||||
|
if (logged && isAuthRoute) {
|
||||||
|
return Routes.home;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
refreshListenable: _AuthListenable(ref),
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: Routes.splash, builder: (_, __) => const SplashPage()),
|
||||||
|
GoRoute(path: Routes.welcome, builder: (_, __) => const WelcomePage()),
|
||||||
|
GoRoute(path: Routes.login, builder: (_, __) => const LoginPage()),
|
||||||
|
GoRoute(path: Routes.bindPhone, builder: (_, __) => const BindPhonePage()),
|
||||||
|
GoRoute(path: Routes.bindWx, builder: (_, __) => const BindWxPage()),
|
||||||
|
|
||||||
|
GoRoute(path: Routes.home, builder: (_, __) => const HomePage()),
|
||||||
|
GoRoute(path: Routes.me, builder: (_, __) => const MePage()),
|
||||||
|
GoRoute(path: Routes.perfect, builder: (_, __) => const PerfectPage()),
|
||||||
|
GoRoute(path: Routes.auth, builder: (_, __) => const AuthenticationPage()),
|
||||||
|
GoRoute(path: Routes.withdrawal, builder: (_, __) => const WithdrawalPage()),
|
||||||
|
GoRoute(path: Routes.withdrawalRecord, builder: (_, __) => const WithdrawalRecordPage()),
|
||||||
|
GoRoute(path: Routes.recharge, builder: (_, __) => const RechargePage()),
|
||||||
|
GoRoute(path: Routes.amountList, builder: (_, __) => const AmountListPage()),
|
||||||
|
GoRoute(path: Routes.feedback, builder: (_, __) => const FeedbackPage()),
|
||||||
|
GoRoute(path: Routes.messenger, builder: (_, __) => const MessengerPage()),
|
||||||
|
GoRoute(path: Routes.setting, builder: (_, __) => const SettingPage()),
|
||||||
|
GoRoute(path: Routes.optimization, builder: (_, __) => const OptimizationPage()),
|
||||||
|
|
||||||
|
GoRoute(path: Routes.orderCheck, builder: (_, __) => const CheckPage()),
|
||||||
|
GoRoute(path: Routes.orderWaiting, builder: (_, __) => const WaitingPage()),
|
||||||
|
GoRoute(
|
||||||
|
path: '${Routes.orderConduct}/:id',
|
||||||
|
builder: (_, s) => ConductPage(orderId: int.parse(s.pathParameters['id']!)),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '${Routes.orderPhotograph}/:id',
|
||||||
|
builder: (_, s) => PhotographPage(orderId: int.parse(s.pathParameters['id']!)),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '${Routes.orderAutograph}/:id',
|
||||||
|
builder: (_, s) => AutographPage(orderId: int.parse(s.pathParameters['id']!)),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '${Routes.orderCollection}/:id',
|
||||||
|
builder: (_, s) => CollectionPage(orderId: int.parse(s.pathParameters['id']!)),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '${Routes.orderComplete}/:id',
|
||||||
|
builder: (_, s) => CompletePage(orderId: int.parse(s.pathParameters['id']!)),
|
||||||
|
),
|
||||||
|
GoRoute(path: Routes.orderRecord, builder: (_, __) => const OrderRecordPage()),
|
||||||
|
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.webview,
|
||||||
|
builder: (_, s) => AgentWebPage(
|
||||||
|
url: s.uri.queryParameters['url'] ?? '',
|
||||||
|
title: s.uri.queryParameters['title'] ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(path: Routes.qrScan, builder: (_, __) => const QrScanPage()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 把 token 变更同步给 GoRouter 触发 redirect
|
||||||
|
class _AuthListenable extends ChangeNotifier {
|
||||||
|
_AuthListenable(this.ref) {
|
||||||
|
ref.listen<String?>(accessTokenProvider, (_, __) => notifyListeners());
|
||||||
|
}
|
||||||
|
final Ref ref;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 音频播放服务(对应 Android SoundPoolUtils)
|
||||||
|
///
|
||||||
|
/// 用 AudioPlayers(Flutter 包)替代 Android 的 SoundPool,
|
||||||
|
/// 提供短音效(订单提示、警告音)的低延迟播放能力。
|
||||||
|
class AudioService {
|
||||||
|
static final AudioService instance = AudioService._();
|
||||||
|
AudioService._();
|
||||||
|
|
||||||
|
final _player = AudioPlayer();
|
||||||
|
|
||||||
|
/// 是否启用声音提醒(用户设置开关)
|
||||||
|
bool enableSound = true;
|
||||||
|
|
||||||
|
/// 初始化播放源(使用 AssetSource)
|
||||||
|
Future<void> _play(String assetPath, {double volume = 1.0}) async {
|
||||||
|
if (!enableSound) return;
|
||||||
|
try {
|
||||||
|
await _player.stop();
|
||||||
|
await _player.setReleaseMode(ReleaseMode.stop);
|
||||||
|
await _player.setVolume(volume);
|
||||||
|
await _player.play(AssetSource(assetPath));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AudioService play failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新订单到来(救援平台内部订单)
|
||||||
|
Future<void> newInsideOrder() => _play('sounds/new_order_inside.mp3');
|
||||||
|
|
||||||
|
/// 新订单(啾啾救援订单)
|
||||||
|
Future<void> newJiuJiuOrder() => _play('sounds/new_order_jiujiu.mp3');
|
||||||
|
|
||||||
|
/// 订单被关闭
|
||||||
|
Future<void> orderClosed() => _play('sounds/order_closed.mp3');
|
||||||
|
|
||||||
|
/// 通用警告
|
||||||
|
Future<void> warning() => _play('sounds/warning.mp3');
|
||||||
|
|
||||||
|
/// 自定义音效
|
||||||
|
Future<void> custom(String assetPath, {double volume = 1.0}) =>
|
||||||
|
_play(assetPath, volume: volume);
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_player.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:autosos_flutter/service/heartbeat_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
|
|
||||||
|
/// 后台保活服务(对应 Android JjSosLongService)
|
||||||
|
///
|
||||||
|
/// 启动后会在原生层持有一个前台服务(Foreground Service),
|
||||||
|
/// 即使 App 被系统回收,isolate 仍可继续执行心跳与位置上报。
|
||||||
|
class BackgroundService {
|
||||||
|
static final BackgroundService instance = BackgroundService._();
|
||||||
|
BackgroundService._();
|
||||||
|
|
||||||
|
static const String _notificationChannelId = 'autosos_bg_channel';
|
||||||
|
static const int _notificationId = 888;
|
||||||
|
|
||||||
|
final _service = FlutterBackgroundService();
|
||||||
|
|
||||||
|
/// 是否正在运行
|
||||||
|
Future<bool> isRunning() => _service.isRunning();
|
||||||
|
|
||||||
|
/// 初始化(在 main() 里调用一次)
|
||||||
|
Future<void> init() async {
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) return;
|
||||||
|
|
||||||
|
await _service.configure(
|
||||||
|
androidConfiguration: AndroidConfiguration(
|
||||||
|
onStart: _onStart,
|
||||||
|
isForegroundMode: true,
|
||||||
|
autoStart: true,
|
||||||
|
autoStartOnBoot: true,
|
||||||
|
foregroundServiceNotificationId: _notificationId,
|
||||||
|
initialNotificationTitle: '啾啾救援',
|
||||||
|
initialNotificationContent: '正在保持在线...',
|
||||||
|
notificationChannelId: _notificationChannelId,
|
||||||
|
foregroundServiceTypes: const [
|
||||||
|
AndroidForegroundType.location,
|
||||||
|
AndroidForegroundType.dataSync,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
iosConfiguration: IosConfiguration(
|
||||||
|
autoStart: true,
|
||||||
|
onForeground: _onStart,
|
||||||
|
onBackground: _onIosBackground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动后台服务
|
||||||
|
Future<void> start() async {
|
||||||
|
final running = await _service.isRunning();
|
||||||
|
if (!running) {
|
||||||
|
await _service.startService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止后台服务
|
||||||
|
Future<void> stop() async {
|
||||||
|
_service.invoke('stopService');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android 入口(独立 isolate)
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
static void _onStart(ServiceInstance service) async {
|
||||||
|
if (service is AndroidServiceInstance) {
|
||||||
|
service.on('setAsForeground').listen((event) {
|
||||||
|
service.setAsForegroundService();
|
||||||
|
});
|
||||||
|
service.on('setAsBackground').listen((event) {
|
||||||
|
service.setAsBackgroundService();
|
||||||
|
});
|
||||||
|
service.on('stopService').listen((event) async {
|
||||||
|
await service.stopSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动位置采集
|
||||||
|
try {
|
||||||
|
LocationService.instance.init();
|
||||||
|
LocationService.instance.start();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('BG location start failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
HeartbeatService.instance.start();
|
||||||
|
|
||||||
|
// 持续保活:每 30 秒检查一次
|
||||||
|
Timer.periodic(const Duration(seconds: 30), (timer) async {
|
||||||
|
if (!HeartbeatService.instance.running) {
|
||||||
|
HeartbeatService.instance.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('Background service started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS 后台入口(必须尽快返回)
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
static bool _onIosBackground(ServiceInstance service) {
|
||||||
|
// iOS 不允许常驻后台任务,只能在限时窗口内上报
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:autosos_flutter/api/user_api.dart';
|
||||||
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 心跳服务(保持在线状态,上报位置)
|
||||||
|
class HeartbeatService {
|
||||||
|
static final HeartbeatService instance = HeartbeatService._();
|
||||||
|
HeartbeatService._();
|
||||||
|
|
||||||
|
Timer? _timer;
|
||||||
|
final Duration _interval = const Duration(seconds: 30);
|
||||||
|
int _onlineSeconds = 0;
|
||||||
|
bool _running = false;
|
||||||
|
|
||||||
|
bool get running => _running;
|
||||||
|
int get onlineSeconds => _onlineSeconds;
|
||||||
|
|
||||||
|
/// 启动心跳
|
||||||
|
void start() {
|
||||||
|
if (_running) return;
|
||||||
|
_running = true;
|
||||||
|
_onlineSeconds = 0;
|
||||||
|
_timer = Timer.periodic(_interval, (_) => _tick());
|
||||||
|
debugPrint('Heartbeat started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止心跳
|
||||||
|
void stop() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
_running = false;
|
||||||
|
_onlineSeconds = 0;
|
||||||
|
debugPrint('Heartbeat stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tick() async {
|
||||||
|
_onlineSeconds += _interval.inSeconds;
|
||||||
|
try {
|
||||||
|
final loc = LocationService.instance.last;
|
||||||
|
await UserApi.instance.heartbeat(
|
||||||
|
lat: (loc?['latitude'] as num?)?.toDouble(),
|
||||||
|
lng: (loc?['longitude'] as num?)?.toDouble(),
|
||||||
|
onlineTime: _onlineSeconds,
|
||||||
|
);
|
||||||
|
Constant.heartbeatTime = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Heartbeat tick failed: $e');
|
||||||
|
Constant.weberror = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置在线时长(接单/完成后调用)
|
||||||
|
void resetOnlineTime() {
|
||||||
|
_onlineSeconds = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:amap_flutter_location/amap_flutter_location.dart';
|
||||||
|
import 'package:amap_flutter_location/amap_location_option.dart';
|
||||||
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 定位服务(封装 AMap Flutter Location 3.0)
|
||||||
|
class LocationService {
|
||||||
|
static final LocationService instance = LocationService._();
|
||||||
|
LocationService._();
|
||||||
|
|
||||||
|
final _location = AMapFlutterLocation();
|
||||||
|
final _positionController =
|
||||||
|
StreamController<Map<String, Object>>.broadcast();
|
||||||
|
|
||||||
|
Stream<Map<String, Object>> get positionStream => _positionController.stream;
|
||||||
|
|
||||||
|
Map<String, Object>? _last;
|
||||||
|
Map<String, Object>? get last => _last;
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// 初始化并请求权限
|
||||||
|
Future<bool> init({
|
||||||
|
String androidKey = '',
|
||||||
|
String iosKey = '',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
if (_initialized) return true;
|
||||||
|
AMapFlutterLocation.setApiKey(androidKey, iosKey);
|
||||||
|
|
||||||
|
_location.setLocationOption(AMapLocationOption(
|
||||||
|
locationMode: AMapLocationMode.Hight_Accuracy,
|
||||||
|
onceLocation: false,
|
||||||
|
locationInterval: 5000, // 5 秒
|
||||||
|
desiredAccuracy: DesiredAccuracy.Best,
|
||||||
|
distanceFilter: -1,
|
||||||
|
needAddress: true,
|
||||||
|
));
|
||||||
|
|
||||||
|
_location.onLocationChanged().listen((event) {
|
||||||
|
_last = event;
|
||||||
|
Constant.lat = (event['latitude'] as num?)?.toDouble() ?? 0;
|
||||||
|
Constant.lng = (event['longitude'] as num?)?.toDouble() ?? 0;
|
||||||
|
Constant.address = (event['address'] as String?) ?? '';
|
||||||
|
_positionController.add(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('LocationService init failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始定位
|
||||||
|
void start() {
|
||||||
|
_location.startLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止定位
|
||||||
|
void stop() {
|
||||||
|
_location.stopLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单次定位(用于初始定位、报点等)
|
||||||
|
Future<Map<String, Object>?> fetchOnce() async {
|
||||||
|
start();
|
||||||
|
final completer = Completer<Map<String, Object>?>();
|
||||||
|
final sub = positionStream.listen((loc) {
|
||||||
|
if (!completer.isCompleted) completer.complete(loc);
|
||||||
|
});
|
||||||
|
final loc = await completer.future.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
await sub.cancel();
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_location.stopLocation();
|
||||||
|
_location.destroy();
|
||||||
|
_positionController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 平台判断辅助
|
||||||
|
static bool get isAndroid => Platform.isAndroid;
|
||||||
|
static bool get isIOS => Platform.isIOS;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:autosos_flutter/api/order_api.dart';
|
||||||
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
|
import 'package:autosos_flutter/model/order.dart';
|
||||||
|
import 'package:autosos_flutter/model/payment.dart';
|
||||||
|
import 'package:autosos_flutter/service/audio_service.dart';
|
||||||
|
import 'package:autosos_flutter/service/location_service.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 订单状态机 + 业务编排服务(对应 Android 订单状态机)
|
||||||
|
class OrderService {
|
||||||
|
static final OrderService instance = OrderService._();
|
||||||
|
OrderService._();
|
||||||
|
|
||||||
|
Order? _current;
|
||||||
|
Order? get currentOrder => _current;
|
||||||
|
|
||||||
|
/// 状态变化回调
|
||||||
|
final _stateController = StreamController<OrderStateEvent>.broadcast();
|
||||||
|
Stream<OrderStateEvent> get stateStream => _stateController.stream;
|
||||||
|
|
||||||
|
/// 救援中(conducting)→ 上报当前位置的轨迹点
|
||||||
|
Timer? _trackUploadTimer;
|
||||||
|
|
||||||
|
/// 设置当前订单
|
||||||
|
void setCurrent(Order? order) {
|
||||||
|
_current = order;
|
||||||
|
if (order != null) {
|
||||||
|
Constant.isService = order.status == OrderStatus.conducting;
|
||||||
|
_stateController.add(OrderStateEvent.updated(order));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接单
|
||||||
|
Future<Order> grab(int orderId) async {
|
||||||
|
final resp = await OrderApi.instance.grabOrder(orderId);
|
||||||
|
if (!resp.isSuccess) throw Exception(resp.message ?? '接单失败');
|
||||||
|
final order = resp.data!;
|
||||||
|
setCurrent(order);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 到达现场
|
||||||
|
Future<void> arrive({double? lat, double? lng}) async {
|
||||||
|
if (_current == null) throw Exception('当前无进行中订单');
|
||||||
|
final loc = LocationService.instance.last;
|
||||||
|
final r = await OrderApi.instance.arriveScene(
|
||||||
|
_current!.id,
|
||||||
|
lat: lat ?? (loc?['latitude'] as num?)?.toDouble(),
|
||||||
|
lng: lng ?? (loc?['longitude'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
if (!r.isSuccess) throw Exception(r.message ?? '上报到达失败');
|
||||||
|
final updated = _current!.copyWith(
|
||||||
|
status: OrderStatus.conducting,
|
||||||
|
arrivedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
setCurrent(updated);
|
||||||
|
_startTrack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成订单
|
||||||
|
Future<dynamic> complete({
|
||||||
|
required List<AmountItem> amountItems,
|
||||||
|
String? remark,
|
||||||
|
}) async {
|
||||||
|
if (_current == null) throw Exception('当前无订单');
|
||||||
|
final r = await OrderApi.instance.completeOrder(
|
||||||
|
orderId: _current!.id,
|
||||||
|
amountItems: amountItems,
|
||||||
|
remark: remark,
|
||||||
|
);
|
||||||
|
if (!r.isSuccess) throw Exception(r.message ?? '完成订单失败');
|
||||||
|
_stopTrack();
|
||||||
|
setCurrent(_current!.copyWith(
|
||||||
|
status: OrderStatus.completed,
|
||||||
|
completedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消订单
|
||||||
|
Future<void> cancel() async {
|
||||||
|
if (_current == null) return;
|
||||||
|
_stopTrack();
|
||||||
|
setCurrent(_current!.copyWith(status: OrderStatus.cancelled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动轨迹上传
|
||||||
|
void _startTrack() {
|
||||||
|
_trackUploadTimer?.cancel();
|
||||||
|
_trackUploadTimer = Timer.periodic(const Duration(seconds: 10), (_) async {
|
||||||
|
if (_current == null) return;
|
||||||
|
final loc = LocationService.instance.last;
|
||||||
|
if (loc == null) return;
|
||||||
|
try {
|
||||||
|
await OrderApi.instance.uploadTrackPoints(_current!.id, [
|
||||||
|
{
|
||||||
|
'lat': (loc['latitude'] as num?)?.toDouble() ?? 0,
|
||||||
|
'lng': (loc['longitude'] as num?)?.toDouble() ?? 0,
|
||||||
|
'time': DateTime.now().toIso8601String(),
|
||||||
|
'speed': (loc['speed'] as num?)?.toDouble() ?? 0,
|
||||||
|
'bearing': (loc['bearing'] as num?)?.toDouble() ?? 0,
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Track upload failed: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止轨迹上传
|
||||||
|
void _stopTrack() {
|
||||||
|
_trackUploadTimer?.cancel();
|
||||||
|
_trackUploadTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收到新订单推送时触发本地语音提醒
|
||||||
|
Future<void> onPushNewOrder(Map<String, dynamic> payload) async {
|
||||||
|
final type = (payload['type'] as num?)?.toInt() ?? 0;
|
||||||
|
if (type == Constant.NEW_ORDER_INSIDE) {
|
||||||
|
AudioService.instance.newInsideOrder();
|
||||||
|
} else if (type == Constant.NEW_ORDER_JIUJIU) {
|
||||||
|
AudioService.instance.newJiuJiuOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_stopTrack();
|
||||||
|
_stateController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单状态变化事件
|
||||||
|
class OrderStateEvent {
|
||||||
|
final OrderStateEventType type;
|
||||||
|
final Order? order;
|
||||||
|
const OrderStateEvent(this.type, this.order);
|
||||||
|
|
||||||
|
factory OrderStateEvent.updated(Order o) => OrderStateEvent(OrderStateEventType.updated, o);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStateEventType { updated, completed, cancelled, paid }
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:autosos_flutter/config/getui_constant.dart';
|
||||||
|
import 'package:autosos_flutter/util/sp_util.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:getuiflut/getuiflut.dart';
|
||||||
|
|
||||||
|
/// 推送服务(对应 Android 个推封装)
|
||||||
|
/// 使用 getuiflut 0.2.x 实际 API(addEventHandler 需要全部回调)
|
||||||
|
class PushService {
|
||||||
|
static final PushService instance = PushService._();
|
||||||
|
PushService._();
|
||||||
|
|
||||||
|
String? _clientId;
|
||||||
|
String? get clientId => _clientId;
|
||||||
|
|
||||||
|
final _payloadController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
Stream<Map<String, dynamic>> get payloadStream => _payloadController.stream;
|
||||||
|
|
||||||
|
final _notificationController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
Stream<Map<String, dynamic>> get notificationStream => _notificationController.stream;
|
||||||
|
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// 启动个推 SDK
|
||||||
|
void init() {
|
||||||
|
try {
|
||||||
|
if (_started) return;
|
||||||
|
|
||||||
|
// Android 通过 initGetuiSdk 启动(自动)
|
||||||
|
// iOS 通过 startSdk 启动
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
Getuiflut().startSdk(
|
||||||
|
appId: GetuiConstant.appId,
|
||||||
|
appKey: GetuiConstant.appKey,
|
||||||
|
appSecret: GetuiConstant.appSecret,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Getuiflut().initGetuiSdk;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册事件回调(addEventHandler 全部为 required)
|
||||||
|
Getuiflut().addEventHandler(
|
||||||
|
onReceiveClientId: (String message) async {
|
||||||
|
_clientId = message;
|
||||||
|
SPUtil().setString('clientId', message);
|
||||||
|
debugPrint('Getui ClientId: $message');
|
||||||
|
},
|
||||||
|
|
||||||
|
onRegisterDeviceToken: (String token) async {
|
||||||
|
debugPrint('Getui device token: $token');
|
||||||
|
},
|
||||||
|
onNotificationMessageArrived: (Map<String, dynamic> msg) async {
|
||||||
|
debugPrint('Getui notification arrived: $msg');
|
||||||
|
_notificationController.add(msg);
|
||||||
|
},
|
||||||
|
onNotificationMessageClicked: (Map<String, dynamic> msg) async {
|
||||||
|
debugPrint('Getui notification clicked: $msg');
|
||||||
|
_notificationController.add({...msg, '_clicked': true});
|
||||||
|
},
|
||||||
|
onTransmitUserMessageReceive: (Map<String, dynamic> msg) async {
|
||||||
|
debugPrint('Getui transmit msg: $msg');
|
||||||
|
},
|
||||||
|
onReceiveOnlineState: (String state) async {
|
||||||
|
debugPrint('Getui online state: $state');
|
||||||
|
},
|
||||||
|
onReceivePayload: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui receive payload: $message');
|
||||||
|
_payloadController.add(message);
|
||||||
|
},
|
||||||
|
onReceiveNotificationResponse: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui receive notification response: $message');
|
||||||
|
},
|
||||||
|
onAppLinkPayload: (String message) async {
|
||||||
|
debugPrint('Getui app link: $message');
|
||||||
|
},
|
||||||
|
onPushModeResult: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui push mode: $message');
|
||||||
|
},
|
||||||
|
onSetTagResult: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui set tag result: $message');
|
||||||
|
},
|
||||||
|
onAliasResult: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui alias result: $message');
|
||||||
|
},
|
||||||
|
onQueryTagResult: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui query tag result: $message');
|
||||||
|
},
|
||||||
|
onWillPresentNotification: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui will present: $message');
|
||||||
|
},
|
||||||
|
onOpenSettingsForNotification: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui open settings: $message');
|
||||||
|
},
|
||||||
|
onGrantAuthorization: (String granted) async {
|
||||||
|
debugPrint('Getui grant auth: $granted');
|
||||||
|
},
|
||||||
|
onLiveActivityResult: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui live activity: $message');
|
||||||
|
},
|
||||||
|
onRegisterPushToStartTokenResult: (Map<String, dynamic> message) async {
|
||||||
|
debugPrint('Getui push to start: $message');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('PushService init failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭推送通道(用户退出登录后)
|
||||||
|
void turnOff() {
|
||||||
|
try {
|
||||||
|
Getuiflut().turnOffPush();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('turnOff push failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开启推送
|
||||||
|
void turnOn() {
|
||||||
|
try {
|
||||||
|
Getuiflut().turnOnPush();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('turnOn push failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 clientId(异步)
|
||||||
|
Future<String?> fetchClientId() async {
|
||||||
|
try {
|
||||||
|
final id = await Getuiflut().getClientId;
|
||||||
|
_clientId = id;
|
||||||
|
if (id.isNotEmpty) {
|
||||||
|
SPUtil().setString('clientId', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('fetchClientId failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定用户别名(手机号 + sn 时间戳)
|
||||||
|
void bindAlias(String alias, String sn) {
|
||||||
|
try {
|
||||||
|
Getuiflut().bindAlias(alias, sn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('bindAlias failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解绑别名
|
||||||
|
void unbindAlias(String alias, String sn, {bool isSelf = true}) {
|
||||||
|
try {
|
||||||
|
Getuiflut().unbindAlias(alias, sn, isSelf);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('unbindAlias failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置标签
|
||||||
|
void setTags(List<String> tags, String sn) {
|
||||||
|
try {
|
||||||
|
Getuiflut().setTag(tags, sn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('setTags failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默时间(夜间免打扰)—— 实际 API 仅接受 beginHour + durationHours
|
||||||
|
void setSilentTime(int beginHour, int durationHours) {
|
||||||
|
try {
|
||||||
|
Getuiflut().setSilentTime(beginHour, durationHours);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('setSilentTime failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角标清零
|
||||||
|
void resetBadge() {
|
||||||
|
try {
|
||||||
|
Getuiflut().resetBadge();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('resetBadge failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_payloadController.close();
|
||||||
|
_notificationController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:autosos_flutter/api/user_api.dart';
|
||||||
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
|
import 'package:autosos_flutter/model/misc.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:install_plugin/install_plugin.dart';
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// 应用内更新服务(对应 Android HttpUpdateVersion)
|
||||||
|
class UpdateService {
|
||||||
|
static final UpdateService instance = UpdateService._();
|
||||||
|
UpdateService._();
|
||||||
|
|
||||||
|
final _dio = Dio();
|
||||||
|
|
||||||
|
/// 检查更新
|
||||||
|
Future<AppUpdate?> checkUpdate() async {
|
||||||
|
try {
|
||||||
|
final resp = await UserApi.instance.checkUpdate();
|
||||||
|
if (!resp.isSuccess) return null;
|
||||||
|
|
||||||
|
final update = resp.data!;
|
||||||
|
Constant.NECUPDATE = update.forceUpdate ? 3 : 2;
|
||||||
|
return update;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('checkUpdate failed: $e');
|
||||||
|
Constant.NECUPDATE = 5;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载并安装
|
||||||
|
Future<void> downloadAndInstall(AppUpdate update) async {
|
||||||
|
if (update.downloadUrl == null) {
|
||||||
|
EasyLoading.showError('下载链接为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EasyLoading.showProgress(0, status: '下载中 0%');
|
||||||
|
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final apkPath = '${dir.path}/autosos_${update.latestVersion}.apk';
|
||||||
|
|
||||||
|
await _dio.download(
|
||||||
|
update.downloadUrl!,
|
||||||
|
apkPath,
|
||||||
|
onReceiveProgress: (received, total) {
|
||||||
|
if (total > 0) {
|
||||||
|
final p = received / total;
|
||||||
|
EasyLoading.showProgress(p, status: '下载中 ${(p * 100).toStringAsFixed(0)}%');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await InstallPlugin.install(apkPath);
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
await OpenFilex.open(apkPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:autosos_flutter/api/upload_api.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 七牛文件上传服务(对应 Android QiNiuEntity + Qiniu SDK)
|
||||||
|
class UploadService {
|
||||||
|
static final UploadService instance = UploadService._();
|
||||||
|
UploadService._();
|
||||||
|
|
||||||
|
final _dio = Dio(BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 30),
|
||||||
|
receiveTimeout: const Duration(seconds: 60),
|
||||||
|
));
|
||||||
|
|
||||||
|
/// 上传单个文件,返回远端 URL
|
||||||
|
Future<String> upload(String localPath) async {
|
||||||
|
final tokenResp = await UploadApi.instance.qiNiuToken();
|
||||||
|
if (!tokenResp.isSuccess) {
|
||||||
|
throw Exception('获取七牛凭证失败: ${tokenResp.message}');
|
||||||
|
}
|
||||||
|
final token = tokenResp.data!;
|
||||||
|
|
||||||
|
final form = FormData.fromMap({
|
||||||
|
'token': token.token,
|
||||||
|
'file': await MultipartFile.fromFile(localPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
final url = 'http://up.qiniup.com/'; // 七牛华东上传地址
|
||||||
|
final r = await _dio.post<String>(url, data: form,
|
||||||
|
options: Options(responseType: ResponseType.json));
|
||||||
|
final data = r.data is Map ? r.data as Map : {};
|
||||||
|
final key = data['key'] as String?;
|
||||||
|
if (key == null) throw Exception('七牛返回 key 为空');
|
||||||
|
|
||||||
|
final domain = token.domain.endsWith('/') ? token.domain : '${token.domain}/';
|
||||||
|
return '$domain$key';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量上传(并发 3 个)
|
||||||
|
Future<List<String>> uploadBatch(List<String> localPaths, {int concurrency = 3}) async {
|
||||||
|
final results = <String?>[];
|
||||||
|
final pool = <Future<void>>[];
|
||||||
|
|
||||||
|
int cursor = 0;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (cursor < localPaths.length) {
|
||||||
|
final i = cursor++;
|
||||||
|
try {
|
||||||
|
results[i] = await upload(localPaths[i]);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('uploadBatch index=$i failed: $e');
|
||||||
|
results[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < concurrency && i < localPaths.length; i++) {
|
||||||
|
pool.add(worker());
|
||||||
|
}
|
||||||
|
await Future.wait(pool);
|
||||||
|
|
||||||
|
if (results.contains(null)) {
|
||||||
|
throw Exception('部分文件上传失败: $results');
|
||||||
|
}
|
||||||
|
return results.cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传并校验文件存在
|
||||||
|
Future<String?> uploadSafe(String localPath) async {
|
||||||
|
if (!await File(localPath).exists()) return null;
|
||||||
|
try {
|
||||||
|
return await upload(localPath);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('uploadSafe failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
|
||||||
|
/// Toast 工具类(封装 fluttertoast + EasyLoading)
|
||||||
|
class Toast {
|
||||||
|
Toast._();
|
||||||
|
|
||||||
|
/// 短提示(屏幕底部)
|
||||||
|
static void show(String message) {
|
||||||
|
if (message.isEmpty) return;
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: message,
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
timeInSecForIosWeb: 2,
|
||||||
|
backgroundColor: const Color(0xCC000000),
|
||||||
|
textColor: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成功样式(顶部绿色弹条)
|
||||||
|
static void success(String message) {
|
||||||
|
if (message.isEmpty) return;
|
||||||
|
EasyLoading.showSuccess(message, duration: const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败样式
|
||||||
|
static void error(String message) {
|
||||||
|
if (message.isEmpty) return;
|
||||||
|
EasyLoading.showError(message, duration: const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 警告
|
||||||
|
static void warn(String message) {
|
||||||
|
if (message.isEmpty) return;
|
||||||
|
EasyLoading.showInfo(message, duration: const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading
|
||||||
|
static void loading(String message) {
|
||||||
|
EasyLoading.show(status: message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void dismiss() => EasyLoading.dismiss();
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:autosos_flutter/config/constant.dart';
|
import 'package:autosos_flutter/config/constant.dart';
|
||||||
import 'package:autosos_flutter/util/device_id_utils.dart';
|
import 'package:autosos_flutter/util/device_id_utils.dart';
|
||||||
import 'package:basic_utils/basic_utils.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
|
@ -789,7 +788,6 @@ class Toast {
|
||||||
// EasyLoading.instance.loadingStyle = EasyLoadingStyle.custom;
|
// EasyLoading.instance.loadingStyle = EasyLoadingStyle.custom;
|
||||||
// 此处可自定义风格
|
// 此处可自定义风格
|
||||||
}
|
}
|
||||||
static final Toast _instance = Toast._();
|
|
||||||
|
|
||||||
static const String SUCCESS = "SUCCESS";
|
static const String SUCCESS = "SUCCESS";
|
||||||
static const String ERROR = "ERROR";
|
static const String ERROR = "ERROR";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum MultiState { loading, empty, error, success, offline }
|
||||||
|
|
||||||
|
/// 多状态视图(对应 Android MultiStatePage)
|
||||||
|
class MultiStateView extends StatelessWidget {
|
||||||
|
final MultiState state;
|
||||||
|
final Widget? child;
|
||||||
|
final String? emptyText;
|
||||||
|
final String? errorText;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final Widget? loadingWidget;
|
||||||
|
|
||||||
|
const MultiStateView({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
this.child,
|
||||||
|
this.emptyText,
|
||||||
|
this.errorText,
|
||||||
|
this.onRetry,
|
||||||
|
this.loadingWidget,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
switch (state) {
|
||||||
|
case MultiState.loading:
|
||||||
|
return loadingWidget ??
|
||||||
|
const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||||
|
case MultiState.empty:
|
||||||
|
return _Empty(text: emptyText ?? '暂无数据', onRetry: onRetry);
|
||||||
|
case MultiState.error:
|
||||||
|
return _Empty(
|
||||||
|
text: errorText ?? '加载失败,点击重试',
|
||||||
|
icon: Icons.error_outline,
|
||||||
|
onRetry: onRetry,
|
||||||
|
);
|
||||||
|
case MultiState.offline:
|
||||||
|
return _Empty(
|
||||||
|
text: '当前无网络,请检查后重试',
|
||||||
|
icon: Icons.wifi_off,
|
||||||
|
onRetry: onRetry,
|
||||||
|
);
|
||||||
|
case MultiState.success:
|
||||||
|
return child ?? const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Empty extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final IconData? icon;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
const _Empty({required this.text, this.icon, this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon ?? Icons.inbox_outlined,
|
||||||
|
size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(text, style: TextStyle(color: Colors.grey.shade600)),
|
||||||
|
if (onRetry != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// 自定义相机预览 + 拍照队列
|
||||||
|
/// 对应 Android CheckPhotoActivity(多张拍照取证)
|
||||||
|
class CameraView extends StatefulWidget {
|
||||||
|
final void Function(String path) onCaptured;
|
||||||
|
const CameraView({super.key, required this.onCaptured});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CameraView> createState() => _CameraViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CameraViewState extends State<CameraView>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
CameraController? _controller;
|
||||||
|
List<CameraDescription> _cameras = [];
|
||||||
|
int _cameraIdx = 0;
|
||||||
|
bool _initializing = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
final c = _controller;
|
||||||
|
if (c == null || !c.value.isInitialized) return;
|
||||||
|
if (state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.paused) {
|
||||||
|
c.dispose();
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
_setup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setup() async {
|
||||||
|
setState(() {
|
||||||
|
_initializing = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final cam = await Permission.camera.request();
|
||||||
|
if (!cam.isGranted) {
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_error = '未授予相机权限';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cameras = await availableCameras();
|
||||||
|
if (_cameras.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_error = '未检测到摄像头';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cameraIdx = _cameras.indexWhere(
|
||||||
|
(c) => c.lensDirection == CameraLensDirection.back,
|
||||||
|
);
|
||||||
|
if (_cameraIdx < 0) _cameraIdx = 0;
|
||||||
|
await _initController();
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_initializing = false;
|
||||||
|
_error = '相机初始化失败: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initController() async {
|
||||||
|
final c = CameraController(
|
||||||
|
_cameras[_cameraIdx],
|
||||||
|
ResolutionPreset.high,
|
||||||
|
enableAudio: false,
|
||||||
|
);
|
||||||
|
await c.initialize();
|
||||||
|
if (!mounted) {
|
||||||
|
await c.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_controller = c;
|
||||||
|
_initializing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _switchCamera() async {
|
||||||
|
if (_cameras.length < 2) return;
|
||||||
|
_cameraIdx = (_cameraIdx + 1) % _cameras.length;
|
||||||
|
await _controller?.dispose();
|
||||||
|
await _initController();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _takePicture() async {
|
||||||
|
final c = _controller;
|
||||||
|
if (c == null || !c.value.isInitialized) return;
|
||||||
|
if (c.value.isTakingPicture) return;
|
||||||
|
try {
|
||||||
|
EasyLoading.show(status: '拍照中…');
|
||||||
|
final xfile = await c.takePicture();
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
// 复制到应用缓存目录(防止临时文件被清理)
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final out =
|
||||||
|
'${dir.photos_cache_path()}/${const Uuid().v4()}.jpg';
|
||||||
|
await File(xfile.path).copy(out);
|
||||||
|
widget.onCaptured(out);
|
||||||
|
} catch (e) {
|
||||||
|
EasyLoading.dismiss();
|
||||||
|
EasyLoading.showError('拍照失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.no_photography, size: 64, color: Colors.white),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.white)),
|
||||||
|
TextButton(onPressed: _setup, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_initializing || _controller == null) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
CameraPreview(_controller!),
|
||||||
|
// 顶部切换镜头
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.cameraswitch, color: Colors.white),
|
||||||
|
onPressed: _switchCamera,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 底部拍照按钮
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 32,
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _takePicture,
|
||||||
|
child: Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 4),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.circle, color: Colors.white, size: 56),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _DirPath on Directory {
|
||||||
|
String photos_cache_path() {
|
||||||
|
final p = path;
|
||||||
|
if (p.endsWith('/')) return '${p}photos';
|
||||||
|
return '$p/photos';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部照片缩略图横排(可删除)
|
||||||
|
class PhotoThumbStrip extends StatelessWidget {
|
||||||
|
final List<String> paths;
|
||||||
|
final ValueChanged<int> onRemove;
|
||||||
|
final VoidCallback? onAdd;
|
||||||
|
const PhotoThumbStrip({
|
||||||
|
super.key,
|
||||||
|
required this.paths,
|
||||||
|
required this.onRemove,
|
||||||
|
this.onAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 88,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
itemCount: paths.length + 1,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == paths.length) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onAdd,
|
||||||
|
child: Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black12,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.add_a_photo, color: Colors.white70),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Image.file(
|
||||||
|
File(paths[i]),
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onRemove(i),
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 备选:从相册选择(用 image_picker)
|
||||||
|
class AlbumPicker {
|
||||||
|
static final _picker = ImagePicker();
|
||||||
|
|
||||||
|
static Future<List<String>> pick({int max = 9}) async {
|
||||||
|
final files = await _picker.pickMultiImage(
|
||||||
|
imageQuality: 85,
|
||||||
|
maxWidth: 1920,
|
||||||
|
);
|
||||||
|
if (files.isEmpty) return const [];
|
||||||
|
return files.take(max).map((f) => f.path).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart' as qr;
|
||||||
|
|
||||||
|
/// 二维码显示组件(避免和 flutter_inappwebview 包内同名类型冲突)
|
||||||
|
class QrImageView extends StatelessWidget {
|
||||||
|
final String data;
|
||||||
|
final double size;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
|
const QrImageView({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
this.size = 200,
|
||||||
|
this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor ?? Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: qr.QrImageView(
|
||||||
|
data: data,
|
||||||
|
version: qr.QrVersions.auto,
|
||||||
|
size: size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 通用按钮(带 loading 态)
|
||||||
|
class LoadingButton extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final bool loading;
|
||||||
|
final Color? color;
|
||||||
|
final Color? textColor;
|
||||||
|
final double? width;
|
||||||
|
final double height;
|
||||||
|
final double radius;
|
||||||
|
|
||||||
|
const LoadingButton({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
this.onPressed,
|
||||||
|
this.loading = false,
|
||||||
|
this.color,
|
||||||
|
this.textColor,
|
||||||
|
this.width,
|
||||||
|
this.height = 48,
|
||||||
|
this.radius = 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = color ?? Theme.of(context).primaryColor;
|
||||||
|
final tc = textColor ?? Colors.white;
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: loading ? null : onPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: c,
|
||||||
|
foregroundColor: tc,
|
||||||
|
disabledBackgroundColor: c.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(tc),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(fontSize: 16, color: tc, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用 Section 头
|
||||||
|
class SectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? actionText;
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
const SectionHeader({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.actionText,
|
||||||
|
this.onAction,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
if (actionText != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onAction,
|
||||||
|
child: Text(actionText!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13, color: Theme.of(context).primaryColor)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用空状态
|
||||||
|
class EmptyView extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final IconData icon;
|
||||||
|
const EmptyView({super.key, this.text = '暂无数据', this.icon = Icons.inbox_outlined});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(text, style: TextStyle(color: Colors.grey.shade600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// 防截屏水印(对应 Android WatermarkUtils)
|
||||||
|
class Watermark extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final int columns;
|
||||||
|
final int rows;
|
||||||
|
final double rotation;
|
||||||
|
|
||||||
|
const Watermark({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
this.columns = 4,
|
||||||
|
this.rows = 8,
|
||||||
|
this.rotation = -0.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: true,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _WatermarkPainter(
|
||||||
|
text: '$text ${DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now())}',
|
||||||
|
columns: columns,
|
||||||
|
rows: rows,
|
||||||
|
rotation: rotation,
|
||||||
|
),
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatermarkPainter extends CustomPainter {
|
||||||
|
final String text;
|
||||||
|
final int columns;
|
||||||
|
final int rows;
|
||||||
|
final double rotation;
|
||||||
|
|
||||||
|
_WatermarkPainter({
|
||||||
|
required this.text,
|
||||||
|
required this.columns,
|
||||||
|
required this.rows,
|
||||||
|
required this.rotation,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: ui.TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
|
||||||
|
final dx = size.width / columns;
|
||||||
|
final dy = size.height / rows;
|
||||||
|
|
||||||
|
for (int i = 0; i <= columns; i++) {
|
||||||
|
for (int j = 0; j <= rows; j++) {
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(i * dx, j * dy);
|
||||||
|
canvas.rotate(rotation);
|
||||||
|
textPainter.paint(canvas, const Offset(0, 0));
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_WatermarkPainter old) => old.text != text;
|
||||||
|
}
|
||||||
1590
pubspec.lock
1590
pubspec.lock
File diff suppressed because it is too large
Load Diff
153
pubspec.yaml
153
pubspec.yaml
|
|
@ -1,103 +1,110 @@
|
||||||
name: autosos_flutter
|
name: autosos_flutter
|
||||||
description: "A new Flutter project."
|
description: "JJSOS 道路救援技师端 — Flutter 实现,对齐 Android jjsos_autosos 4.1.3.3"
|
||||||
# The following line prevents the package from being accidentally published to
|
publish_to: 'none'
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
version: 4.1.39+421
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
||||||
|
|
||||||
# The following defines the version and build number for your application.
|
|
||||||
# A version number is three numbers separated by dots, like 1.2.43
|
|
||||||
# followed by an optional build number separated by a +.
|
|
||||||
# Both the version and the builder number may be overridden in flutter
|
|
||||||
# build by specifying --build-name and --build-number, respectively.
|
|
||||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
|
||||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
|
||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
|
||||||
# Read more about iOS versioning at
|
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
|
||||||
version: 1.0.0+1
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
|
||||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
|
||||||
# dependencies can be manually updated by changing the version numbers below to
|
|
||||||
# the latest version available on pub.dev. To see which dependencies have newer
|
|
||||||
# versions available, run `flutter pub outdated`.
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
# ============ 基础 ============
|
||||||
|
cupertino_icons: ^1.0.6
|
||||||
|
|
||||||
|
# ============ 状态管理 & 路由 ============
|
||||||
|
flutter_riverpod: ^2.5.1
|
||||||
|
riverpod_annotation: ^2.3.5
|
||||||
|
go_router: ^14.2.0
|
||||||
|
|
||||||
|
# ============ 网络 ============
|
||||||
|
dio: ^5.4.1
|
||||||
|
basic_utils: ^5.7.0
|
||||||
|
|
||||||
|
# ============ 存储 ============
|
||||||
|
shared_preferences: ^2.2.2
|
||||||
|
hive: ^2.2.3
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
# ============ 设备信息 / 工具 ============
|
||||||
|
device_info_plus: ^9.1.2
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
uuid: ^4.3.3
|
||||||
|
intl: ^0.19.0
|
||||||
|
path_provider: ^2.1.2
|
||||||
|
url_launcher: ^6.2.4
|
||||||
|
|
||||||
|
# ============ 定位 / 地图 / 导航 ============
|
||||||
amap_flutter_location: ^3.0.0
|
amap_flutter_location: ^3.0.0
|
||||||
amap_flutter_map: ^3.0.0
|
amap_flutter_map: ^3.0.0
|
||||||
dio: ^5.4.1
|
|
||||||
flutter_easyloading: ^3.0.5
|
# ============ 推送 ============
|
||||||
shared_preferences: ^2.2.2
|
|
||||||
basic_utils: ^5.7.0
|
|
||||||
uuid: ^4.3.3
|
|
||||||
device_info_plus: ^9.1.2
|
|
||||||
getuiflut: ^0.2.25
|
getuiflut: ^0.2.25
|
||||||
|
|
||||||
|
# ============ 权限 ============
|
||||||
permission_handler: ^11.3.0
|
permission_handler: ^11.3.0
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# ============ UI 反馈 ============
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
flutter_easyloading: ^3.0.5
|
||||||
cupertino_icons: ^1.0.2
|
fluttertoast: ^8.2.5
|
||||||
|
|
||||||
|
# ============ 媒体:相机/图片/签名 ============
|
||||||
|
camera: ^0.10.5+9
|
||||||
|
image_picker: ^1.0.7
|
||||||
|
flutter_image_compress: ^2.3.0
|
||||||
|
signature: ^5.4.0
|
||||||
|
|
||||||
|
# ============ 扫码 ============
|
||||||
|
mobile_scanner: ^5.0.0
|
||||||
|
qr_flutter: ^4.1.0
|
||||||
|
|
||||||
|
# ============ WebView ============
|
||||||
|
flutter_inappwebview: ^6.0.0
|
||||||
|
|
||||||
|
# ============ 图片加载 ============
|
||||||
|
cached_network_image: ^3.3.1
|
||||||
|
photo_view: ^0.15.0
|
||||||
|
flutter_svg: ^2.0.9
|
||||||
|
shimmer: ^3.0.0
|
||||||
|
|
||||||
|
# ============ 音频(订单语音提醒) ============
|
||||||
|
audioplayers: ^5.2.1
|
||||||
|
|
||||||
|
# ============ 后台服务 / 保活 ============
|
||||||
|
flutter_background_service: ^5.0.5
|
||||||
|
flutter_background_service_ios: ^5.0.0
|
||||||
|
workmanager: ^0.5.1
|
||||||
|
|
||||||
|
# ============ 微信登录/支付/分享 ============
|
||||||
|
fluwx: ^4.5.0
|
||||||
|
|
||||||
|
# ============ 崩溃监控 & 性能 ============
|
||||||
|
sentry_flutter: ^7.13.0
|
||||||
|
sentry_dio: ^7.13.0
|
||||||
|
|
||||||
|
# ============ 应用内更新(下载 APK) ============
|
||||||
|
install_plugin: ^2.0.0
|
||||||
|
open_filex: ^4.3.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
flutter_lints: ^3.0.0
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
|
||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
|
||||||
# package. See that file for information about deactivating specific lint
|
|
||||||
# rules and activating additional ones.
|
|
||||||
flutter_lints: ^2.0.0
|
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# 代码生成
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
build_runner: ^2.4.7
|
||||||
|
json_serializable: ^6.7.1
|
||||||
|
riverpod_generator: ^2.4.0
|
||||||
|
hive_generator: ^2.0.1
|
||||||
|
|
||||||
# The following section is specific to Flutter packages.
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
|
||||||
# included with your application, so that you can use the icons in
|
|
||||||
# the material Icons class.
|
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
|
||||||
assets:
|
assets:
|
||||||
- images/
|
- images/
|
||||||
- images/1.5x/
|
- images/1.5x/
|
||||||
- images/2.0x/
|
- images/2.0x/
|
||||||
- images/3.0x/
|
- images/3.0x/
|
||||||
- images/4.0x/
|
- images/4.0x/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
|
||||||
|
|
||||||
# For details regarding adding assets from package dependencies, see
|
|
||||||
# https://flutter.dev/assets-and-images/#from-packages
|
|
||||||
|
|
||||||
# To add custom fonts to your application, add a fonts section here,
|
|
||||||
# in this "flutter" section. Each entry in this list should have a
|
|
||||||
# "family" key with the font family name, and a "fonts" key with a
|
|
||||||
# list giving the asset and other descriptors for the font. For
|
|
||||||
# example:
|
|
||||||
# fonts:
|
|
||||||
# - family: Schyler
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/Schyler-Regular.ttf
|
|
||||||
# - asset: fonts/Schyler-Italic.ttf
|
|
||||||
# style: italic
|
|
||||||
# - family: Trajan Pro
|
|
||||||
# fonts:
|
|
||||||
# - asset: fonts/TrajanPro.ttf
|
|
||||||
# - asset: fonts/TrajanPro_Bold.ttf
|
|
||||||
# weight: 700
|
|
||||||
#
|
|
||||||
# For details regarding fonts from package dependencies,
|
|
||||||
# see https://flutter.dev/custom-fonts/#from-packages
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,22 @@
|
||||||
// This is a basic Flutter widget test.
|
// This is a basic Flutter widget test for jjsos_autosos_flutter.
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:autosos_flutter/main.dart';
|
import 'package:autosos_flutter/app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('App boots and shows splash/welcome',
|
||||||
// Build our app and trigger a frame.
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(
|
||||||
|
const ProviderScope(
|
||||||
// Verify that our counter starts at 0.
|
child: AutososApp(),
|
||||||
expect(find.text('0'), findsOneWidget);
|
),
|
||||||
expect(find.text('1'), findsNothing);
|
);
|
||||||
|
// Allow first frame
|
||||||
// Tap the '+' icon and trigger a frame.
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
// No exception means the app rendered
|
||||||
await tester.pump();
|
expect(find.byType(MaterialApp), findsOneWidget);
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue