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
王海滨 2026-07-17 15:14:44 +08:00
parent 1c9466b9b5
commit 43c780f74c
61 changed files with 9812 additions and 497 deletions

View File

@ -1,9 +1,77 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
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
android:label="autosos_flutter"
android:label="啾啾救援"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
@ -12,10 +80,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
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
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@ -25,10 +89,14 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- 高德定位 / 地图 Key(与 Android 端保持一致)-->
<meta-data
android:name="com.amap.api.v2.apikey"
android:value="androidKey_is_set_in_manifest"/>
</application>
</manifest>

View File

@ -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>

24
lib/api/_api_client.dart Normal file
View File

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

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:autosos_flutter/pages/home/home_page.dart';
import 'package:autosos_flutter/util/sp_util.dart';
import 'package:autosos_flutter/util/xhttp.dart';

188
lib/api/order_api.dart Normal file
View File

@ -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>));
}
}

29
lib/api/upload_api.dart Normal file
View File

@ -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);
}
}

308
lib/api/user_api.dart Normal file
View File

@ -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);
}
}

43
lib/app.dart Normal file
View File

@ -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(),
);
},
),
);
}
}

View File

@ -1,197 +1,117 @@
import 'dart:io';
import 'package:autosos_flutter/config/theme_colors.dart';
import 'package:autosos_flutter/pages/login/login_page.dart';
import 'package:autosos_flutter/app.dart';
import 'package:autosos_flutter/config/constant.dart';
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/service/update_service.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/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:getuiflut/getuiflut.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sentry_dio/sentry_dio.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({});
runApp(const MyApp());
// 强制竖屏
await SystemChrome.setPreferredOrientations(const [
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;
}
// 启动 Sentry(崩溃监控 + Dio 拦截)
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();
// App 启动后异步执行:
_postBoot();
runApp(
const ProviderScope(
child: AutososApp(),
),
);
},
);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
// This widget is the root of your application.
@override
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';
String _userMsg = "";
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 {
platformVersion = await Getuiflut.platformVersion;
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) {
e.toString();
}
}
Future<void> getClientId() async {
String getClientId;
try {
getClientId = await Getuiflut.getClientId;
print(getClientId);
} catch (e) {
print(e.toString());
}
}
Future<void> getSdkVersion() async {
String ver;
try {
ver = await Getuiflut.sdkVersion;
print(ver);
} catch (e) {
print(e.toString());
}
}
Future<void> getLaunchNotification() async {
Map info;
try {
info = await Getuiflut.getLaunchNotification;
print(info);
} catch (e) {
print(e.toString());
}
}
/// 启动后异步执行:后台服务 / 定位 / 推送 / 更新
/// 任何步骤失败都不影响 App 启动
Future<void> _postBoot() async {
// 后台保活
try {
await BackgroundService.instance.init();
await BackgroundService.instance.start();
} catch (e) {
debugPrint('BG init failed: $e');
}
// 定位
try {
await LocationService.instance.init();
} catch (e) {
debugPrint('Location init failed: $e');
}
// 个推
try {
PushService.instance.init();
} catch (e) {
debugPrint('Push init failed: $e');
}
// 延迟 2s 后检查更新(避免影响冷启动)
Future.delayed(const Duration(seconds: 2), () async {
try {
final update = await UpdateService.instance.checkUpdate();
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) {
debugPrint('Update check failed: $e');
Constant.NECUPDATE = 5;
}
});
}

View File

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

219
lib/model/misc.dart Normal file
View File

@ -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(),
);
}

263
lib/model/order.dart Normal file
View File

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

120
lib/model/payment.dart Normal file
View File

@ -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?,
);
}

124
lib/model/user.dart Normal file
View File

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

View File

@ -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),
),
),
),
),
],
),
);
}
}

View File

@ -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('登录'),
),
),
],
);
}
}

View File

@ -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),
),
),
],
),
),
),
);
}
}

View File

@ -1,7 +1,5 @@
import 'package:autosos_flutter/api/login_api.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';

View File

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

View File

@ -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),
),
);

View File

@ -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 ? '提交中…' : '提交反馈'),
),
),
],
),
),
);
}
}

328
lib/pages/me/me_page.dart Normal file
View File

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

View File

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

View File

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

View File

@ -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 ? '保存中…' : '保存'),
),
),
],
),
),
),
);
}
}

View File

@ -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 ? '处理中…' : '立即充值'),
),
],
),
),
);
}
}

View File

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

View File

@ -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),
),
],
),
),
);
}
}

View File

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

View File

@ -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 ? '提交中…' : '确认提交签字'),
),
),
),
],
),
);
}
}

View File

@ -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('确认核单'),
),
),
],
),
],
),
),
),
);
}
}

View File

@ -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),
),
),
],
),
),
);
}
}

View File

@ -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('添加'),
),
],
);
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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,
),
),
),
),
],
),
);
}
}

View File

@ -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)),
],
),
),
),
);
}
}

View File

@ -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),
),
),
),
],
),
],
),
),
);
}
}

View File

@ -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('同意并继续'),
),
],
),
);
}

View File

@ -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),
],
),
),
);
}
}

View File

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

View File

@ -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)),
],
),
);
}
}

View File

@ -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);

148
lib/router/app_router.dart Normal file
View File

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

View File

@ -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();
}
}

View File

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

View File

@ -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();
}
}

View File

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

View File

@ -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 }

View File

@ -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();
}
}

View File

@ -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);
}
}
}

View File

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

46
lib/util/toast.dart Normal file
View File

@ -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();
}

View File

@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:autosos_flutter/config/constant.dart';
import 'package:autosos_flutter/util/device_id_utils.dart';
import 'package:basic_utils/basic_utils.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
@ -789,7 +788,6 @@ class Toast {
// EasyLoading.instance.loadingStyle = EasyLoadingStyle.custom;
// 此处可自定义风格
}
static final Toast _instance = Toast._();
static const String SUCCESS = "SUCCESS";
static const String ERROR = "ERROR";

View File

@ -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('重试')),
],
],
),
);
}
}

View File

@ -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();
}
}

32
lib/widget/qr_image.dart Normal file
View File

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

View File

@ -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)),
],
),
);
}
}

80
lib/widget/watermark.dart Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,103 +1,110 @@
name: autosos_flutter
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
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
description: "JJSOS 道路救援技师端 — Flutter 实现,对齐 Android jjsos_autosos 4.1.3.3"
publish_to: 'none'
version: 4.1.39+421
environment:
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:
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_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
# ============ 权限 ============
permission_handler: ^11.3.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
# ============ UI 反馈 ============
flutter_easyloading: ^3.0.5
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:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# 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
flutter_lints: ^3.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:
# 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
# To add assets to your application, add an assets section, like this:
assets:
- images/
- images/1.5x/
- images/2.0x/
- images/3.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
- images/
- images/1.5x/
- images/2.0x/
- images/3.0x/
- images/4.0x/

View File

@ -1,30 +1,22 @@
// This is a basic Flutter widget test.
//
// 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.
// This is a basic Flutter widget test for jjsos_autosos_flutter.
import 'package:flutter/material.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() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
testWidgets('App boots and shows splash/welcome',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: AutososApp(),
),
);
// Allow first frame
await tester.pump(const Duration(milliseconds: 100));
// No exception means the app rendered
expect(find.byType(MaterialApp), findsOneWidget);
});
}