You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
649 lines
20 KiB
649 lines
20 KiB
// modules/problem/controllers/problem_controller.dart |
|
import 'dart:developer'; |
|
import 'dart:io'; |
|
|
|
import 'package:dio/dio.dart'; |
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
import 'package:get/get.dart' hide MultipartFile, FormData, Response; |
|
import 'package:flutter/material.dart'; |
|
import 'package:problem_check_system/app/routes/app_routes.dart'; |
|
import 'package:problem_check_system/core/extensions/http_response_extension.dart'; |
|
import 'package:problem_check_system/data/models/image_metadata_model.dart'; |
|
import 'package:problem_check_system/data/models/image_status.dart'; |
|
import 'package:problem_check_system/data/models/problem_sync_status.dart'; |
|
import 'package:problem_check_system/data/repositories/file_repository.dart'; |
|
import 'package:problem_check_system/data/repositories/problem_repository.dart'; |
|
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
import 'package:problem_check_system/modules/problem/views/widgets/models/date_range_enum.dart'; |
|
import 'package:problem_check_system/modules/problem/views/widgets/models/dropdown_option.dart'; |
|
|
|
class ProblemController extends GetxController |
|
with GetSingleTickerProviderStateMixin { |
|
/// 依赖问题数据 |
|
final ProblemRepository problemRepository; |
|
final FileRepository fileRepository = Get.find<FileRepository>(); |
|
|
|
/// 最近问题列表 |
|
final RxList<Problem> problems = <Problem>[].obs; |
|
|
|
/// 历史问题列表 |
|
final RxList<Problem> historyProblems = <Problem>[].obs; |
|
|
|
/// 未上传的问题列表 |
|
final RxList<Problem> unUploadedProblems = <Problem>[].obs; |
|
final Rx<bool> allSelected = false.obs; |
|
final RxDouble uploadProgress = 0.0.obs; |
|
// Dio 的取消令牌,用于取消正在进行的请求 |
|
late CancelToken _cancelToken; |
|
|
|
final RxSet<Problem> _selectedProblems = <Problem>{}.obs; |
|
|
|
Set<Problem> get selectedProblems => _selectedProblems; |
|
|
|
int get selectedCount => _selectedProblems.length; |
|
|
|
/// 选中未上传的数量 |
|
int get selectedUnUploadCount => _selectedProblems |
|
.where((p) => p.syncStatus != ProblemSyncStatus.synced) |
|
.length; |
|
|
|
// 在 ProblemController 中添加 |
|
// 添加日期范围选项列表 |
|
List<DropdownOption> get dateRangeOptions { |
|
return DateRange.values.map((range) => range.toDropdownOption()).toList(); |
|
} |
|
|
|
final List<DropdownOption> uploadOptions = const [ |
|
DropdownOption(label: '全部', value: '全部', icon: Icons.all_inclusive), |
|
DropdownOption(label: '已上传', value: '已上传', icon: Icons.cloud_done), |
|
DropdownOption(label: '未上传', value: '未上传', icon: Icons.cloud_off), |
|
]; |
|
|
|
final List<DropdownOption> bindOptions = const [ |
|
DropdownOption(label: '全部', value: '全部', icon: Icons.all_inclusive), |
|
DropdownOption(label: '已绑定', value: '已绑定', icon: Icons.link), |
|
DropdownOption(label: '未绑定', value: '未绑定', icon: Icons.link_off), |
|
]; |
|
|
|
final Rx<DateRange> currentDateRange = DateRange.oneWeek.obs; |
|
final RxString currentUploadFilter = '全部'.obs; |
|
final RxString currentBindFilter = '全部'.obs; |
|
|
|
// 历史问题列表筛选条件 |
|
final Rx<DateTime> historyStartTime = DateTime.now() |
|
.subtract(const Duration(days: 7)) |
|
.obs; |
|
final Rx<DateTime> historyEndTime = DateTime( |
|
DateTime.now().year, |
|
DateTime.now().month, |
|
DateTime.now().day, |
|
23, |
|
59, |
|
59, |
|
999, |
|
).obs; |
|
final RxString historyUploadFilter = '全部'.obs; |
|
final RxString historyBindFilter = '全部'.obs; |
|
|
|
/// 是否加载中 |
|
final RxBool isLoading = false.obs; |
|
|
|
late TabController tabController; |
|
|
|
/// floatingButton 拖动 |
|
final double _fabSize = 56.0; |
|
final double _edgePaddingX = 27.0.w; |
|
final double _edgePaddingY = 111.0.h; |
|
final fabUploadPosition = Offset(337.0, 703.7).obs; |
|
|
|
/// get 选中的 |
|
RxBool get isOnline => problemRepository.isOnline; |
|
|
|
ProblemController({required this.problemRepository}); |
|
|
|
@override |
|
void onInit() { |
|
super.onInit(); |
|
|
|
tabController = TabController(length: 2, vsync: this); |
|
tabController.addListener(_onTabChanged); |
|
loadProblems(); |
|
// 查询未上传问题 |
|
// loadUnUploadedProblems(); |
|
} |
|
|
|
@override |
|
void onClose() { |
|
tabController.dispose(); |
|
super.onClose(); |
|
} |
|
|
|
// #region 问题上传 |
|
|
|
void updateProblemSelection(Problem problem, bool isChecked) { |
|
if (isChecked) { |
|
_selectedProblems.add(problem); |
|
} else { |
|
_selectedProblems.remove(problem); |
|
} |
|
// 更新全选状态 |
|
allSelected.value = _selectedProblems.length == unUploadedProblems.length; |
|
} |
|
|
|
void selectAll() { |
|
if (allSelected.value) { |
|
// 如果已经是全选,则取消全选 |
|
_selectedProblems.clear(); |
|
} else { |
|
// 如果是取消全选,则选择所有 |
|
_selectedProblems.addAll(unUploadedProblems); |
|
} |
|
allSelected.value = !allSelected.value; |
|
} |
|
|
|
// 上传完成后清空选中状态 |
|
void clearSelection() { |
|
_selectedProblems.clear(); |
|
allSelected.value = false; |
|
} |
|
|
|
// 在 handleUpload 方法中,上传完成后调用 clearSelection |
|
Future<void> handleUpload() async { |
|
if (_selectedProblems.isEmpty) { |
|
Get.snackbar('提示', '请选择要上传的问题'); |
|
return; |
|
} |
|
|
|
uploadProgress.value = 0.0; |
|
_cancelToken = CancelToken(); |
|
|
|
showUploadProgressDialog(); |
|
|
|
try { |
|
await uploadProblems( |
|
_selectedProblems.toList(), // 转换为列表 |
|
cancelToken: _cancelToken, |
|
onProgress: (progress) { |
|
uploadProgress.value = progress; |
|
}, |
|
); |
|
|
|
Get.back(); |
|
Get.snackbar('成功', '所有问题已成功上传!', snackPosition: SnackPosition.BOTTOM); |
|
|
|
// 上传成功后清空选中状态 |
|
clearSelection(); |
|
// 重新加载未上传的问题列表 |
|
loadUnUploadedProblems(); |
|
} on DioException catch (e) { |
|
Get.back(); |
|
if (CancelToken.isCancel(e)) { |
|
Get.snackbar('提示', '上传已取消', snackPosition: SnackPosition.BOTTOM); |
|
} else { |
|
Get.snackbar( |
|
'上传失败', |
|
'错误: ${e.message}', |
|
snackPosition: SnackPosition.BOTTOM, |
|
); |
|
} |
|
} catch (e) { |
|
Get.back(); |
|
Get.snackbar('上传失败', '发生未知错误', snackPosition: SnackPosition.BOTTOM); |
|
} |
|
} |
|
|
|
/// 显示上传对话框 |
|
void showUploadProgressDialog() { |
|
// 显示对话框 |
|
Get.defaultDialog( |
|
title: '上传问题中...', |
|
content: Obx(() { |
|
// final progress = (uploadProgress.value * 100).toInt(); |
|
return Column( |
|
// mainAxisSize: MainAxisSize.min, |
|
children: [ |
|
SizedBox(height: 16.h), |
|
LinearProgressIndicator( |
|
value: uploadProgress.value, |
|
backgroundColor: Colors.grey[300], |
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue), |
|
), |
|
SizedBox(height: 16.h), |
|
// Text('已完成: $progress%'), |
|
Text('已上传: $selectedUnUploadCount / $selectedCount'), |
|
], |
|
); |
|
}), |
|
barrierDismissible: false, // 防止用户点击外部关闭对话框 |
|
// 添加一个 "取消" 按钮 |
|
cancel: ElevatedButton( |
|
onPressed: () { |
|
// 调用 controller 中的取消方法 |
|
cancelUpload(); |
|
}, |
|
child: Text('取消', style: TextStyle(color: Colors.red)), |
|
), |
|
); |
|
// 启动上传逻辑 |
|
// ... |
|
} |
|
|
|
void cancelUpload() { |
|
// 在这里实现你的取消逻辑 |
|
// 1. 停止上传任务(例如,取消 HTTP 请求) |
|
// 2. 将上传进度重置为0 |
|
uploadProgress.value = 0.0; |
|
// 3. 关闭对话框 |
|
Get.back(); |
|
// 4. 可以添加一个提示,例如: |
|
// Get.snackbar('提示', '上传已取消'); |
|
} |
|
|
|
/// 新增:上传问题列表。 |
|
/// 遍历问题列表,并计算总进度。 |
|
Future<void> uploadProblems( |
|
List<Problem> problems, { |
|
required CancelToken cancelToken, |
|
required void Function(double progress) onProgress, |
|
}) async { |
|
final int totalProblems = problems.length; |
|
// final List<Problem> updatedProblems = []; |
|
|
|
try { |
|
for (int i = 0; i < totalProblems; i++) { |
|
// 如果取消令牌被触发,停止并返回 |
|
if (cancelToken.isCancelled) { |
|
break; |
|
} |
|
|
|
final problemToUpload = problems[i]; |
|
|
|
// 传递一个子进度回调,用于计算单个问题的进度 |
|
final updatedProblem = await uploadProblem( |
|
problemToUpload, |
|
cancelToken: cancelToken, |
|
onProgress: (progress) { |
|
// 计算总体进度:(已完成的问题数 + 当前问题的进度) / 总问题数 |
|
final overallProgress = (i + progress) / totalProblems; |
|
onProgress(overallProgress); |
|
}, |
|
); |
|
|
|
problemRepository.updateProblem(updatedProblem); |
|
// updatedProblems.add(updatedProblem); |
|
} |
|
// return updatedProblems; |
|
} on DioException { |
|
rethrow; |
|
} |
|
} |
|
|
|
/// 上传单个问题及其所有关联的图片。 |
|
/// 上传单个问题及其所有关联的图片,根据操作类型执行不同逻辑 |
|
Future<Problem> uploadProblem( |
|
Problem problem, { |
|
required CancelToken cancelToken, |
|
required void Function(double progress) onProgress, |
|
}) async { |
|
try { |
|
// 检查操作类型有效性 |
|
if (problem.syncStatus == ProblemSyncStatus.synced || |
|
problem.syncStatus == ProblemSyncStatus.untracked) { |
|
throw Exception('问题已同步,无需再次同步'); |
|
} |
|
|
|
// 1. 上传图片(仅对创建和更新操作) |
|
final List<String> remoteUrls = []; |
|
if (problem.syncStatus != ProblemSyncStatus.pendingDelete) { |
|
final newImages = problem.imageUrls |
|
.where((img) => img.status == ImageStatus.local) |
|
.toList(); |
|
|
|
final totalFilesToUpload = newImages.length; |
|
int filesUploadedCount = 0; |
|
|
|
for (var image in newImages) { |
|
if (cancelToken.isCancelled) { |
|
throw DioException( |
|
requestOptions: RequestOptions(path: ''), |
|
type: DioExceptionType.cancel, |
|
error: '上传已取消', |
|
); |
|
} |
|
|
|
final url = await fileRepository.uploadImage( |
|
image.localPath, |
|
cancelToken: cancelToken, |
|
onSendProgress: (sent, total) { |
|
double overallProgress = |
|
(filesUploadedCount + (sent / total)) / totalFilesToUpload; |
|
onProgress(overallProgress); |
|
}, |
|
); |
|
remoteUrls.add(url); |
|
filesUploadedCount++; |
|
} |
|
onProgress(1.0); |
|
} |
|
|
|
// 2. 构建 API payload(删除操作不需要完整payload) |
|
final apiPayload = problem.syncStatus != ProblemSyncStatus.pendingDelete |
|
? { |
|
'id': problem.id, |
|
'title': problem.description, |
|
'location': problem.location, |
|
'imageUrls': _buildFinalRemoteUrls(problem.imageUrls, remoteUrls), |
|
'creationTime': problem.creationTime.toUtc().toIso8601String(), |
|
} |
|
: null; |
|
|
|
// 3. 根据操作类型调用不同的API |
|
late final Response response; |
|
|
|
switch (problem.syncStatus) { |
|
case ProblemSyncStatus.untracked: |
|
case ProblemSyncStatus.synced: |
|
throw Exception('无效的操作类型: none'); |
|
case ProblemSyncStatus.pendingCreate: |
|
response = await problemRepository.post(apiPayload!, cancelToken); |
|
break; |
|
case ProblemSyncStatus.pendingUpdate: |
|
response = await problemRepository.put(apiPayload!, cancelToken); |
|
break; |
|
case ProblemSyncStatus.pendingDelete: |
|
response = await problemRepository.delete(problem.id!, cancelToken); |
|
break; |
|
} |
|
|
|
// 4. 处理服务器响应 |
|
if (response.isSuccess) { |
|
// 更新图片状态(仅对创建和更新操作) |
|
final updatedImageMetadata = |
|
problem.syncStatus != ProblemSyncStatus.pendingDelete |
|
? _updateImageMetadata(problem.imageUrls, remoteUrls) |
|
: problem.imageUrls; |
|
|
|
// 返回同步完成的对象,操作类型重置为none |
|
return problem.copyWith( |
|
syncStatus: ProblemSyncStatus.synced, // 同步完成,重置为none |
|
imageUrls: updatedImageMetadata, |
|
); |
|
} else { |
|
throw Exception('操作失败,状态码: ${response.statusCode}'); |
|
} |
|
} on DioException { |
|
rethrow; |
|
} |
|
} |
|
|
|
/// 辅助方法:构建最终的远程URL列表 |
|
List<String> _buildFinalRemoteUrls( |
|
List<ImageMetadata> images, |
|
List<String> newRemoteUrls, |
|
) { |
|
final List<String> finalRemoteUrls = []; |
|
int newImageIndex = 0; |
|
|
|
for (var image in images) { |
|
if (image.status == ImageStatus.synced) { |
|
finalRemoteUrls.add(image.remoteUrl!); |
|
} else if (image.status == ImageStatus.local) { |
|
finalRemoteUrls.add(newRemoteUrls[newImageIndex]); |
|
newImageIndex++; |
|
} |
|
} |
|
|
|
return finalRemoteUrls; |
|
} |
|
|
|
/// 辅助方法:更新图片元数据状态 |
|
List<ImageMetadata> _updateImageMetadata( |
|
List<ImageMetadata> images, |
|
List<String> newRemoteUrls, |
|
) { |
|
final List<ImageMetadata> updatedImageMetadata = []; |
|
int uploadedUrlIndex = 0; |
|
|
|
for (var image in images) { |
|
if (image.status == ImageStatus.local) { |
|
updatedImageMetadata.add( |
|
ImageMetadata( |
|
localPath: image.localPath, |
|
remoteUrl: newRemoteUrls[uploadedUrlIndex], |
|
status: ImageStatus.synced, |
|
), |
|
); |
|
uploadedUrlIndex++; |
|
} else { |
|
updatedImageMetadata.add(image); |
|
} |
|
} |
|
|
|
return updatedImageMetadata; |
|
} |
|
|
|
// #endregion |
|
|
|
// #region 悬浮按钮 |
|
/// floatingButton更新位置 |
|
void updateFabUploadPosition(Offset delta) { |
|
final screenWidth = ScreenUtil().screenWidth; |
|
final screenHeight = ScreenUtil().screenHeight; |
|
|
|
Offset newPosition = fabUploadPosition.value + delta; |
|
|
|
// 限制水平范围:按钮左边缘与屏幕左边缘的距离 |
|
double clampedDx = newPosition.dx.clamp( |
|
_edgePaddingX, |
|
screenWidth - _fabSize - _edgePaddingX, |
|
); |
|
|
|
// 限制垂直范围:按钮上边缘与屏幕上边缘的距离 |
|
double clampedDy = newPosition.dy.clamp( |
|
_edgePaddingY, |
|
screenHeight - _fabSize - _edgePaddingY, |
|
); |
|
|
|
fabUploadPosition.value = Offset(clampedDx, clampedDy); |
|
} |
|
|
|
/// floatingButton 贴靠 |
|
void snapToEdge() { |
|
final screenWidth = ScreenUtil().screenWidth; |
|
|
|
// 获取当前按钮的水平中心点 |
|
final buttonCenterDx = fabUploadPosition.value.dx + _fabSize / 2; |
|
|
|
double newDx; |
|
|
|
// 判断按钮中心点位于屏幕的左半部分还是右半部分 |
|
if (buttonCenterDx < screenWidth / 2) { |
|
// 贴靠到左侧,按钮左边缘与屏幕左边缘距离为 _edgePaddingX |
|
newDx = _edgePaddingX; |
|
} else { |
|
// 贴靠到右侧,按钮右边缘与屏幕右边缘距离为 _edgePaddingX |
|
newDx = screenWidth - _fabSize - _edgePaddingX; |
|
} |
|
|
|
// 关键:只更新水平位置,垂直位置保持不变 |
|
fabUploadPosition.value = Offset(newDx, fabUploadPosition.value.dy); |
|
} |
|
|
|
// #endregion |
|
|
|
// #region ta按钮 |
|
void _onTabChanged() { |
|
if (!tabController.indexIsChanging) { |
|
loadProblems(); |
|
} |
|
} |
|
|
|
// #endregion |
|
// 添加筛选方法 |
|
// 更新日期范围的方法 |
|
void updateCurrentDateRange(String rangeValue) { |
|
final newRange = rangeValue.toDateRange(); |
|
if (newRange != null) { |
|
currentDateRange.value = newRange; |
|
loadProblems(); // 重新加载数据 |
|
} |
|
} |
|
|
|
void updateCurrentUpload(String value) { |
|
currentUploadFilter.value = value; |
|
loadProblems(); // 重新加载数据 |
|
} |
|
|
|
void updateCurrentBind(String value) { |
|
currentBindFilter.value = value; |
|
loadProblems(); // 重新加载数据 |
|
} |
|
|
|
// 添加筛选方法 |
|
/// 显示日期选择器 |
|
Future<void> selectDateRange(BuildContext context) async { |
|
final initialDateRange = DateTimeRange( |
|
start: historyStartTime.value, |
|
end: historyEndTime.value, |
|
); |
|
|
|
final DateTimeRange? picked = await showDateRangePicker( |
|
context: context, |
|
firstDate: DateTime(2025, 8, 1), // 可选的最早日期 |
|
lastDate: DateTime(2101), // 可选的最晚日期 |
|
initialDateRange: initialDateRange, |
|
); |
|
|
|
if (picked != null) { |
|
// 处理用户选择的日期范围 |
|
historyStartTime.value = picked.start; |
|
historyEndTime.value = DateTime( |
|
picked.end.year, |
|
picked.end.month, |
|
picked.end.day, |
|
23, |
|
59, |
|
59, |
|
999, |
|
); |
|
loadProblems(); |
|
log('选择的日期范围是: ${picked.start} 到 ${picked.end}'); |
|
} |
|
} |
|
|
|
void updateHistoryUpload(String value) { |
|
historyUploadFilter.value = value; |
|
loadProblems(); // 重新加载数据 |
|
} |
|
|
|
void updateHistoryBind(String value) { |
|
historyBindFilter.value = value; |
|
loadProblems(); // 重新加载数据 |
|
} |
|
|
|
/// 加载 |
|
Future<void> loadProblems() async { |
|
isLoading.value = true; |
|
try { |
|
// 根据 Tab 索引设置查询参数的默认值 |
|
final bool isProblemListTab = tabController.index == 0; |
|
|
|
final DateTime startDate = isProblemListTab |
|
? currentDateRange.value.startDate |
|
: historyStartTime.value; |
|
|
|
final DateTime endDate = isProblemListTab |
|
? currentDateRange.value.endDate |
|
: historyEndTime.value; |
|
|
|
final String uploadStatus = isProblemListTab |
|
? currentUploadFilter.value |
|
: historyUploadFilter.value; |
|
|
|
final String bindStatus = isProblemListTab |
|
? currentBindFilter.value |
|
: historyBindFilter.value; |
|
|
|
// 只执行一次数据库查询 |
|
final loadedProblems = await problemRepository.getProblems( |
|
startDate: startDate, |
|
endDate: endDate, |
|
syncStatus: uploadStatus, |
|
bindStatus: bindStatus, |
|
); |
|
|
|
// 根据 Tab 索引将数据分配给正确的列表 |
|
if (isProblemListTab) { |
|
problems.assignAll(loadedProblems); |
|
} else { |
|
historyProblems.assignAll(loadedProblems); |
|
} |
|
} catch (e) { |
|
Get.snackbar('错误', '加载问题失败: $e'); |
|
} finally { |
|
isLoading.value = false; |
|
} |
|
} |
|
|
|
/// 显示上传页面 |
|
void showUploadPage() { |
|
Get.toNamed(AppRoutes.problemUpload); |
|
clearSelection(); |
|
loadUnUploadedProblems(); |
|
} |
|
|
|
// 查询所有未上传的问题 |
|
Future<void> loadUnUploadedProblems() async { |
|
isLoading.value = true; |
|
try { |
|
// 调用 _localDatabase.getProblems 并只筛选 '未上传' 的问题 |
|
unUploadedProblems.value = await problemRepository.getProblems( |
|
syncStatus: '未上传', |
|
); |
|
} catch (e) { |
|
Get.snackbar('错误', '加载未上传问题失败: $e'); |
|
} finally { |
|
isLoading.value = false; |
|
} |
|
} |
|
|
|
/// 删除问题 |
|
/// 控制器中可以添加逻辑 |
|
Future<void> deleteProblem(Problem problem) async { |
|
try { |
|
final deleteProblem = ProblemStateManager.markForDeletion(problem); |
|
if (deleteProblem.syncStatus == ProblemSyncStatus.untracked) { |
|
// 直接删除问题和图片 |
|
await problemRepository.deleteProblem(problem.id!); |
|
await _deleteProblemImages(problem); |
|
} else { |
|
// 更新状态 |
|
await problemRepository.updateProblem(deleteProblem); |
|
} |
|
|
|
loadProblems(); |
|
} catch (e) { |
|
Get.snackbar('错误', '删除问题失败: $e'); |
|
rethrow; |
|
} |
|
} |
|
|
|
// 删除本地文件 |
|
Future<void> _deleteProblemImages(Problem problem) async { |
|
for (var imagePath in problem.imageUrls) { |
|
try { |
|
final file = File(imagePath.localPath); |
|
if (await file.exists()) { |
|
await file.delete(); |
|
} |
|
} catch (e) { |
|
throw Exception(e); |
|
} |
|
} |
|
} |
|
|
|
Future<void> toProblemFormPageAndRefresh() async { |
|
await Get.toNamed(AppRoutes.problemForm); |
|
loadProblems(); |
|
} |
|
}
|
|
|