12 changed files with 568 additions and 25 deletions
@ -1,39 +1,78 @@ |
|||||||
# problem_check_system |
# 项目名称:现场问题检查系统 |
||||||
|
|
||||||
系统架构为MVVM + 仓库模式 |
## 核心功能 |
||||||
|
|
||||||
这个应用需要实现以下功能: |
本系统旨在为现场工作人员提供一个高效的问题记录与管理平台,支持离线操作和数据同步。 |
||||||
|
|
||||||
离线登录系统 |
1.1 在线/离线登录 |
||||||
|
需求: 用户能够使用用户名和密码进行登录。如果设备曾成功登录过,即使在离线状态下也能进入应用。 |
||||||
|
|
||||||
问题数据收集(描述、位置、图片等) |
技术实现思路: |
||||||
|
|
||||||
本地数据存储 |
在线: 登录成功后,从服务器获取用户身份信息和Token。 |
||||||
|
|
||||||
有网络时手动上传功能 |
离线: 将登录凭证(如Token)安全地存储在本地数据库中,启动时优先检查本地凭证。如果凭证有效,直接进入主界面。 |
||||||
|
|
||||||
技术栈 |
1.2 问题数据离线增删改查 |
||||||
Flutter SDK |
需求: 用户在离线状态下能完整地创建、编辑、删除和查询问题数据。 |
||||||
|
|
||||||
GetX (状态管理、路由管理、依赖注入) |
技术实现思路: |
||||||
|
|
||||||
SQFlite (本地数据库) |
本地数据库: 使用 SQFlite 作为本地数据库。 |
||||||
|
|
||||||
Image Picker (图片选择) |
数据结构: 设计清晰的数据库表结构,包含问题描述、图片路径、创建时间、状态(如“待上传”)等字段。 |
||||||
|
|
||||||
Geolocator (位置信息) |
操作: 所有的增删改查操作都直接在本地数据库上进行。 |
||||||
|
|
||||||
HTTP/Dio (网络请求) |
1.3 问题数据上传/下载 |
||||||
|
需求: 在有网络连接时,系统能自动或手动将本地数据同步到服务器,并从服务器下载最新数据。 |
||||||
|
|
||||||
# !/bin/bash |
技术实现思路: |
||||||
|
|
||||||
echo "开始清理..." |
上传队列: 在本地数据库中新增一个“待上传”队列。当用户离线进行增删改操作时,将这些操作记录在队列中。 |
||||||
flutter clean |
|
||||||
|
|
||||||
echo "获取依赖..." |
智能同步: 当网络恢复时,按顺序处理上传队列中的任务。使用统一的 HTTP/Dio 拦截器处理身份验证和错误重试。 |
||||||
flutter pub get |
|
||||||
|
|
||||||
echo "构建APK..." |
差异化下载: 从服务器下载数据时,只拉取自上次同步以来有更新的数据,避免全量下载。 |
||||||
flutter build apk --release --target-platform android-arm64 |
|
||||||
|
|
||||||
echo "构建完成!APK位置:build/app/outputs/flutter-apk/app-release.apk" |
2. 系统架构:MVVM + 仓库模式 |
||||||
|
此架构能有效分离关注点,提高代码的可测试性和可维护性。 |
||||||
|
|
||||||
|
模型 (Model): 负责数据管理。定义问题数据的实体类,如 Problem。 |
||||||
|
|
||||||
|
视图模型 (ViewModel): 作为视图和模型之间的桥梁。 |
||||||
|
|
||||||
|
负责业务逻辑、状态管理和数据处理。 |
||||||
|
|
||||||
|
通过 GetX 管理 UI 状态,并与视图进行绑定。 |
||||||
|
|
||||||
|
使用 GetX 的依赖注入(DI)管理 Repository 实例。 |
||||||
|
|
||||||
|
视图 (View): 负责 UI 展示。 |
||||||
|
|
||||||
|
使用 Flutter Widgets 构建界面。 |
||||||
|
|
||||||
|
通过 GetX 提供的响应式组件(如 Obx),监听 ViewModel 中的状态变化并自动更新 UI。 |
||||||
|
|
||||||
|
仓库 (Repository): 负责统一数据源(本地数据库和网络)。 |
||||||
|
|
||||||
|
封装数据访问逻辑,向 ViewModel 提供统一的数据接口。 |
||||||
|
|
||||||
|
根据网络状态,决定是从 SQFlite 获取数据还是从网络请求。 |
||||||
|
|
||||||
|
3. 技术栈 |
||||||
|
Flutter SDK: 跨平台应用开发框架。 |
||||||
|
|
||||||
|
GetX: |
||||||
|
|
||||||
|
状态管理: 轻量高效,用于管理 UI 状态。 |
||||||
|
|
||||||
|
路由管理: 简化页面跳转。 |
||||||
|
|
||||||
|
依赖注入: 方便管理和提供单例服务(如 Repository)。 |
||||||
|
|
||||||
|
SQFlite: 本地数据库,用于离线数据存储。 |
||||||
|
|
||||||
|
Image Picker: 用于从图库或相机选择图片。 |
||||||
|
|
||||||
|
HTTP/Dio: 网络请求库。推荐使用 Dio,因为它提供了更强大的拦截器、表单数据处理和错误处理功能。 |
||||||
|
@ -0,0 +1,6 @@ |
|||||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||||
|
<paths> |
||||||
|
<external-path name="external" path="." /> |
||||||
|
<external-files-path name="external_files" path="." /> |
||||||
|
<cache-path name="cache" path="." /> |
||||||
|
</paths> |
@ -0,0 +1,213 @@ |
|||||||
|
import 'dart:io'; |
||||||
|
import 'package:dio/dio.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:get/get.dart'; |
||||||
|
import 'package:path_provider/path_provider.dart'; |
||||||
|
import 'package:open_file/open_file.dart'; |
||||||
|
import 'package:package_info_plus/package_info_plus.dart'; |
||||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||||
|
|
||||||
|
class UpgraderService extends GetxService { |
||||||
|
final Dio _dio = Dio(); |
||||||
|
final String _versionUrl = |
||||||
|
'http://xhota.anxincloud.cn/problem/version.json'; // 您的版本信息 JSON 地址 |
||||||
|
|
||||||
|
var isUpdateAvailable = false.obs; |
||||||
|
var updateInfo = {}.obs; |
||||||
|
var downloadProgress = '0'.obs; |
||||||
|
var totalSize = '0'.obs; |
||||||
|
var isDownloading = false.obs; |
||||||
|
|
||||||
|
@override |
||||||
|
void onInit() { |
||||||
|
super.onInit(); |
||||||
|
checkForUpdate(); |
||||||
|
} |
||||||
|
|
||||||
|
// 检查更新 |
||||||
|
Future<void> checkForUpdate() async { |
||||||
|
try { |
||||||
|
// 获取当前应用版本信息 |
||||||
|
PackageInfo packageInfo = await PackageInfo.fromPlatform(); |
||||||
|
String currentVersion = packageInfo.version; |
||||||
|
|
||||||
|
final response = await _dio.get(_versionUrl); |
||||||
|
if (response.statusCode == 200) { |
||||||
|
final latestVersion = response.data['version']; |
||||||
|
if (latestVersion.compareTo(currentVersion) > 0) { |
||||||
|
isUpdateAvailable.value = true; |
||||||
|
updateInfo.value = response.data; |
||||||
|
showUpdateDialog(); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
Get.log('检查更新失败: $e'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 显示更新提示对话框 |
||||||
|
void showUpdateDialog() { |
||||||
|
Get.dialog( |
||||||
|
AlertDialog( |
||||||
|
title: Text('发现新版本'), |
||||||
|
content: Text(updateInfo['description'] ?? '有新的更新可用。'), |
||||||
|
actions: [ |
||||||
|
TextButton(onPressed: () => Get.back(), child: Text('稍后')), |
||||||
|
TextButton( |
||||||
|
onPressed: () { |
||||||
|
Get.back(); |
||||||
|
startDownload(); |
||||||
|
}, |
||||||
|
child: Text('立即更新'), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
barrierDismissible: false, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// 开始下载 |
||||||
|
Future<void> startDownload() async { |
||||||
|
isDownloading.value = true; |
||||||
|
showDownloadProgressDialog(); |
||||||
|
|
||||||
|
try { |
||||||
|
Directory? dir = await getExternalStorageDirectory(); |
||||||
|
|
||||||
|
if (dir == null) { |
||||||
|
Get.back(); // 关闭进度对话框 |
||||||
|
Get.snackbar('错误', '无法获取外部存储目录。'); |
||||||
|
print('错误: getExternalStorageDirectory() 返回 null'); |
||||||
|
isDownloading.value = false; |
||||||
|
return; // 提前退出 |
||||||
|
} |
||||||
|
|
||||||
|
String savePath = '${dir.path}/app-release.apk'; |
||||||
|
print('--- 调试信息 ---'); |
||||||
|
print('目标下载路径: $savePath'); |
||||||
|
print('--- 调试信息 ---'); |
||||||
|
|
||||||
|
// 确保父目录存在 |
||||||
|
final file = File(savePath); |
||||||
|
if (!await file.parent.exists()) { |
||||||
|
await file.parent.create(recursive: true); |
||||||
|
print('--- 调试信息 ---'); |
||||||
|
print('已尝试创建父目录: ${file.parent.path}'); |
||||||
|
print('--- 调试信息 ---'); |
||||||
|
} |
||||||
|
|
||||||
|
await _dio.download( |
||||||
|
updateInfo['url'], |
||||||
|
savePath, |
||||||
|
onReceiveProgress: (received, total) { |
||||||
|
if (total != -1) { |
||||||
|
downloadProgress.value = (received / 1024 / 1024).toStringAsFixed( |
||||||
|
2, |
||||||
|
); |
||||||
|
totalSize.value = (total / 1024 / 1024).toStringAsFixed(2); |
||||||
|
// 在进度回调中也打印路径,确保一直有效 |
||||||
|
// print('下载进度 - 目标路径: $savePath'); |
||||||
|
} |
||||||
|
}, |
||||||
|
options: Options( |
||||||
|
receiveTimeout: const Duration(seconds: 30), // 增加超时时间 |
||||||
|
sendTimeout: const Duration(seconds: 30), |
||||||
|
), |
||||||
|
); |
||||||
|
|
||||||
|
isDownloading.value = false; |
||||||
|
Get.back(); // 关闭下载进度对话框 |
||||||
|
|
||||||
|
// 再次验证文件是否存在 |
||||||
|
if (await file.exists()) { |
||||||
|
final fileSize = await file.length(); |
||||||
|
Get.log('文件验证成功!路径: $savePath'); |
||||||
|
Get.log('文件大小: ${(fileSize / 1024 / 1024).toStringAsFixed(2)} MB'); |
||||||
|
installApk(savePath); |
||||||
|
} else { |
||||||
|
Get.snackbar('下载失败', '文件下载完毕但未找到,请联系管理员。'); |
||||||
|
Get.log('错误:文件下载完毕但未找到!'); |
||||||
|
} |
||||||
|
} on DioException catch (e) { |
||||||
|
// 使用 DioException 捕获更具体的 Dio 错误 |
||||||
|
isDownloading.value = false; |
||||||
|
Get.back(); |
||||||
|
String errorMessage = '下载失败: 网络或服务器问题。'; |
||||||
|
if (e.type == DioExceptionType.badResponse) { |
||||||
|
errorMessage = '下载失败: 服务器响应错误 (${e.response?.statusCode})'; |
||||||
|
Get.log('Dio响应错误: ${e.response?.data}'); |
||||||
|
} else if (e.type == DioExceptionType.connectionError) { |
||||||
|
errorMessage = '下载失败: 连接错误,请检查网络。'; |
||||||
|
} else if (e.type == DioExceptionType.unknown) { |
||||||
|
// 可能是文件系统错误、权限错误等 |
||||||
|
errorMessage = '下载失败: 未知错误。请检查存储权限或设备空间。'; |
||||||
|
Get.log('Dio未知错误: $e'); |
||||||
|
} |
||||||
|
Get.snackbar('下载失败', errorMessage); |
||||||
|
Get.log('下载失败 (DioException): $e'); |
||||||
|
} catch (e) { |
||||||
|
isDownloading.value = false; |
||||||
|
Get.back(); |
||||||
|
Get.snackbar('下载失败', '发生未知错误。'); |
||||||
|
Get.log('下载失败 (通用异常): $e'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 显示下载进度对话框 |
||||||
|
void showDownloadProgressDialog() { |
||||||
|
Get.dialog( |
||||||
|
AlertDialog( |
||||||
|
title: Text('正在下载更新...'), |
||||||
|
content: Obx( |
||||||
|
() => Column( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
LinearProgressIndicator( |
||||||
|
value: totalSize.value == '0' |
||||||
|
? null |
||||||
|
: double.parse(downloadProgress.value) / |
||||||
|
double.parse(totalSize.value), |
||||||
|
), |
||||||
|
SizedBox(height: 16), |
||||||
|
Text('${downloadProgress.value} MB / ${totalSize.value} MB'), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
barrierDismissible: false, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// 安装 APK |
||||||
|
Future<void> installApk(String path) async { |
||||||
|
// 检查安装权限 |
||||||
|
var status = await Permission.requestInstallPackages.status; |
||||||
|
if (status.isGranted) { |
||||||
|
// 权限已授予,直接安装 |
||||||
|
_openApkFile(path); |
||||||
|
} else { |
||||||
|
// 权限未授予,发起请求 |
||||||
|
// 这会打开系统设置页面,让用户手动授权 |
||||||
|
status = await Permission.requestInstallPackages.request(); |
||||||
|
if (status.isGranted) { |
||||||
|
_openApkFile(path); |
||||||
|
} else { |
||||||
|
// 如果用户拒绝了权限,给出提示 |
||||||
|
Get.snackbar( |
||||||
|
'权限被拒绝', |
||||||
|
'需要授权才能安装更新。请在系统设置中为本应用开启“安装未知应用”的权限。', |
||||||
|
duration: Duration(seconds: 5), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 抽离出打开文件的逻辑 |
||||||
|
Future<void> _openApkFile(String path) async { |
||||||
|
final result = await OpenFile.open(path); |
||||||
|
print('OpenFile result: ${result.type}, message: ${result.message}'); |
||||||
|
if (result.type != ResultType.done) { |
||||||
|
Get.snackbar('安装失败', '无法启动安装程序: ${result.message}'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue