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" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.autosos_flutter"> package="com.example.autosos_flutter">
<!-- 基础网络 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<!-- 定位 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- 相机 / 闪光灯 -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.FLASHLIGHT"/>
<!-- 存储Android 10 以下)-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<!-- Android 13+ 媒体权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- 音频 / 录音 -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!-- 振动 / 通知 -->
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 蓝牙(部分车机连接)-->
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<!-- 省电白名单(用于后台保活)-->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- 安装 APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<!-- 启动系统安装器 -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.DIAL"/>
<data android:scheme="tel"/>
</intent>
<!-- 高德地图 -->
<package android:name="com.autonavi.minimap"/>
<!-- 微信 -->
<package android:name="com.tencent.mm"/>
</queries>
<application <application
android:label="autosos_flutter" android:label="啾啾救援"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -12,10 +80,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
@ -25,10 +89,14 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- 高德定位 / 地图 Key与 Android 端保持一致)-->
<meta-data
android:name="com.amap.api.v2.apikey"
android:value="androidKey_is_set_in_manifest"/>
</application> </application>
</manifest> </manifest>

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/pages/home/home_page.dart';
import 'package:autosos_flutter/util/sp_util.dart'; import 'package:autosos_flutter/util/sp_util.dart';
import 'package:autosos_flutter/util/xhttp.dart'; import 'package:autosos_flutter/util/xhttp.dart';

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 'package:autosos_flutter/app.dart';
import 'dart:io'; import 'package:autosos_flutter/config/constant.dart';
import 'package:autosos_flutter/service/background_service.dart';
import 'package:autosos_flutter/config/theme_colors.dart'; import 'package:autosos_flutter/service/location_service.dart';
import 'package:autosos_flutter/pages/login/login_page.dart'; import 'package:autosos_flutter/service/push_service.dart';
import 'package:autosos_flutter/service/update_service.dart';
import 'package:autosos_flutter/util/sp_util.dart'; import 'package:autosos_flutter/util/sp_util.dart';
import 'package:autosos_flutter/util/xhttp.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:getuiflut/getuiflut.dart'; import 'package:sentry_dio/sentry_dio.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'config/getui_constant.dart'; ///
///
///
/// 1. /
/// 2. SP
/// 3. Sentry runApp
/// 4. App / / /
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
void main() { //
SharedPreferences.setMockInitialValues({}); await SystemChrome.setPreferredOrientations(const [
runApp(const MyApp()); DeviceOrientation.portraitUp,
} DeviceOrientation.portraitDown,
]);
class MyApp extends StatefulWidget { //
const MyApp({super.key}); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
));
@override // SP
_MyAppState createState() => _MyAppState(); SPUtil().init();
}
class _MyAppState extends State<MyApp> { //
// This widget is the root of your application. final savedToken = SPUtil().get('accessToken') as String?;
@override if (savedToken != null && savedToken.isNotEmpty) {
Widget build(BuildContext context) { Constant.newVersion =
return MaterialApp( (SPUtil().get('appver') as String?) ?? Constant.newVersion;
title: 'Flutter Demo', }
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: ThemeColors.primary), // Sentry + Dio
useMaterial3: true, await SentryFlutter.init(
), (options) {
home: const LoginPage(), options.dsn = const String.fromEnvironment(
builder: EasyLoading.init(), 'SENTRY_DSN',
defaultValue: '',
); );
} options.tracesSampleRate = 0.1;
options.environment = kDebugMode ? 'debug' : 'release';
},
appRunner: () async {
// Dio Sentry SentryDioExtension Dio
XHttp.dio.addSentry();
String _platformVersion = 'Unknown'; // App
String _payloadInfo = 'Null'; _postBoot();
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());
}
}
runApp(
const ProviderScope(
child: AutososApp(),
),
);
},
);
}
/// / / /
/// 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/api/login_api.dart';
import 'package:autosos_flutter/util/sp_util.dart'; import 'package:autosos_flutter/util/sp_util.dart';
import 'package:autosos_flutter/util/xhttp.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

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
///
/// AudioPlayersFlutter 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 APIaddEventHandler
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 'dart:convert';
import 'package:autosos_flutter/config/constant.dart'; import 'package:autosos_flutter/config/constant.dart';
import 'package:autosos_flutter/util/device_id_utils.dart'; import 'package:autosos_flutter/util/device_id_utils.dart';
import 'package:basic_utils/basic_utils.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart';
@ -789,7 +788,6 @@ class Toast {
// EasyLoading.instance.loadingStyle = EasyLoadingStyle.custom; // EasyLoading.instance.loadingStyle = EasyLoadingStyle.custom;
// //
} }
static final Toast _instance = Toast._();
static const String SUCCESS = "SUCCESS"; static const String SUCCESS = "SUCCESS";
static const String ERROR = "ERROR"; static const String ERROR = "ERROR";

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 name: autosos_flutter
description: "A new Flutter project." description: "JJSOS 道路救援技师端 — Flutter 实现,对齐 Android jjsos_autosos 4.1.3.3"
# The following line prevents the package from being accidentally published to publish_to: 'none'
# pub.dev using `flutter pub publish`. This is preferred for private packages. version: 4.1.39+421
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# ============ 基础 ============
cupertino_icons: ^1.0.6
# ============ 状态管理 & 路由 ============
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
go_router: ^14.2.0
# ============ 网络 ============
dio: ^5.4.1
basic_utils: ^5.7.0
# ============ 存储 ============
shared_preferences: ^2.2.2
hive: ^2.2.3
hive_flutter: ^1.1.0
# ============ 设备信息 / 工具 ============
device_info_plus: ^9.1.2
package_info_plus: ^8.0.0
uuid: ^4.3.3
intl: ^0.19.0
path_provider: ^2.1.2
url_launcher: ^6.2.4
# ============ 定位 / 地图 / 导航 ============
amap_flutter_location: ^3.0.0 amap_flutter_location: ^3.0.0
amap_flutter_map: ^3.0.0 amap_flutter_map: ^3.0.0
dio: ^5.4.1
flutter_easyloading: ^3.0.5 # ============ 推送 ============
shared_preferences: ^2.2.2
basic_utils: ^5.7.0
uuid: ^4.3.3
device_info_plus: ^9.1.2
getuiflut: ^0.2.25 getuiflut: ^0.2.25
# ============ 权限 ============
permission_handler: ^11.3.0 permission_handler: ^11.3.0
# The following adds the Cupertino Icons font to your application. # ============ UI 反馈 ============
# Use with the CupertinoIcons class for iOS style icons. flutter_easyloading: ^3.0.5
cupertino_icons: ^1.0.2 fluttertoast: ^8.2.5
# ============ 媒体:相机/图片/签名 ============
camera: ^0.10.5+9
image_picker: ^1.0.7
flutter_image_compress: ^2.3.0
signature: ^5.4.0
# ============ 扫码 ============
mobile_scanner: ^5.0.0
qr_flutter: ^4.1.0
# ============ WebView ============
flutter_inappwebview: ^6.0.0
# ============ 图片加载 ============
cached_network_image: ^3.3.1
photo_view: ^0.15.0
flutter_svg: ^2.0.9
shimmer: ^3.0.0
# ============ 音频(订单语音提醒) ============
audioplayers: ^5.2.1
# ============ 后台服务 / 保活 ============
flutter_background_service: ^5.0.5
flutter_background_service_ios: ^5.0.0
workmanager: ^0.5.1
# ============ 微信登录/支付/分享 ============
fluwx: ^4.5.0
# ============ 崩溃监控 & 性能 ============
sentry_flutter: ^7.13.0
sentry_dio: ^7.13.0
# ============ 应用内更新(下载 APK ============
install_plugin: ^2.0.0
open_filex: ^4.3.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to flutter_lints: ^3.0.0
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^2.0.0
# For information on the generic Dart part of this file, see the # 代码生成
# following page: https://dart.dev/tools/pub/pubspec build_runner: ^2.4.7
json_serializable: ^6.7.1
riverpod_generator: ^2.4.0
hive_generator: ^2.0.1
# The following section is specific to Flutter packages.
flutter: flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this:
assets: assets:
- images/ - images/
- images/1.5x/ - images/1.5x/
- images/2.0x/ - images/2.0x/
- images/3.0x/ - images/3.0x/
- images/4.0x/ - images/4.0x/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages

View File

@ -1,30 +1,22 @@
// This is a basic Flutter widget test. // This is a basic Flutter widget test for jjsos_autosos_flutter.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:autosos_flutter/main.dart'; import 'package:autosos_flutter/app.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('App boots and shows splash/welcome',
// Build our app and trigger a frame. (WidgetTester tester) async {
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(
const ProviderScope(
// Verify that our counter starts at 0. child: AutososApp(),
expect(find.text('0'), findsOneWidget); ),
expect(find.text('1'), findsNothing); );
// Allow first frame
// Tap the '+' icon and trigger a frame. await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byIcon(Icons.add)); // No exception means the app rendered
await tester.pump(); expect(find.byType(MaterialApp), findsOneWidget);
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
}); });
} }