Compare commits
No commits in common. 'dev' and 'master' have entirely different histories.
70 changed files with 663 additions and 5734 deletions
@ -1,3 +1,3 @@ |
|||||||
{ |
{ |
||||||
"cSpell.words": ["fenix", "Getx", "tdesign"] |
"cSpell.words": ["Getx", "tdesign"] |
||||||
} |
} |
||||||
|
@ -1,26 +1,3 @@ |
|||||||
# problem_check_system |
# problem_check_system |
||||||
|
|
||||||
系统架构为MVVM + 仓库模式 |
A new Flutter project. |
||||||
|
|
||||||
这个应用需要实现以下功能: |
|
||||||
|
|
||||||
离线登录系统 |
|
||||||
|
|
||||||
问题数据收集(描述、位置、图片等) |
|
||||||
|
|
||||||
本地数据存储 |
|
||||||
|
|
||||||
有网络时手动上传功能 |
|
||||||
|
|
||||||
技术栈 |
|
||||||
Flutter SDK |
|
||||||
|
|
||||||
GetX (状态管理、路由管理、依赖注入) |
|
||||||
|
|
||||||
SQFlite (本地数据库) |
|
||||||
|
|
||||||
Image Picker (图片选择) |
|
||||||
|
|
||||||
Geolocator (位置信息) |
|
||||||
|
|
||||||
HTTP/Dio (网络请求) |
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 90 KiB |
@ -1,3 +0,0 @@ |
|||||||
description: This file stores settings for Dart & Flutter DevTools. |
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states |
|
||||||
extensions: |
|
@ -1,49 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:get_storage/get_storage.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/connectivity_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/http_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/sqlite_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/file_repository.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/image_repository.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/image_repository_impl.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/problem_repository.dart'; |
|
||||||
|
|
||||||
class InitialBinding implements Bindings { |
|
||||||
@override |
|
||||||
void dependencies() { |
|
||||||
_registerCoreServices(); |
|
||||||
_registerRepositories(); |
|
||||||
} |
|
||||||
|
|
||||||
void _registerCoreServices() { |
|
||||||
/// 立即注册所有的适配器 |
|
||||||
Get.put<GetStorage>(GetStorage(), permanent: true); |
|
||||||
Get.put<HttpProvider>(HttpProvider()); |
|
||||||
Get.put<SQLiteProvider>(SQLiteProvider()); |
|
||||||
Get.put<ConnectivityProvider>(ConnectivityProvider()); |
|
||||||
} |
|
||||||
|
|
||||||
void _registerRepositories() { |
|
||||||
Get.lazyPut<FileRepository>(() => FileRepository()); |
|
||||||
Get.lazyPut<ImageRepository>( |
|
||||||
() => ImageRepositoryImpl(httpProvider: Get.find<HttpProvider>()), |
|
||||||
); |
|
||||||
|
|
||||||
/// 懒加载注册所有的仓库 |
|
||||||
Get.lazyPut<AuthRepository>( |
|
||||||
() => AuthRepository( |
|
||||||
httpProvider: Get.find<HttpProvider>(), |
|
||||||
storage: Get.find<GetStorage>(), |
|
||||||
connectivityProvider: Get.find<ConnectivityProvider>(), |
|
||||||
), |
|
||||||
); |
|
||||||
Get.lazyPut<ProblemRepository>( |
|
||||||
() => ProblemRepository( |
|
||||||
sqliteProvider: Get.find<SQLiteProvider>(), |
|
||||||
httpProvider: Get.find<HttpProvider>(), |
|
||||||
connectivityProvider: Get.find<ConnectivityProvider>(), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,43 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/home/bindings/home_binding.dart'; |
|
||||||
import 'package:problem_check_system/modules/home/views/home_page.dart'; |
|
||||||
import 'package:problem_check_system/modules/auth/bindings/login_binding.dart'; |
|
||||||
import 'package:problem_check_system/modules/auth/views/login_page.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/bindings/change_password_binding.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/views/change_password.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/bindings/problem_form_binding.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/problem_form_page.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/problem_upload_page.dart'; |
|
||||||
|
|
||||||
import 'app_routes.dart'; |
|
||||||
|
|
||||||
abstract class AppPages { |
|
||||||
// 所有路由的 GetPage 列表 |
|
||||||
static final routes = <GetPage>[ |
|
||||||
GetPage( |
|
||||||
name: AppRoutes.home, |
|
||||||
page: () => const HomePage(), |
|
||||||
binding: HomeBinding(), |
|
||||||
), |
|
||||||
// 登录页 |
|
||||||
GetPage( |
|
||||||
name: AppRoutes.login, |
|
||||||
page: () => const LoginPage(), |
|
||||||
binding: LoginBinding(), |
|
||||||
), |
|
||||||
GetPage( |
|
||||||
name: AppRoutes.changePassword, |
|
||||||
page: () => const ChangePasswordPage(), |
|
||||||
binding: ChangePasswordBinding(), |
|
||||||
), |
|
||||||
GetPage( |
|
||||||
name: AppRoutes.problemUpload, |
|
||||||
page: () => const ProblemUploadPage(), |
|
||||||
), |
|
||||||
GetPage( |
|
||||||
name: AppRoutes.problemForm, |
|
||||||
page: () => const ProblemFormPage(), |
|
||||||
binding: ProblemFormBinding(), |
|
||||||
), |
|
||||||
]; |
|
||||||
} |
|
@ -1,14 +0,0 @@ |
|||||||
abstract class AppRoutes { |
|
||||||
// 命名路由,使用 const 常量 |
|
||||||
static const home = '/home'; |
|
||||||
static const login = '/login'; |
|
||||||
|
|
||||||
static const my = '/my'; |
|
||||||
static const changePassword = '/changePassword'; |
|
||||||
|
|
||||||
// #region |
|
||||||
static const problem = '/problem'; |
|
||||||
static const problemUpload = '/problemUpload'; |
|
||||||
static const problemForm = '/problemForm'; |
|
||||||
// #endregion |
|
||||||
} |
|
@ -1,16 +0,0 @@ |
|||||||
// core/extensions/http_response_extension.dart |
|
||||||
import 'package:dio/dio.dart'; |
|
||||||
|
|
||||||
extension HttpResponseExtension on Response { |
|
||||||
bool get isSuccess { |
|
||||||
return statusCode != null && statusCode! >= 200 && statusCode! < 300; |
|
||||||
} |
|
||||||
|
|
||||||
bool get isClientError { |
|
||||||
return statusCode != null && statusCode! >= 400 && statusCode! < 500; |
|
||||||
} |
|
||||||
|
|
||||||
bool get isServerError { |
|
||||||
return statusCode != null && statusCode! >= 500 && statusCode! < 600; |
|
||||||
} |
|
||||||
} |
|
@ -1,19 +0,0 @@ |
|||||||
// lib/data/api_endpoints.dart |
|
||||||
abstract class ApiEndpoints { |
|
||||||
static const String baseUrl = 'https://xhdev.anxincloud.cn'; |
|
||||||
|
|
||||||
// 定义 Accounts 相关的端点 |
|
||||||
static const String postLogin = '/api/Accounts/SignIn'; |
|
||||||
static const String postRefreshToken = '/api/Accounts/RefreshToken'; |
|
||||||
static const String getUserProfile = '/api/Accounts/Profile'; |
|
||||||
static const String patchPassword = '/api/Accounts/ChangePassword'; |
|
||||||
|
|
||||||
// 定义 Memorandum 相关的端点 |
|
||||||
static const String getProblems = '/api/Memorandum'; |
|
||||||
static const String postProblem = '/api/Memorandum'; |
|
||||||
static String putProblemById(String id) => '/api/Memorandum/$id'; |
|
||||||
static String deleteProblemById(String id) => '/api/Memorandum/$id'; |
|
||||||
|
|
||||||
// 文件上传相关 |
|
||||||
static const String postUploadFile = '/api/Objects/association'; |
|
||||||
} |
|
@ -1,53 +0,0 @@ |
|||||||
/// 登录请求模型 |
|
||||||
class LoginRequest { |
|
||||||
final String username; |
|
||||||
final String password; |
|
||||||
final String wechatJsCode; |
|
||||||
|
|
||||||
LoginRequest({ |
|
||||||
required this.username, |
|
||||||
required this.password, |
|
||||||
this.wechatJsCode = "", |
|
||||||
}); |
|
||||||
|
|
||||||
Map<String, dynamic> toJson() { |
|
||||||
return { |
|
||||||
'username': username, |
|
||||||
'password': password, |
|
||||||
'wechatJsCode': wechatJsCode, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
// 从 Map 创建 LoginRequest 对象 |
|
||||||
factory LoginRequest.fromJson(Map<String, dynamic> json) { |
|
||||||
return LoginRequest( |
|
||||||
username: json['username'] as String, |
|
||||||
password: json['password'] as String, |
|
||||||
wechatJsCode: json['wechatJsCode'] as String, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 登录响应模型 |
|
||||||
class LoginResponse { |
|
||||||
final String token; |
|
||||||
final String refreshToken; |
|
||||||
final int expires; |
|
||||||
final String name; |
|
||||||
|
|
||||||
LoginResponse({ |
|
||||||
required this.token, |
|
||||||
required this.refreshToken, |
|
||||||
required this.expires, |
|
||||||
required this.name, |
|
||||||
}); |
|
||||||
|
|
||||||
factory LoginResponse.fromJson(Map<String, dynamic> json) { |
|
||||||
return LoginResponse( |
|
||||||
token: json['token'] ?? '', |
|
||||||
refreshToken: json['refresh_token'] ?? '', |
|
||||||
expires: json['expires'] ?? '', |
|
||||||
name: json['name'] ?? '', |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,47 +0,0 @@ |
|||||||
// image_metadata_model.dart |
|
||||||
import 'package:problem_check_system/data/models/image_status.dart'; |
|
||||||
|
|
||||||
class ImageMetadata { |
|
||||||
final String localPath; |
|
||||||
final String? remoteUrl; |
|
||||||
final ImageStatus status; |
|
||||||
|
|
||||||
ImageMetadata({ |
|
||||||
required this.localPath, |
|
||||||
this.remoteUrl, |
|
||||||
required this.status, |
|
||||||
}); |
|
||||||
|
|
||||||
// For saving to SQL |
|
||||||
Map<String, dynamic> toMap() { |
|
||||||
return { |
|
||||||
'localPath': localPath, |
|
||||||
'remoteUrl': remoteUrl, |
|
||||||
'status': status.index, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
// For reading from SQL |
|
||||||
factory ImageMetadata.fromMap(Map<String, dynamic> map) { |
|
||||||
return ImageMetadata( |
|
||||||
localPath: map['localPath'] as String, |
|
||||||
remoteUrl: map['remoteUrl'] as String?, |
|
||||||
status: ImageStatus.values[map['status'] as int], |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// Creates a new [ImageMetadata] instance with optional new values. |
|
||||||
/// |
|
||||||
/// The original object remains unchanged. |
|
||||||
ImageMetadata copyWith({ |
|
||||||
String? localPath, |
|
||||||
String? remoteUrl, |
|
||||||
ImageStatus? status, |
|
||||||
}) { |
|
||||||
return ImageMetadata( |
|
||||||
localPath: localPath ?? this.localPath, |
|
||||||
remoteUrl: remoteUrl ?? this.remoteUrl, |
|
||||||
status: status ?? this.status, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,14 +0,0 @@ |
|||||||
/// 图片的同步状态 |
|
||||||
enum ImageStatus { |
|
||||||
/// 已同步 |
|
||||||
synced, |
|
||||||
|
|
||||||
// 待上传 |
|
||||||
pendingUpload, |
|
||||||
|
|
||||||
/// 待删除 |
|
||||||
pendingDeleted, |
|
||||||
|
|
||||||
/// 待下载 |
|
||||||
pendingDownload, |
|
||||||
} |
|
@ -1,132 +0,0 @@ |
|||||||
import 'dart:convert'; |
|
||||||
|
|
||||||
import 'package:problem_check_system/data/models/image_metadata_model.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_sync_status.dart'; |
|
||||||
|
|
||||||
/// 问题的数据模型。 |
|
||||||
/// 用于表示系统中的一个具体问题,包含了问题的描述、位置、图片等信息。 |
|
||||||
class Problem { |
|
||||||
/// 问题的唯一标识符,可空。 |
|
||||||
final String id; |
|
||||||
|
|
||||||
/// 问题的详细描述。 |
|
||||||
final String description; |
|
||||||
|
|
||||||
/// 问题发生的位置。 |
|
||||||
final String location; |
|
||||||
|
|
||||||
/// 问题的图片元数据列表。 |
|
||||||
final List<ImageMetadata> imageUrls; |
|
||||||
|
|
||||||
/// 问题创建的时间。 |
|
||||||
final DateTime creationTime; |
|
||||||
|
|
||||||
/// 问题的同步状态,默认为未同步。 |
|
||||||
final ProblemSyncStatus syncStatus; |
|
||||||
|
|
||||||
/// 最后修改时间 |
|
||||||
final DateTime lastModifiedTime; |
|
||||||
|
|
||||||
/// 相关的审查任务ID,可空。 |
|
||||||
final String? censorTaskId; |
|
||||||
|
|
||||||
/// 绑定的附加数据,可空。 |
|
||||||
final String? bindData; |
|
||||||
|
|
||||||
/// 问题是否已被检查,默认为false。 |
|
||||||
final bool isChecked; |
|
||||||
|
|
||||||
Problem({ |
|
||||||
required this.id, |
|
||||||
required this.description, |
|
||||||
required this.location, |
|
||||||
required this.imageUrls, |
|
||||||
required this.creationTime, |
|
||||||
required this.lastModifiedTime, |
|
||||||
this.syncStatus = ProblemSyncStatus.pendingCreate, |
|
||||||
this.censorTaskId, |
|
||||||
this.bindData, |
|
||||||
this.isChecked = false, |
|
||||||
}); |
|
||||||
|
|
||||||
/// copyWith 方法,用于创建对象的副本并修改指定字段 |
|
||||||
Problem copyWith({ |
|
||||||
String? id, |
|
||||||
String? description, |
|
||||||
String? location, |
|
||||||
List<ImageMetadata>? imageUrls, |
|
||||||
DateTime? creationTime, |
|
||||||
DateTime? lastModifiedTime, |
|
||||||
ProblemSyncStatus? syncStatus, |
|
||||||
bool? isDeleted, |
|
||||||
String? censorTaskId, |
|
||||||
String? bindData, |
|
||||||
bool? isChecked, |
|
||||||
}) { |
|
||||||
return Problem( |
|
||||||
id: id ?? this.id, |
|
||||||
description: description ?? this.description, |
|
||||||
location: location ?? this.location, |
|
||||||
imageUrls: imageUrls ?? this.imageUrls, |
|
||||||
creationTime: creationTime ?? this.creationTime, |
|
||||||
lastModifiedTime: lastModifiedTime ?? this.lastModifiedTime, |
|
||||||
syncStatus: syncStatus ?? this.syncStatus, |
|
||||||
censorTaskId: censorTaskId ?? this.censorTaskId, |
|
||||||
bindData: bindData ?? this.bindData, |
|
||||||
isChecked: isChecked ?? this.isChecked, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 将对象转换为Map,用于SQLite存储 |
|
||||||
Map<String, dynamic> toMap() { |
|
||||||
return { |
|
||||||
'id': id, |
|
||||||
'description': description, |
|
||||||
'location': location, |
|
||||||
'imageUrls': json.encode(imageUrls.map((e) => e.toMap()).toList()), |
|
||||||
'creationTime': creationTime.millisecondsSinceEpoch, |
|
||||||
'lastModifiedTime': lastModifiedTime.millisecondsSinceEpoch, |
|
||||||
'syncStatus': syncStatus.index, |
|
||||||
'censorTaskId': censorTaskId, |
|
||||||
'bindData': bindData, |
|
||||||
'isChecked': isChecked ? 1 : 0, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
/// 从Map创建对象,用于从SQLite读取 |
|
||||||
factory Problem.fromMap(Map<String, dynamic> map) { |
|
||||||
// 处理imageUrls的转换 |
|
||||||
List<ImageMetadata> imageUrlsList = []; |
|
||||||
if (map['imageUrls'] != null) { |
|
||||||
try { |
|
||||||
final List<dynamic> imageList = json.decode(map['imageUrls']); |
|
||||||
imageUrlsList = imageList.map((e) => ImageMetadata.fromMap(e)).toList(); |
|
||||||
} catch (e) { |
|
||||||
// 如果解析失败,保持空列表 |
|
||||||
imageUrlsList = []; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return Problem( |
|
||||||
id: map['id'], |
|
||||||
description: map['description'], |
|
||||||
location: map['location'], |
|
||||||
imageUrls: imageUrlsList, |
|
||||||
creationTime: DateTime.fromMillisecondsSinceEpoch(map['creationTime']), |
|
||||||
lastModifiedTime: DateTime.fromMillisecondsSinceEpoch( |
|
||||||
map['lastModifiedTime'], |
|
||||||
), |
|
||||||
syncStatus: ProblemSyncStatus.values[map['syncStatus']], |
|
||||||
censorTaskId: map['censorTaskId'], |
|
||||||
bindData: map['bindData'], |
|
||||||
isChecked: map['isChecked'] == 1, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 转换为JSON字符串 |
|
||||||
String toJson() => json.encode(toMap()); |
|
||||||
|
|
||||||
/// 从JSON字符串创建对象 |
|
||||||
factory Problem.fromJson(String source) => |
|
||||||
Problem.fromMap(json.decode(source)); |
|
||||||
} |
|
@ -1,98 +0,0 @@ |
|||||||
import 'package:problem_check_system/data/models/image_metadata_model.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:uuid/uuid.dart'; |
|
||||||
|
|
||||||
enum ProblemSyncStatus { |
|
||||||
/// 未跟踪 - 需要被移除的记录(如本地删除但从未同步过) |
|
||||||
untracked, |
|
||||||
|
|
||||||
/// 已同步 - 与服务器完全一致(类似git的unmodified) |
|
||||||
synced, |
|
||||||
|
|
||||||
/// 待创建 - 新问题,需要上传到服务器(类似git的untracked → staged) |
|
||||||
pendingCreate, |
|
||||||
|
|
||||||
/// 待更新 - 已修改的问题,需要更新到服务器(类似git的modified → staged) |
|
||||||
pendingUpdate, |
|
||||||
|
|
||||||
/// 待删除 - 已标记删除,需要从服务器删除(类似git的deleted → staged) |
|
||||||
pendingDelete, |
|
||||||
} |
|
||||||
|
|
||||||
/// 问题状态管理器 - 类似 git add/git commit |
|
||||||
class ProblemStateManager { |
|
||||||
/// 静态对象uuid |
|
||||||
static final Uuid _uuid = Uuid(); |
|
||||||
|
|
||||||
/// 创建新问题(类似创建新文件) |
|
||||||
static Problem createNewProblem({ |
|
||||||
required String description, |
|
||||||
required String location, |
|
||||||
required List<ImageMetadata> imageUrls, |
|
||||||
}) { |
|
||||||
return Problem( |
|
||||||
id: _uuid.v4(), |
|
||||||
description: description, |
|
||||||
location: location, |
|
||||||
imageUrls: imageUrls, |
|
||||||
creationTime: DateTime.now(), |
|
||||||
lastModifiedTime: DateTime.now(), |
|
||||||
syncStatus: ProblemSyncStatus.pendingCreate, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 修改问题内容(类似编辑文件) |
|
||||||
static Problem modifyProblem(Problem problem) { |
|
||||||
final newStatus = problem.syncStatus == ProblemSyncStatus.synced |
|
||||||
? ProblemSyncStatus |
|
||||||
.pendingUpdate // 已同步的改为待更新 |
|
||||||
: problem.syncStatus; // 保持原有待处理状态 |
|
||||||
|
|
||||||
return problem.copyWith( |
|
||||||
syncStatus: newStatus, |
|
||||||
lastModifiedTime: DateTime.now(), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 标记问题为删除 |
|
||||||
static Problem markForDeletion(Problem problem) { |
|
||||||
switch (problem.syncStatus) { |
|
||||||
case ProblemSyncStatus.pendingCreate: |
|
||||||
// 待创建的问题 → 未跟踪(直接移除) |
|
||||||
return problem.copyWith( |
|
||||||
syncStatus: ProblemSyncStatus.untracked, |
|
||||||
lastModifiedTime: DateTime.now(), |
|
||||||
); |
|
||||||
case ProblemSyncStatus.synced: |
|
||||||
case ProblemSyncStatus.pendingUpdate: |
|
||||||
// 已同步或待更新的问题 → 待删除(需要服务器操作) |
|
||||||
return problem.copyWith( |
|
||||||
syncStatus: ProblemSyncStatus.pendingDelete, |
|
||||||
lastModifiedTime: DateTime.now(), |
|
||||||
); |
|
||||||
case ProblemSyncStatus.untracked: |
|
||||||
case ProblemSyncStatus.pendingDelete: |
|
||||||
// 已经是删除相关状态,无需变化 |
|
||||||
return problem; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 撤销删除(类似 git reset) |
|
||||||
static Problem undoDeletion(Problem problem) { |
|
||||||
if (problem.syncStatus == ProblemSyncStatus.pendingDelete) { |
|
||||||
return problem.copyWith( |
|
||||||
syncStatus: ProblemSyncStatus.pendingUpdate, |
|
||||||
lastModifiedTime: DateTime.now(), |
|
||||||
); |
|
||||||
} |
|
||||||
return problem; |
|
||||||
} |
|
||||||
|
|
||||||
/// 同步成功后的状态更新(类似 git commit 成功) |
|
||||||
static Problem markAsSynced(Problem problem) { |
|
||||||
return problem.copyWith( |
|
||||||
syncStatus: ProblemSyncStatus.synced, |
|
||||||
lastModifiedTime: DateTime.now(), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,88 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
|
|
||||||
@immutable |
|
||||||
class ServerProblem { |
|
||||||
final String id; |
|
||||||
final String title; |
|
||||||
final String location; |
|
||||||
final String? censorTaskId; |
|
||||||
final String? rowId; |
|
||||||
final String? bindData; |
|
||||||
final List<String>? imageUrls; |
|
||||||
final DateTime creationTime; |
|
||||||
final String creatorId; |
|
||||||
final DateTime lastModificationTime; |
|
||||||
final String lastModifierId; |
|
||||||
|
|
||||||
const ServerProblem({ |
|
||||||
required this.id, |
|
||||||
required this.title, |
|
||||||
required this.location, |
|
||||||
this.censorTaskId, |
|
||||||
this.rowId, |
|
||||||
this.bindData, |
|
||||||
this.imageUrls, |
|
||||||
required this.creationTime, |
|
||||||
required this.creatorId, |
|
||||||
required this.lastModificationTime, |
|
||||||
required this.lastModifierId, |
|
||||||
}); |
|
||||||
|
|
||||||
factory ServerProblem.fromJson(Map<String, dynamic> json) => ServerProblem( |
|
||||||
id: json['id'] as String, |
|
||||||
title: json['title'] as String, |
|
||||||
location: json['location'] as String, |
|
||||||
censorTaskId: json['censorTaskId'] as String?, |
|
||||||
rowId: json['rowId'] as String?, |
|
||||||
bindData: json['bindData'] as String?, |
|
||||||
imageUrls: json['imageUrls'] as List<String>?, |
|
||||||
creationTime: DateTime.parse(json['creationTime'] as String), |
|
||||||
creatorId: json['creatorId'] as String, |
|
||||||
lastModificationTime: DateTime.parse( |
|
||||||
json['lastModificationTime'] as String, |
|
||||||
), |
|
||||||
lastModifierId: json['lastModifierId'] as String, |
|
||||||
); |
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => { |
|
||||||
'id': id, |
|
||||||
'title': title, |
|
||||||
'location': location, |
|
||||||
'censorTaskId': censorTaskId, |
|
||||||
'rowId': rowId, |
|
||||||
'bindData': bindData, |
|
||||||
'imageUrls': imageUrls, |
|
||||||
'creationTime': creationTime.toUtc().toIso8601String(), |
|
||||||
'creatorId': creatorId, |
|
||||||
'lastModificationTime': lastModificationTime.toUtc().toIso8601String(), |
|
||||||
'lastModifierId': lastModifierId, |
|
||||||
}; |
|
||||||
|
|
||||||
ServerProblem copyWith({ |
|
||||||
String? id, |
|
||||||
String? title, |
|
||||||
String? location, |
|
||||||
String? censorTaskId, |
|
||||||
String? rowId, |
|
||||||
String? bindData, |
|
||||||
List<String>? imageUrls, |
|
||||||
DateTime? creationTime, |
|
||||||
String? creatorId, |
|
||||||
DateTime? lastModificationTime, |
|
||||||
String? lastModifierId, |
|
||||||
}) { |
|
||||||
return ServerProblem( |
|
||||||
id: id ?? this.id, |
|
||||||
title: title ?? this.title, |
|
||||||
location: location ?? this.location, |
|
||||||
censorTaskId: censorTaskId ?? this.censorTaskId, |
|
||||||
rowId: rowId ?? this.rowId, |
|
||||||
bindData: bindData ?? this.bindData, |
|
||||||
imageUrls: imageUrls ?? this.imageUrls, |
|
||||||
creationTime: creationTime ?? this.creationTime, |
|
||||||
creatorId: creatorId ?? this.creatorId, |
|
||||||
lastModificationTime: lastModificationTime ?? this.lastModificationTime, |
|
||||||
lastModifierId: lastModifierId ?? this.lastModifierId, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,81 +0,0 @@ |
|||||||
class Organization { |
|
||||||
String? id; |
|
||||||
String? name; |
|
||||||
dynamic organizationTypes; |
|
||||||
dynamic newAuth; |
|
||||||
String? otherAuth; |
|
||||||
String? psmAuth; |
|
||||||
String? dangerAuth; |
|
||||||
dynamic parentId; |
|
||||||
int? order; |
|
||||||
bool? enabled; |
|
||||||
String? level; |
|
||||||
String? code; |
|
||||||
List<dynamic>? organizationTypeIds; |
|
||||||
String? creationTime; |
|
||||||
dynamic creatorId; |
|
||||||
DateTime? lastModificationTime; |
|
||||||
String? lastModifierId; |
|
||||||
|
|
||||||
Organization({ |
|
||||||
this.id, |
|
||||||
this.name, |
|
||||||
this.organizationTypes, |
|
||||||
this.newAuth, |
|
||||||
this.otherAuth, |
|
||||||
this.psmAuth, |
|
||||||
this.dangerAuth, |
|
||||||
this.parentId, |
|
||||||
this.order, |
|
||||||
this.enabled, |
|
||||||
this.level, |
|
||||||
this.code, |
|
||||||
this.organizationTypeIds, |
|
||||||
this.creationTime, |
|
||||||
this.creatorId, |
|
||||||
this.lastModificationTime, |
|
||||||
this.lastModifierId, |
|
||||||
}); |
|
||||||
|
|
||||||
factory Organization.fromJson(Map<String, dynamic> json) => Organization( |
|
||||||
id: json['id'] as String?, |
|
||||||
name: json['name'] as String?, |
|
||||||
organizationTypes: json['organizationTypes'] as dynamic, |
|
||||||
newAuth: json['newAuth'] as dynamic, |
|
||||||
otherAuth: json['otherAuth'] as String?, |
|
||||||
psmAuth: json['psmAuth'] as String?, |
|
||||||
dangerAuth: json['dangerAuth'] as String?, |
|
||||||
parentId: json['parentId'] as dynamic, |
|
||||||
order: json['order'] as int?, |
|
||||||
enabled: json['enabled'] as bool?, |
|
||||||
level: json['level'] as String?, |
|
||||||
code: json['code'] as String?, |
|
||||||
organizationTypeIds: json['organizationTypeIds'] as List<dynamic>?, |
|
||||||
creationTime: json['creationTime'] as String?, |
|
||||||
creatorId: json['creatorId'] as dynamic, |
|
||||||
lastModificationTime: json['lastModificationTime'] == null |
|
||||||
? null |
|
||||||
: DateTime.parse(json['lastModificationTime'] as String), |
|
||||||
lastModifierId: json['lastModifierId'] as String?, |
|
||||||
); |
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => { |
|
||||||
'id': id, |
|
||||||
'name': name, |
|
||||||
'organizationTypes': organizationTypes, |
|
||||||
'newAuth': newAuth, |
|
||||||
'otherAuth': otherAuth, |
|
||||||
'psmAuth': psmAuth, |
|
||||||
'dangerAuth': dangerAuth, |
|
||||||
'parentId': parentId, |
|
||||||
'order': order, |
|
||||||
'enabled': enabled, |
|
||||||
'level': level, |
|
||||||
'code': code, |
|
||||||
'organizationTypeIds': organizationTypeIds, |
|
||||||
'creationTime': creationTime, |
|
||||||
'creatorId': creatorId, |
|
||||||
'lastModificationTime': lastModificationTime?.toIso8601String(), |
|
||||||
'lastModifierId': lastModifierId, |
|
||||||
}; |
|
||||||
} |
|
@ -1,15 +0,0 @@ |
|||||||
class Page { |
|
||||||
String? id; |
|
||||||
String? name; |
|
||||||
dynamic url; |
|
||||||
|
|
||||||
Page({this.id, this.name, this.url}); |
|
||||||
|
|
||||||
factory Page.fromJson(Map<String, dynamic> json) => Page( |
|
||||||
id: json['id'] as String?, |
|
||||||
name: json['name'] as String?, |
|
||||||
url: json['url'] as dynamic, |
|
||||||
); |
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'url': url}; |
|
||||||
} |
|
@ -1,15 +0,0 @@ |
|||||||
class Role { |
|
||||||
String? id; |
|
||||||
String? name; |
|
||||||
dynamic desc; |
|
||||||
|
|
||||||
Role({this.id, this.name, this.desc}); |
|
||||||
|
|
||||||
factory Role.fromJson(Map<String, dynamic> json) => Role( |
|
||||||
id: json['id'] as String?, |
|
||||||
name: json['name'] as String?, |
|
||||||
desc: json['desc'] as dynamic, |
|
||||||
); |
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'desc': desc}; |
|
||||||
} |
|
@ -1,81 +0,0 @@ |
|||||||
import 'organization.dart'; |
|
||||||
import 'page.dart'; |
|
||||||
import 'role.dart'; |
|
||||||
|
|
||||||
class User { |
|
||||||
String? id; |
|
||||||
String? username; |
|
||||||
dynamic email; |
|
||||||
String? name; |
|
||||||
bool? enabled; |
|
||||||
dynamic posts; |
|
||||||
String? organizationId; |
|
||||||
Organization? organization; |
|
||||||
String? organizationName; |
|
||||||
String? organizationLevel; |
|
||||||
List<Role>? roles; |
|
||||||
List<dynamic>? permissions; |
|
||||||
List<Page>? pages; |
|
||||||
dynamic company; |
|
||||||
String? signatureImage; |
|
||||||
|
|
||||||
User({ |
|
||||||
this.id, |
|
||||||
this.username, |
|
||||||
this.email, |
|
||||||
this.name, |
|
||||||
this.enabled, |
|
||||||
this.posts, |
|
||||||
this.organizationId, |
|
||||||
this.organization, |
|
||||||
this.organizationName, |
|
||||||
this.organizationLevel, |
|
||||||
this.roles, |
|
||||||
this.permissions, |
|
||||||
this.pages, |
|
||||||
this.company, |
|
||||||
this.signatureImage, |
|
||||||
}); |
|
||||||
|
|
||||||
factory User.fromJson(Map<String, dynamic> json) => User( |
|
||||||
id: json['id'] as String?, |
|
||||||
username: json['username'] as String?, |
|
||||||
email: json['email'] as dynamic, |
|
||||||
name: json['name'] as String?, |
|
||||||
enabled: json['enabled'] as bool?, |
|
||||||
posts: json['posts'] as dynamic, |
|
||||||
organizationId: json['organizationId'] as String?, |
|
||||||
organization: json['organization'] == null |
|
||||||
? null |
|
||||||
: Organization.fromJson(json['organization'] as Map<String, dynamic>), |
|
||||||
organizationName: json['organizationName'] as String?, |
|
||||||
organizationLevel: json['organizationLevel'] as String?, |
|
||||||
roles: (json['roles'] as List<dynamic>?) |
|
||||||
?.map((e) => Role.fromJson(e as Map<String, dynamic>)) |
|
||||||
.toList(), |
|
||||||
permissions: json['permissions'] as List<dynamic>?, |
|
||||||
pages: (json['pages'] as List<dynamic>?) |
|
||||||
?.map((e) => Page.fromJson(e as Map<String, dynamic>)) |
|
||||||
.toList(), |
|
||||||
company: json['company'] as dynamic, |
|
||||||
signatureImage: json['signatureImage'] as String?, |
|
||||||
); |
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => { |
|
||||||
'id': id, |
|
||||||
'username': username, |
|
||||||
'email': email, |
|
||||||
'name': name, |
|
||||||
'enabled': enabled, |
|
||||||
'posts': posts, |
|
||||||
'organizationId': organizationId, |
|
||||||
'organization': organization?.toJson(), |
|
||||||
'organizationName': organizationName, |
|
||||||
'organizationLevel': organizationLevel, |
|
||||||
'roles': roles?.map((e) => e.toJson()).toList(), |
|
||||||
'permissions': permissions, |
|
||||||
'pages': pages?.map((e) => e.toJson()).toList(), |
|
||||||
'company': company, |
|
||||||
'signatureImage': signatureImage, |
|
||||||
}; |
|
||||||
} |
|
@ -1,62 +0,0 @@ |
|||||||
// lib/data/providers/connectivity_provider.dart |
|
||||||
import 'dart:async'; |
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
|
|
||||||
class ConnectivityProvider extends GetxService { |
|
||||||
final Connectivity _connectivity = Connectivity(); |
|
||||||
final RxBool isOnline = false.obs; |
|
||||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription; |
|
||||||
|
|
||||||
@override |
|
||||||
void onInit() { |
|
||||||
super.onInit(); |
|
||||||
_initConnectivityListener(); |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void onClose() { |
|
||||||
_connectivitySubscription.cancel(); |
|
||||||
super.onClose(); |
|
||||||
} |
|
||||||
|
|
||||||
Future<void> _initConnectivityListener() async { |
|
||||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(( |
|
||||||
results, |
|
||||||
) { |
|
||||||
final isConnected = results.any( |
|
||||||
(result) => |
|
||||||
result == ConnectivityResult.mobile || |
|
||||||
result == ConnectivityResult.wifi || |
|
||||||
result == ConnectivityResult.ethernet, |
|
||||||
); |
|
||||||
isOnline.value = isConnected; |
|
||||||
if (isConnected) { |
|
||||||
Get.snackbar( |
|
||||||
'网络状态', |
|
||||||
'已连接到网络', |
|
||||||
colorText: Colors.white, |
|
||||||
backgroundColor: Colors.green, |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} else { |
|
||||||
Get.snackbar( |
|
||||||
'网络状态', |
|
||||||
'已断开网络连接', |
|
||||||
colorText: Colors.white, |
|
||||||
backgroundColor: Colors.red, |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
final initialResults = await _connectivity.checkConnectivity(); |
|
||||||
isOnline.value = initialResults.any( |
|
||||||
(result) => |
|
||||||
result == ConnectivityResult.mobile || |
|
||||||
result == ConnectivityResult.wifi || |
|
||||||
result == ConnectivityResult.ethernet, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,258 +0,0 @@ |
|||||||
import 'package:dio/dio.dart'; |
|
||||||
import 'package:flutter/foundation.dart'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:get/get.dart' hide Response; |
|
||||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart'; |
|
||||||
import 'package:problem_check_system/app/routes/app_routes.dart'; |
|
||||||
import 'package:problem_check_system/core/utils/constants/api_endpoints.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
|
|
||||||
// DioProvider 是一个 GetxService,确保它在应用生命周期内是单例的。 |
|
||||||
// 它负责初始化和配置 Dio 实例,并添加所有拦截器。 |
|
||||||
class HttpProvider extends GetxService { |
|
||||||
late final Dio _dio; |
|
||||||
|
|
||||||
@override |
|
||||||
Future<void> onInit() async { |
|
||||||
super.onInit(); |
|
||||||
_initDio(); |
|
||||||
} |
|
||||||
|
|
||||||
// 初始化 Dio 并配置基础选项。 |
|
||||||
void _initDio() { |
|
||||||
_dio = Dio( |
|
||||||
BaseOptions( |
|
||||||
baseUrl: ApiEndpoints.baseUrl, |
|
||||||
connectTimeout: const Duration(seconds: 30), |
|
||||||
receiveTimeout: const Duration(seconds: 30), |
|
||||||
sendTimeout: const Duration(seconds: 30), |
|
||||||
headers: { |
|
||||||
'Content-Type': 'application/json', |
|
||||||
'Accept': 'application/json', |
|
||||||
}, |
|
||||||
), |
|
||||||
); |
|
||||||
|
|
||||||
// 添加拦截器。拦截器的顺序非常关键: |
|
||||||
// 1. 认证拦截器 (AuthInterceptor): 负责添加 token 和处理 401 错误,优先级最高。 |
|
||||||
// 2. 错误拦截器 (ErrorInterceptor): 处理通用错误,作为所有其他拦截器之后的最终捕获。 |
|
||||||
// 3. 日志拦截器 (LoggerInterceptor): 在调试模式下打印详细日志,方便开发。 |
|
||||||
_dio.interceptors.addAll(_getInterceptors()); |
|
||||||
} |
|
||||||
|
|
||||||
List<Interceptor> _getInterceptors() { |
|
||||||
return [_getErrorInterceptor(), if (kDebugMode) _getLoggerInterceptor()]; |
|
||||||
} |
|
||||||
|
|
||||||
/// 日志拦截器:在调试模式下打印详细的请求和响应日志。 |
|
||||||
Interceptor _getLoggerInterceptor() { |
|
||||||
return PrettyDioLogger( |
|
||||||
requestHeader: true, |
|
||||||
requestBody: true, |
|
||||||
responseHeader: true, |
|
||||||
responseBody: true, |
|
||||||
error: true, |
|
||||||
compact: false, |
|
||||||
maxWidth: 90, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 错误拦截器:处理通用的网络和服务器端错误,并显示 Snackbar 提示。 |
|
||||||
Interceptor _getErrorInterceptor() { |
|
||||||
return InterceptorsWrapper( |
|
||||||
// 在请求发送前执行 |
|
||||||
onRequest: (options, handler) async { |
|
||||||
try { |
|
||||||
// 尝试获取 AuthRepository 并添加 token 到请求头。 |
|
||||||
final authRepository = Get.find<AuthRepository>(); |
|
||||||
final token = authRepository.getToken(); |
|
||||||
if (token != null && token.isNotEmpty) { |
|
||||||
options.headers['Authorization'] = 'Bearer $token'; |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
// 如果 AuthRepository 尚未初始化(例如在登录或注册时),则跳过添加认证头。 |
|
||||||
Get.snackbar( |
|
||||||
'认证过期', |
|
||||||
'请重新手动登录', |
|
||||||
colorText: Colors.white, |
|
||||||
backgroundColor: Colors.red, |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} |
|
||||||
return handler.next(options); |
|
||||||
}, |
|
||||||
onError: (error, handler) { |
|
||||||
// 处理网络连接超时或未知网络错误。 |
|
||||||
if (error.type == DioExceptionType.connectionTimeout || |
|
||||||
error.type == DioExceptionType.receiveTimeout || |
|
||||||
error.type == DioExceptionType.sendTimeout) { |
|
||||||
Get.snackbar( |
|
||||||
'网络超时', |
|
||||||
'请检查网络连接后重试', |
|
||||||
colorText: Colors.white, |
|
||||||
backgroundColor: Colors.red, |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} else if (error.type == DioExceptionType.unknown) { |
|
||||||
Get.snackbar( |
|
||||||
'网络异常', |
|
||||||
'请检查网络连接后重试', |
|
||||||
colorText: Colors.white, |
|
||||||
backgroundColor: Colors.red, |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// 这部分代码只会在 AuthInterceptor 没有处理的错误(即非 401)时执行。 |
|
||||||
if (error.response != null) { |
|
||||||
final message = _handleStatusCode(error.response!); |
|
||||||
Get.snackbar( |
|
||||||
'请求错误', |
|
||||||
message, |
|
||||||
colorText: Colors.white, |
|
||||||
backgroundColor: Colors.red, |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
return handler.next(error); |
|
||||||
}, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 辅助方法:根据 HTTP 状态码返回用户友好的错误信息。 |
|
||||||
String _handleStatusCode(Response response) { |
|
||||||
switch (response.statusCode) { |
|
||||||
case 400: |
|
||||||
return response.data?['detail'] ?? '请求参数错误'; |
|
||||||
case 401: |
|
||||||
final authRepository = Get.find<AuthRepository>(); |
|
||||||
authRepository.clearAuthData(); |
|
||||||
Get.offAllNamed(AppRoutes.login); |
|
||||||
return '未授权,请重新登录'; |
|
||||||
case 403: |
|
||||||
return response.data?['detail'] ?? '访问被拒绝'; |
|
||||||
case 404: |
|
||||||
return response.data?['detail'] ?? '请求资源不存在'; |
|
||||||
case 422: |
|
||||||
final errors = response.data?['errors']; |
|
||||||
if (errors != null && errors is Map && errors.isNotEmpty) { |
|
||||||
return errors.values.first?.first?.toString() ?? '数据验证失败'; |
|
||||||
} |
|
||||||
return response.data?['detail'] ?? '数据验证失败'; |
|
||||||
case 500: |
|
||||||
return response.data?['detail'] ?? '服务器内部错误'; |
|
||||||
case 502: |
|
||||||
return response.data?['detail'] ?? '网关错误'; |
|
||||||
case 503: |
|
||||||
return response.data?['detail'] ?? '服务不可用'; |
|
||||||
default: |
|
||||||
return response.data?['detail'] ?? '网络异常(${response.statusCode})'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void clear() { |
|
||||||
_dio.interceptors.clear(); |
|
||||||
} |
|
||||||
|
|
||||||
/// 新增的请求方法 |
|
||||||
|
|
||||||
/// 发送 GET 请求 |
|
||||||
Future<Response> get( |
|
||||||
String path, { |
|
||||||
Map<String, dynamic>? queryParameters, |
|
||||||
Options? options, |
|
||||||
CancelToken? cancelToken, |
|
||||||
ProgressCallback? onReceiveProgress, |
|
||||||
}) async { |
|
||||||
return await _dio.get( |
|
||||||
path, |
|
||||||
queryParameters: queryParameters, |
|
||||||
options: options, |
|
||||||
cancelToken: cancelToken, |
|
||||||
onReceiveProgress: onReceiveProgress, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 发送 POST 请求 |
|
||||||
Future<Response> post( |
|
||||||
String path, { |
|
||||||
dynamic data, |
|
||||||
Map<String, dynamic>? queryParameters, |
|
||||||
Options? options, |
|
||||||
CancelToken? cancelToken, |
|
||||||
ProgressCallback? onSendProgress, |
|
||||||
ProgressCallback? onReceiveProgress, |
|
||||||
}) async { |
|
||||||
return await _dio.post( |
|
||||||
path, |
|
||||||
data: data, |
|
||||||
queryParameters: queryParameters, |
|
||||||
options: options, |
|
||||||
cancelToken: cancelToken, |
|
||||||
onSendProgress: onSendProgress, |
|
||||||
onReceiveProgress: onReceiveProgress, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 发送 PUT 请求 |
|
||||||
Future<Response> put( |
|
||||||
String path, { |
|
||||||
dynamic data, |
|
||||||
Map<String, dynamic>? queryParameters, |
|
||||||
Options? options, |
|
||||||
CancelToken? cancelToken, |
|
||||||
ProgressCallback? onSendProgress, |
|
||||||
ProgressCallback? onReceiveProgress, |
|
||||||
}) async { |
|
||||||
return await _dio.put( |
|
||||||
path, |
|
||||||
data: data, |
|
||||||
queryParameters: queryParameters, |
|
||||||
options: options, |
|
||||||
cancelToken: cancelToken, |
|
||||||
onSendProgress: onSendProgress, |
|
||||||
onReceiveProgress: onReceiveProgress, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 发送 DELETE 请求 |
|
||||||
Future<Response> delete( |
|
||||||
String path, { |
|
||||||
dynamic data, |
|
||||||
Map<String, dynamic>? queryParameters, |
|
||||||
Options? options, |
|
||||||
CancelToken? cancelToken, |
|
||||||
}) async { |
|
||||||
return await _dio.delete( |
|
||||||
path, |
|
||||||
data: data, |
|
||||||
queryParameters: queryParameters, |
|
||||||
options: options, |
|
||||||
cancelToken: cancelToken, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 下载文件 |
|
||||||
Future<Response> download( |
|
||||||
String urlPath, |
|
||||||
String savePath, { |
|
||||||
Map<String, dynamic>? queryParameters, |
|
||||||
Options? options, |
|
||||||
CancelToken? cancelToken, |
|
||||||
ProgressCallback? onReceiveProgress, |
|
||||||
bool deleteOnError = true, |
|
||||||
int? lengthHeader, |
|
||||||
Object? data, |
|
||||||
Options? requestOptions, |
|
||||||
}) async { |
|
||||||
return await _dio.download( |
|
||||||
urlPath, |
|
||||||
savePath, |
|
||||||
queryParameters: queryParameters, |
|
||||||
options: options, |
|
||||||
cancelToken: cancelToken, |
|
||||||
onReceiveProgress: onReceiveProgress, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,271 +0,0 @@ |
|||||||
// sqlite_provider.dart |
|
||||||
|
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_sync_status.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:sqflite/sqflite.dart'; |
|
||||||
import 'package:path/path.dart'; |
|
||||||
|
|
||||||
/// `SQLiteProvider` 是一个 GetxService,负责管理本地 SQLite 数据库。 |
|
||||||
/// 作为一个单例服务,它在整个应用生命周期中只会被创建一次。 |
|
||||||
class SQLiteProvider extends GetxService { |
|
||||||
static const String _dbName = 'problems.db'; |
|
||||||
static const String _tableName = 'problems'; |
|
||||||
static const int _dbVersion = 1; |
|
||||||
|
|
||||||
late Database _database; |
|
||||||
|
|
||||||
@override |
|
||||||
void onInit() { |
|
||||||
super.onInit(); |
|
||||||
_initDatabase(); |
|
||||||
} |
|
||||||
|
|
||||||
/// 异步初始化数据库连接 |
|
||||||
Future<void> _initDatabase() async { |
|
||||||
try { |
|
||||||
final databasePath = await getDatabasesPath(); |
|
||||||
final path = join(databasePath, _dbName); |
|
||||||
|
|
||||||
_database = await openDatabase( |
|
||||||
path, |
|
||||||
version: _dbVersion, |
|
||||||
onCreate: _onCreate, |
|
||||||
onUpgrade: _onUpgrade, |
|
||||||
); |
|
||||||
|
|
||||||
Get.log('数据库初始化成功'); |
|
||||||
} catch (e) { |
|
||||||
Get.log('数据库初始化失败:$e', isError: true); |
|
||||||
rethrow; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 数据库创建时的回调函数 |
|
||||||
Future<void> _onCreate(Database db, int version) async { |
|
||||||
await db.execute(''' |
|
||||||
CREATE TABLE $_tableName( |
|
||||||
id TEXT PRIMARY KEY, |
|
||||||
description TEXT NOT NULL, |
|
||||||
location TEXT NOT NULL, |
|
||||||
imageUrls TEXT NOT NULL, |
|
||||||
creationTime INTEGER NOT NULL, |
|
||||||
lastModifiedTime INTEGER NOT NULL, |
|
||||||
syncStatus INTEGER NOT NULL, |
|
||||||
censorTaskId TEXT, |
|
||||||
bindData TEXT, |
|
||||||
isChecked INTEGER NOT NULL |
|
||||||
) |
|
||||||
'''); |
|
||||||
|
|
||||||
Get.log('数据库表创建成功'); |
|
||||||
} |
|
||||||
|
|
||||||
/// 数据库版本升级处理 |
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { |
|
||||||
Get.log('正在将数据库从版本 $oldVersion 升级到 $newVersion...'); |
|
||||||
|
|
||||||
// 版本升级迁移逻辑 |
|
||||||
for (int version = oldVersion + 1; version <= newVersion; version++) { |
|
||||||
await _runMigration(db, version); |
|
||||||
} |
|
||||||
|
|
||||||
Get.log('数据库升级完成'); |
|
||||||
} |
|
||||||
|
|
||||||
/// 执行特定版本的数据库迁移 |
|
||||||
Future<void> _runMigration(Database db, int version) async { |
|
||||||
switch (version) { |
|
||||||
case 2: |
|
||||||
// 版本2迁移逻辑 |
|
||||||
// await db.execute('ALTER TABLE $_tableName ADD COLUMN newColumn TEXT;'); |
|
||||||
break; |
|
||||||
// 添加更多版本迁移逻辑 |
|
||||||
default: |
|
||||||
Get.log('没有找到版本 $version 的迁移脚本'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 插入问题记录,并设置同步状态为未同步 |
|
||||||
Future<int> insertProblem(Problem problem) async { |
|
||||||
try { |
|
||||||
final result = await _database.insert( |
|
||||||
_tableName, |
|
||||||
problem.toMap(), |
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace, |
|
||||||
); |
|
||||||
|
|
||||||
Get.log('问题记录插入成功,ID: ${problem.id}'); |
|
||||||
return result; |
|
||||||
} catch (e) { |
|
||||||
Get.log('插入问题失败(ID: ${problem.id}):$e', isError: true); |
|
||||||
throw Exception(''); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 从数据库物理删除问题记录 |
|
||||||
Future<int> deleteProblem(String problemId) async { |
|
||||||
try { |
|
||||||
final result = await _database.delete( |
|
||||||
_tableName, |
|
||||||
where: 'id = ?', |
|
||||||
whereArgs: [problemId], |
|
||||||
); |
|
||||||
|
|
||||||
if (result > 0) { |
|
||||||
Get.log('问题删除成功,ID: $problemId'); |
|
||||||
} else { |
|
||||||
Get.log('未找到要删除的问题,ID: $problemId'); |
|
||||||
} |
|
||||||
|
|
||||||
return result; |
|
||||||
} catch (e) { |
|
||||||
Get.log('删除问题失败(ID: $problemId):$e', isError: true); |
|
||||||
return 0; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 更新问题记录,并设置同步状态为未同步 |
|
||||||
Future<int> updateProblem(Problem problem) async { |
|
||||||
try { |
|
||||||
final result = await _database.update( |
|
||||||
_tableName, |
|
||||||
problem.toMap(), |
|
||||||
where: 'id = ?', |
|
||||||
whereArgs: [problem.id], |
|
||||||
); |
|
||||||
|
|
||||||
if (result > 0) { |
|
||||||
Get.log('问题更新成功,ID: ${problem.id}'); |
|
||||||
} |
|
||||||
|
|
||||||
return result; |
|
||||||
} catch (e) { |
|
||||||
Get.log('更新问题失败(ID: ${problem.id}):$e', isError: true); |
|
||||||
return 0; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// /// 获取需要同步的问题记录(所有同步状态为未同步的记录) |
|
||||||
// Future<List<Problem>> getProblemsForSync() async { |
|
||||||
// try { |
|
||||||
// final results = await _database.query( |
|
||||||
// _tableName, |
|
||||||
// where: 'syncStatus = ?', |
|
||||||
// whereArgs: [SyncStatus.notSynced.index], |
|
||||||
// orderBy: 'creationTime ASC', |
|
||||||
// ); |
|
||||||
|
|
||||||
// Get.log('找到 ${results.length} 条需要同步的记录'); |
|
||||||
// return results.map((json) => Problem.fromMap(json)).toList(); |
|
||||||
// } catch (e) { |
|
||||||
// Get.log('获取待同步问题失败:$e', isError: true); |
|
||||||
// return []; |
|
||||||
// } |
|
||||||
// } |
|
||||||
|
|
||||||
/// 标记问题为已同步(在同步成功后调用) |
|
||||||
Future<int> markAsSynced(String id) async { |
|
||||||
try { |
|
||||||
final result = await _database.update( |
|
||||||
_tableName, |
|
||||||
{'syncStatus': ProblemSyncStatus.synced.index}, |
|
||||||
where: 'id = ?', |
|
||||||
whereArgs: [id], |
|
||||||
); |
|
||||||
|
|
||||||
if (result > 0) { |
|
||||||
Get.log('问题标记为已同步,ID: $id'); |
|
||||||
} |
|
||||||
|
|
||||||
return result; |
|
||||||
} catch (e) { |
|
||||||
Get.log('标记同步状态失败(ID: $id):$e', isError: true); |
|
||||||
return 0; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 根据ID获取问题记录 |
|
||||||
Future<Problem?> getProblemById(String id) async { |
|
||||||
try { |
|
||||||
final results = await _database.query( |
|
||||||
_tableName, |
|
||||||
where: 'id = ?', |
|
||||||
whereArgs: [id], |
|
||||||
limit: 1, |
|
||||||
); |
|
||||||
|
|
||||||
return results.isNotEmpty ? Problem.fromMap(results.first) : null; |
|
||||||
} catch (e) { |
|
||||||
Get.log('获取问题失败(ID: $id):$e', isError: true); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 获取问题列表(支持多种筛选条件) |
|
||||||
Future<List<Problem>> getProblems({ |
|
||||||
DateTime? startDate, |
|
||||||
DateTime? endDate, |
|
||||||
String? syncStatus, |
|
||||||
String? bindStatus, |
|
||||||
}) async { |
|
||||||
try { |
|
||||||
final whereClauses = <String>[]; |
|
||||||
final whereArgs = <dynamic>[]; |
|
||||||
|
|
||||||
// 时间范围筛选 |
|
||||||
if (startDate != null) { |
|
||||||
whereClauses.add('creationTime >= ?'); |
|
||||||
whereArgs.add(startDate.millisecondsSinceEpoch); |
|
||||||
} |
|
||||||
|
|
||||||
if (endDate != null) { |
|
||||||
whereClauses.add('creationTime <= ?'); |
|
||||||
whereArgs.add(endDate.millisecondsSinceEpoch); |
|
||||||
} |
|
||||||
|
|
||||||
// 同步状态筛选 |
|
||||||
if (syncStatus != null && syncStatus != '全部') { |
|
||||||
if (syncStatus == '未上传') { |
|
||||||
whereClauses.add('syncStatus IN (?, ?, ?)'); |
|
||||||
whereArgs.addAll([ |
|
||||||
ProblemSyncStatus.pendingCreate.index, |
|
||||||
ProblemSyncStatus.pendingUpdate.index, |
|
||||||
ProblemSyncStatus.pendingDelete.index, |
|
||||||
]); |
|
||||||
} else { |
|
||||||
whereClauses.add('syncStatus = ?'); |
|
||||||
whereArgs.add(ProblemSyncStatus.synced.index); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 绑定状态筛选 |
|
||||||
if (bindStatus != null && bindStatus != '全部') { |
|
||||||
if (bindStatus == '已绑定') { |
|
||||||
whereClauses.add('bindData IS NOT NULL AND bindData != ""'); |
|
||||||
} else { |
|
||||||
whereClauses.add('(bindData IS NULL OR bindData = "")'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
final results = await _database.query( |
|
||||||
_tableName, |
|
||||||
where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null, |
|
||||||
whereArgs: whereArgs.isEmpty ? null : whereArgs, |
|
||||||
orderBy: 'creationTime DESC', |
|
||||||
); |
|
||||||
|
|
||||||
return results.map((json) => Problem.fromMap(json)).toList(); |
|
||||||
} catch (e) { |
|
||||||
Get.log('获取问题列表失败:$e', isError: true); |
|
||||||
return []; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void onClose() { |
|
||||||
_database.close(); |
|
||||||
Get.log('数据库连接已关闭'); |
|
||||||
super.onClose(); |
|
||||||
} |
|
||||||
} |
|
@ -1,121 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:get_storage/get_storage.dart'; |
|
||||||
import 'package:problem_check_system/core/utils/constants/api_endpoints.dart'; |
|
||||||
import 'package:problem_check_system/data/models/auth_model.dart'; |
|
||||||
import 'package:problem_check_system/data/models/user/user.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/connectivity_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/http_provider.dart'; |
|
||||||
|
|
||||||
class AuthRepository extends GetxService { |
|
||||||
final HttpProvider httpProvider; |
|
||||||
final GetStorage storage; |
|
||||||
final ConnectivityProvider connectivityProvider; |
|
||||||
|
|
||||||
AuthRepository({ |
|
||||||
required this.httpProvider, |
|
||||||
required this.storage, |
|
||||||
required this.connectivityProvider, |
|
||||||
}); |
|
||||||
|
|
||||||
static const String _tokenKey = 'token'; |
|
||||||
static const String _refreshTokenKey = 'refresh_token'; |
|
||||||
static const String _loginKey = 'user'; |
|
||||||
static const String _rememberMe = 'remember_me'; |
|
||||||
|
|
||||||
void saveToken(String token) { |
|
||||||
storage.write(_tokenKey, token); |
|
||||||
} |
|
||||||
|
|
||||||
String? getToken() { |
|
||||||
return storage.read(_tokenKey); |
|
||||||
} |
|
||||||
|
|
||||||
void saveRefreshToken(String refreshToken) { |
|
||||||
storage.write(_refreshTokenKey, refreshToken); |
|
||||||
} |
|
||||||
|
|
||||||
String? getRefreshToken() { |
|
||||||
return storage.read(_refreshTokenKey); |
|
||||||
} |
|
||||||
|
|
||||||
void addLoginKey(LoginRequest login) { |
|
||||||
storage.write(_loginKey, login.toJson()); |
|
||||||
} |
|
||||||
|
|
||||||
/// 登录请求 |
|
||||||
LoginRequest getLoginKey() { |
|
||||||
final loginData = storage.read(_loginKey); |
|
||||||
|
|
||||||
// 检查是否找到数据,并进行反序列化 |
|
||||||
if (loginData != null) { |
|
||||||
// 确保类型正确,然后进行反序列化 |
|
||||||
return LoginRequest.fromJson(Map<String, dynamic>.from(loginData)); |
|
||||||
} |
|
||||||
|
|
||||||
// 如果没有找到数据,返回一个默认的空对象 |
|
||||||
return LoginRequest(username: '', password: ''); |
|
||||||
} |
|
||||||
|
|
||||||
void removeLoginKey() { |
|
||||||
storage.remove(_loginKey); |
|
||||||
} |
|
||||||
|
|
||||||
void addRememberMe(bool remembered) { |
|
||||||
storage.write(_rememberMe, remembered); |
|
||||||
} |
|
||||||
|
|
||||||
bool getRememberMe() { |
|
||||||
return storage.read(_rememberMe) ?? false; |
|
||||||
} |
|
||||||
|
|
||||||
void clearAuthData() { |
|
||||||
storage.remove(_tokenKey); |
|
||||||
storage.remove(_refreshTokenKey); |
|
||||||
} |
|
||||||
|
|
||||||
// 是否在线 |
|
||||||
bool get isOnline { |
|
||||||
return connectivityProvider.isOnline.value; |
|
||||||
} |
|
||||||
|
|
||||||
/// Check if a user is currently logged in by verifying the existence of a token. |
|
||||||
bool isLoggedIn() { |
|
||||||
final token = getToken(); |
|
||||||
return token != null && token.isNotEmpty; |
|
||||||
} |
|
||||||
|
|
||||||
/// Handles the user login process by calling the API and saving the response. |
|
||||||
Future<LoginResponse> login(LoginRequest request) async { |
|
||||||
final response = await httpProvider.post( |
|
||||||
ApiEndpoints.postLogin, |
|
||||||
data: request.toJson(), |
|
||||||
); |
|
||||||
|
|
||||||
final loginResponse = LoginResponse.fromJson(response.data); |
|
||||||
return loginResponse; |
|
||||||
} |
|
||||||
|
|
||||||
/// 从 API 获取用户个人资料 |
|
||||||
Future<User> getUserProfile() async { |
|
||||||
final response = await httpProvider.get(ApiEndpoints.getUserProfile); |
|
||||||
|
|
||||||
// 将 JSON 数据反序列化为 Profile 模型 |
|
||||||
return User.fromJson(response.data); |
|
||||||
} |
|
||||||
|
|
||||||
/// Refreshes the authentication token using the refresh token. |
|
||||||
Future<LoginResponse> refreshToken() async { |
|
||||||
final refreshToken = getRefreshToken(); |
|
||||||
|
|
||||||
final response = await httpProvider.post( |
|
||||||
ApiEndpoints.postRefreshToken, |
|
||||||
data: {'refresh_token': refreshToken}, |
|
||||||
); |
|
||||||
|
|
||||||
final authResponse = LoginResponse.fromJson(response.data); |
|
||||||
saveToken(authResponse.token); |
|
||||||
saveRefreshToken(authResponse.refreshToken); |
|
||||||
|
|
||||||
return authResponse; |
|
||||||
} |
|
||||||
} |
|
@ -1,167 +0,0 @@ |
|||||||
import 'dart:io'; |
|
||||||
|
|
||||||
import 'package:dio/dio.dart'; |
|
||||||
import 'package:flutter/foundation.dart'; // 引入 kDebugMode 和 debugPrint |
|
||||||
import 'package:get/get.dart' hide FormData, MultipartFile; |
|
||||||
import 'package:path/path.dart' as p; |
|
||||||
import 'package:path_provider/path_provider.dart'; |
|
||||||
import 'package:problem_check_system/core/extensions/http_response_extension.dart'; |
|
||||||
import 'package:problem_check_system/core/utils/constants/api_endpoints.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/providers/http_provider.dart'; |
|
||||||
|
|
||||||
class FileRepository { |
|
||||||
final HttpProvider _httpProvider = Get.find<HttpProvider>(); |
|
||||||
|
|
||||||
/// @param imageFilePath 要上传的本地图片文件。 |
|
||||||
/// @param cancelToken 用于取消上传任务的令牌。 |
|
||||||
/// @param onSendProgress 上传进度回调,提供已发送和总大小。 |
|
||||||
/// @return 上传成功后服务器返回的图片 URL。 |
|
||||||
Future<String> uploadImage( |
|
||||||
String imageFilePath, { |
|
||||||
required CancelToken cancelToken, |
|
||||||
ProgressCallback? onSendProgress, |
|
||||||
}) async { |
|
||||||
try { |
|
||||||
// 1. 创建 FormData 对象,用于构建 multipart/form-data 请求体 |
|
||||||
final formData = FormData.fromMap({ |
|
||||||
// 'file': 这通常是后端接口定义的文件字段名 |
|
||||||
'file': await MultipartFile.fromFile( |
|
||||||
imageFilePath, |
|
||||||
filename: p.basename(imageFilePath), |
|
||||||
), |
|
||||||
}); |
|
||||||
|
|
||||||
// 2. 使用 HttpProvider 的 post 方法发送请求 |
|
||||||
final response = await _httpProvider.post( |
|
||||||
ApiEndpoints.postUploadFile, |
|
||||||
data: formData, |
|
||||||
cancelToken: cancelToken, // 将取消令牌传递给 post 请求 |
|
||||||
onSendProgress: onSendProgress, // 将进度回调传递给 post 请求 |
|
||||||
); |
|
||||||
|
|
||||||
// --- 在这里打印服务器的完整响应结构 (仅在调试模式下) --- |
|
||||||
if (kDebugMode) { |
|
||||||
debugPrint('服务器返回的状态码: ${response.statusCode}'); |
|
||||||
debugPrint('服务器返回的原始数据: ${response.data}'); |
|
||||||
} |
|
||||||
|
|
||||||
// 3. 处理响应,并返回图片 URL |
|
||||||
if (response.isSuccess) { |
|
||||||
final Map<String, dynamic> data = response.data; |
|
||||||
|
|
||||||
// 假设服务器返回的图片 URL 字段名为 'url' |
|
||||||
String imageUrl = data['fileName']; |
|
||||||
|
|
||||||
return imageUrl; |
|
||||||
} else { |
|
||||||
throw Exception('上传失败,状态码: ${response.statusCode}'); |
|
||||||
} |
|
||||||
} on DioException catch (e) { |
|
||||||
Get.log('图片上传发生未知错误: $e'); |
|
||||||
throw Exception('图片上传失败: ${e.message}'); |
|
||||||
} catch (e) { |
|
||||||
Get.log('图片上传发生未知错误: $e'); |
|
||||||
throw Exception('图片上传发生未知错误: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 新增的下载方法 |
|
||||||
Future<ImageMetadata> downloadImage( |
|
||||||
String imageUrl, { |
|
||||||
CancelToken? cancelToken, |
|
||||||
void Function(int received, int total)? onReceiveProgress, |
|
||||||
}) async { |
|
||||||
final directory = await getApplicationDocumentsDirectory(); |
|
||||||
final imagesDir = Directory('${directory.path}/problem_images'); |
|
||||||
|
|
||||||
// 确保目录存在 |
|
||||||
if (!await imagesDir.exists()) { |
|
||||||
await imagesDir.create(recursive: true); |
|
||||||
} |
|
||||||
|
|
||||||
// 生成唯一的文件名 |
|
||||||
final String fileName = |
|
||||||
'downloaded_${DateTime.now().millisecondsSinceEpoch}_${imageUrl.hashCode}${_getFileExtension(imageUrl)}'; |
|
||||||
final String imagePath = '${imagesDir.path}/$fileName'; |
|
||||||
|
|
||||||
try { |
|
||||||
// 下载图片 |
|
||||||
await _httpProvider.download( |
|
||||||
imageUrl, |
|
||||||
imagePath, |
|
||||||
cancelToken: cancelToken, |
|
||||||
onReceiveProgress: onReceiveProgress, |
|
||||||
); |
|
||||||
|
|
||||||
// 返回图片元数据 |
|
||||||
return ImageMetadata( |
|
||||||
localPath: imagePath, |
|
||||||
remoteUrl: imageUrl, |
|
||||||
status: ImageStatus.synced, |
|
||||||
); |
|
||||||
} catch (e) { |
|
||||||
// 清理可能创建的不完整文件 |
|
||||||
final file = File(imagePath); |
|
||||||
if (await file.exists()) { |
|
||||||
await file.delete(); |
|
||||||
} |
|
||||||
rethrow; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 批量下载方法 |
|
||||||
Future<List<ImageMetadata>> downloadImages( |
|
||||||
List<String> imageUrls, { |
|
||||||
CancelToken? cancelToken, |
|
||||||
void Function(int current, int total)? onProgress, |
|
||||||
}) async { |
|
||||||
final List<ImageMetadata> results = []; |
|
||||||
int downloadedCount = 0; |
|
||||||
|
|
||||||
for (final imageUrl in imageUrls) { |
|
||||||
if (cancelToken?.isCancelled == true) { |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
final metadata = await downloadImage( |
|
||||||
imageUrl, |
|
||||||
cancelToken: cancelToken, |
|
||||||
onReceiveProgress: (received, total) { |
|
||||||
// 单个文件的进度可以在这里处理 |
|
||||||
}, |
|
||||||
); |
|
||||||
results.add(metadata); |
|
||||||
|
|
||||||
// 更新总体进度 |
|
||||||
downloadedCount++; |
|
||||||
onProgress?.call(downloadedCount, imageUrls.length); |
|
||||||
} catch (e) { |
|
||||||
Get.log('Failed to download image $imageUrl: $e'); |
|
||||||
// 可以选择继续下载其他图片或抛出异常 |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return results; |
|
||||||
} |
|
||||||
|
|
||||||
// 辅助方法:获取文件扩展名 |
|
||||||
String _getFileExtension(String url) { |
|
||||||
try { |
|
||||||
final uri = Uri.parse(url); |
|
||||||
final pathSegments = uri.pathSegments; |
|
||||||
if (pathSegments.isNotEmpty) { |
|
||||||
final fileName = pathSegments.last; |
|
||||||
final dotIndex = fileName.lastIndexOf('.'); |
|
||||||
if (dotIndex != -1 && dotIndex < fileName.length - 1) { |
|
||||||
return fileName.substring(dotIndex); |
|
||||||
} |
|
||||||
} |
|
||||||
return '.jpg'; |
|
||||||
} catch (e) { |
|
||||||
return '.jpg'; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,8 +0,0 @@ |
|||||||
// image_repository.dart |
|
||||||
abstract class ImageRepository { |
|
||||||
Future<String> downloadImage(String imageUrl, String problemId); |
|
||||||
Future<bool> isImageDownloaded(String imageUrl, String problemId); |
|
||||||
Future<String?> getLocalImagePath(String imageUrl, String problemId); |
|
||||||
Future<void> deleteProblemImages(String problemId); |
|
||||||
Future<void> cleanupCache({Duration maxAge = const Duration(days: 30)}); |
|
||||||
} |
|
@ -1,202 +0,0 @@ |
|||||||
// image_repository_impl.dart |
|
||||||
import 'dart:io'; |
|
||||||
import 'package:dio/dio.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:path_provider/path_provider.dart'; |
|
||||||
import 'package:path/path.dart' as path; |
|
||||||
import 'package:problem_check_system/data/providers/http_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/image_repository.dart'; |
|
||||||
|
|
||||||
class ImageRepositoryImpl implements ImageRepository { |
|
||||||
final HttpProvider httpProvider; |
|
||||||
|
|
||||||
ImageRepositoryImpl({required this.httpProvider}); |
|
||||||
|
|
||||||
@override |
|
||||||
Future<String> downloadImage(String imageUrl, String problemId) async { |
|
||||||
try { |
|
||||||
// 1. 获取应用文档目录 |
|
||||||
final Directory appDocDir = await getApplicationDocumentsDirectory(); |
|
||||||
final String problemDirPath = path.join( |
|
||||||
appDocDir.path, |
|
||||||
'problems', |
|
||||||
problemId, |
|
||||||
'images', |
|
||||||
); |
|
||||||
final Directory problemDir = Directory(problemDirPath); |
|
||||||
|
|
||||||
// 2. 创建目录(如果不存在) |
|
||||||
if (!await problemDir.exists()) { |
|
||||||
await problemDir.create(recursive: true); |
|
||||||
} |
|
||||||
|
|
||||||
// 3. 生成文件名 |
|
||||||
final String fileExtension = _getFileExtensionFromUrl(imageUrl); |
|
||||||
final String fileName = |
|
||||||
'${_generateFileNameHash(imageUrl)}.$fileExtension'; |
|
||||||
final String filePath = path.join(problemDir.path, fileName); |
|
||||||
|
|
||||||
// 4. 使用 Dio 下载文件 |
|
||||||
final response = await httpProvider.download( |
|
||||||
imageUrl, |
|
||||||
filePath, |
|
||||||
options: Options( |
|
||||||
responseType: ResponseType.bytes, |
|
||||||
followRedirects: true, |
|
||||||
receiveTimeout: const Duration(seconds: 30), |
|
||||||
), |
|
||||||
onReceiveProgress: (received, total) { |
|
||||||
if (total != -1) { |
|
||||||
Get.log('下载进度: ${(received / total * 100).toStringAsFixed(1)}%'); |
|
||||||
} |
|
||||||
}, |
|
||||||
); |
|
||||||
|
|
||||||
if (response.statusCode == 200) { |
|
||||||
// 验证文件是否成功写入 |
|
||||||
final File file = File(filePath); |
|
||||||
if (await file.exists()) { |
|
||||||
return filePath; |
|
||||||
} else { |
|
||||||
throw Exception('文件写入失败'); |
|
||||||
} |
|
||||||
} else { |
|
||||||
throw Exception('下载失败: HTTP ${response.statusCode}'); |
|
||||||
} |
|
||||||
} on DioException catch (e) { |
|
||||||
throw Exception('图片下载失败: ${e.message}'); |
|
||||||
} catch (e) { |
|
||||||
throw Exception('图片下载失败: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
Future<bool> isImageDownloaded(String imageUrl, String problemId) async { |
|
||||||
try { |
|
||||||
final String fileExtension = _getFileExtensionFromUrl(imageUrl); |
|
||||||
final String fileName = |
|
||||||
'${_generateFileNameHash(imageUrl)}.$fileExtension'; |
|
||||||
|
|
||||||
final Directory appDocDir = await getApplicationDocumentsDirectory(); |
|
||||||
final String filePath = path.join( |
|
||||||
appDocDir.path, |
|
||||||
'problems', |
|
||||||
problemId, |
|
||||||
'images', |
|
||||||
fileName, |
|
||||||
); |
|
||||||
|
|
||||||
return await File(filePath).exists(); |
|
||||||
} catch (e) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
Future<String?> getLocalImagePath(String imageUrl, String problemId) async { |
|
||||||
try { |
|
||||||
final String fileExtension = _getFileExtensionFromUrl(imageUrl); |
|
||||||
final String fileName = |
|
||||||
'${_generateFileNameHash(imageUrl)}.$fileExtension'; |
|
||||||
|
|
||||||
final Directory appDocDir = await getApplicationDocumentsDirectory(); |
|
||||||
final String filePath = path.join( |
|
||||||
appDocDir.path, |
|
||||||
'problems', |
|
||||||
problemId, |
|
||||||
'images', |
|
||||||
fileName, |
|
||||||
); |
|
||||||
|
|
||||||
if (await File(filePath).exists()) { |
|
||||||
return filePath; |
|
||||||
} |
|
||||||
return null; |
|
||||||
} catch (e) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
Future<void> deleteProblemImages(String problemId) async { |
|
||||||
try { |
|
||||||
final Directory appDocDir = await getApplicationDocumentsDirectory(); |
|
||||||
final String problemDirPath = path.join( |
|
||||||
appDocDir.path, |
|
||||||
'problems', |
|
||||||
problemId, |
|
||||||
); |
|
||||||
final Directory problemDir = Directory(problemDirPath); |
|
||||||
|
|
||||||
if (await problemDir.exists()) { |
|
||||||
await problemDir.delete(recursive: true); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Get.log('删除图片失败: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
Future<void> cleanupCache({ |
|
||||||
Duration maxAge = const Duration(days: 30), |
|
||||||
}) async { |
|
||||||
try { |
|
||||||
final Directory appDocDir = await getApplicationDocumentsDirectory(); |
|
||||||
final String problemsDirPath = path.join(appDocDir.path, 'problems'); |
|
||||||
final Directory problemsDir = Directory(problemsDirPath); |
|
||||||
|
|
||||||
if (await problemsDir.exists()) { |
|
||||||
final DateTime cutoffTime = DateTime.now().subtract(maxAge); |
|
||||||
|
|
||||||
final List<FileSystemEntity> entities = await problemsDir |
|
||||||
.list() |
|
||||||
.toList(); |
|
||||||
|
|
||||||
for (final entity in entities) { |
|
||||||
if (entity is Directory) { |
|
||||||
try { |
|
||||||
final FileStat stat = await entity.stat(); |
|
||||||
if (stat.modified.isBefore(cutoffTime)) { |
|
||||||
await entity.delete(recursive: true); |
|
||||||
Get.log('已清理过期目录: ${entity.path}'); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Get.log('无法获取目录状态: ${entity.path}, 错误: $e'); |
|
||||||
// 如果无法获取状态,也尝试删除(可能是损坏的目录) |
|
||||||
try { |
|
||||||
await entity.delete(recursive: true); |
|
||||||
Get.log('强制清理目录: ${entity.path}'); |
|
||||||
} catch (deleteError) { |
|
||||||
Get.log('删除目录失败: ${entity.path}, 错误: $deleteError'); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Get.log('清理缓存失败: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 辅助方法 |
|
||||||
String _getFileExtensionFromUrl(String url) { |
|
||||||
try { |
|
||||||
final Uri uri = Uri.parse(url); |
|
||||||
final String path = uri.path; |
|
||||||
|
|
||||||
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'jpg'; |
|
||||||
if (path.endsWith('.png')) return 'png'; |
|
||||||
if (path.endsWith('.gif')) return 'gif'; |
|
||||||
if (path.endsWith('.webp')) return 'webp'; |
|
||||||
if (path.endsWith('.bmp')) return 'bmp'; |
|
||||||
|
|
||||||
return 'jpg'; |
|
||||||
} catch (e) { |
|
||||||
return 'jpg'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
String _generateFileNameHash(String url) { |
|
||||||
return url.hashCode.abs().toString(); |
|
||||||
} |
|
||||||
} |
|
@ -1,128 +0,0 @@ |
|||||||
import 'package:dio/dio.dart'; |
|
||||||
import 'package:get/get.dart' hide MultipartFile, FormData, Response; |
|
||||||
import 'package:problem_check_system/core/extensions/http_response_extension.dart'; |
|
||||||
import 'package:problem_check_system/core/utils/constants/api_endpoints.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:problem_check_system/data/models/server_problem.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/connectivity_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/http_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/providers/sqlite_provider.dart'; |
|
||||||
|
|
||||||
/// 问题仓库,负责处理问题数据的本地持久化。 |
|
||||||
/// 它封装了底层数据库操作,为业务逻辑层提供一个简洁的接口。 |
|
||||||
class ProblemRepository extends GetxService { |
|
||||||
final SQLiteProvider sqliteProvider; |
|
||||||
final HttpProvider httpProvider; |
|
||||||
final ConnectivityProvider connectivityProvider; |
|
||||||
|
|
||||||
RxBool get isOnline => connectivityProvider.isOnline; |
|
||||||
|
|
||||||
ProblemRepository({ |
|
||||||
required this.sqliteProvider, |
|
||||||
required this.httpProvider, |
|
||||||
required this.connectivityProvider, |
|
||||||
}); |
|
||||||
|
|
||||||
/// 更新本地数据库中的一个问题。 |
|
||||||
Future<void> updateProblem(Problem problem) async { |
|
||||||
await sqliteProvider.updateProblem(problem); |
|
||||||
} |
|
||||||
|
|
||||||
/// 通用查询方法,根据可选的筛选条件获取问题列表。 |
|
||||||
/// - `startDate`/`endDate`:筛选创建时间范围。 |
|
||||||
/// - `syncStatus`:筛选上传状态('已上传', '未上传', '全部')。 |
|
||||||
/// - `bindStatus`:筛选绑定状态('已绑定', '未绑定', '全部')。 |
|
||||||
Future getProblems({ |
|
||||||
DateTime? startDate, |
|
||||||
DateTime? endDate, |
|
||||||
String? syncStatus, |
|
||||||
String? bindStatus, |
|
||||||
}) async { |
|
||||||
return await sqliteProvider.getProblems( |
|
||||||
startDate: startDate, |
|
||||||
endDate: endDate, |
|
||||||
syncStatus: syncStatus, |
|
||||||
bindStatus: bindStatus, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Future<void> insertProblem(Problem problem) async { |
|
||||||
await sqliteProvider.insertProblem(problem); |
|
||||||
} |
|
||||||
|
|
||||||
Future<void> deleteProblem(String problemId) async { |
|
||||||
await sqliteProvider.deleteProblem(problemId); |
|
||||||
} |
|
||||||
|
|
||||||
// 在ProblemRepository中添加 |
|
||||||
Future<List<ServerProblem>> fetchProblemsFromServer({ |
|
||||||
DateTime? startTime, |
|
||||||
DateTime? endTime, |
|
||||||
int? pageNumber, |
|
||||||
int? pageSize, |
|
||||||
CancelToken? cancelToken, |
|
||||||
}) async { |
|
||||||
try { |
|
||||||
final response = await httpProvider.get( |
|
||||||
ApiEndpoints.getProblems, |
|
||||||
queryParameters: { |
|
||||||
if (startTime != null) |
|
||||||
'StartTime': startTime.toUtc().toIso8601String(), |
|
||||||
if (endTime != null) 'EndTime': endTime.toUtc().toIso8601String(), |
|
||||||
if (pageNumber != null) 'pageNumber': pageNumber, |
|
||||||
if (pageSize != null) 'pageSize': pageSize, |
|
||||||
}, |
|
||||||
cancelToken: cancelToken, |
|
||||||
); |
|
||||||
|
|
||||||
if (response.isSuccess) { |
|
||||||
// 假设服务器返回的是Problem对象的列表 |
|
||||||
final List<dynamic> data = response.data; |
|
||||||
return data.map((json) => ServerProblem.fromJson(json)).toList(); |
|
||||||
} else { |
|
||||||
throw Exception('拉取问题失败: ${response.statusCode}'); |
|
||||||
} |
|
||||||
} on DioException catch (e) { |
|
||||||
throw Exception('拉取问题失败: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// post |
|
||||||
Future<Response> post( |
|
||||||
Map<String, Object> apiPayload, |
|
||||||
CancelToken cancelToken, |
|
||||||
) async { |
|
||||||
// 3. 发送给服务器 |
|
||||||
final response = await httpProvider.post( |
|
||||||
ApiEndpoints.postProblem, |
|
||||||
data: apiPayload, |
|
||||||
cancelToken: cancelToken, |
|
||||||
); |
|
||||||
return response; |
|
||||||
} |
|
||||||
|
|
||||||
/// put |
|
||||||
Future<Response> put( |
|
||||||
String id, |
|
||||||
Map<String, Object> apiPayload, |
|
||||||
CancelToken cancelToken, |
|
||||||
) async { |
|
||||||
// 3. 发送给服务器 |
|
||||||
final response = await httpProvider.put( |
|
||||||
ApiEndpoints.putProblemById(id), |
|
||||||
data: apiPayload, |
|
||||||
cancelToken: cancelToken, |
|
||||||
); |
|
||||||
return response; |
|
||||||
} |
|
||||||
|
|
||||||
/// delete |
|
||||||
Future<Response> delete(String id, CancelToken cancelToken) async { |
|
||||||
// 3. 发送给服务器 |
|
||||||
final response = await httpProvider.delete( |
|
||||||
ApiEndpoints.deleteProblemById(id), |
|
||||||
cancelToken: cancelToken, |
|
||||||
); |
|
||||||
return response; |
|
||||||
} |
|
||||||
} |
|
@ -1,12 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
import 'package:problem_check_system/modules/auth/controllers/login_controller.dart'; |
|
||||||
|
|
||||||
class LoginBinding implements Bindings { |
|
||||||
@override |
|
||||||
void dependencies() { |
|
||||||
Get.lazyPut<LoginController>( |
|
||||||
() => LoginController(authRepository: Get.find<AuthRepository>()), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,99 +0,0 @@ |
|||||||
import 'package:dio/dio.dart'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/models/auth_model.dart'; |
|
||||||
import 'package:problem_check_system/app/routes/app_routes.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
|
|
||||||
class LoginController extends GetxController { |
|
||||||
final AuthRepository _authRepository; |
|
||||||
final TextEditingController usernameController = TextEditingController(); |
|
||||||
final TextEditingController passwordController = TextEditingController(); |
|
||||||
|
|
||||||
final isLoading = false.obs; |
|
||||||
final rememberMe = false.obs; |
|
||||||
|
|
||||||
LoginController({required AuthRepository authRepository}) |
|
||||||
: _authRepository = authRepository; |
|
||||||
|
|
||||||
@override |
|
||||||
void onInit() { |
|
||||||
super.onInit(); |
|
||||||
_loadRememberedMe(); |
|
||||||
} |
|
||||||
|
|
||||||
void _loadRememberedMe() { |
|
||||||
rememberMe.value = _authRepository.getRememberMe(); |
|
||||||
if (rememberMe.value) { |
|
||||||
final loginData = _authRepository.getLoginKey(); |
|
||||||
usernameController.text = loginData.username; |
|
||||||
passwordController.text = loginData.password; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Check if the user is already logged in by delegating to the repository. |
|
||||||
bool isLoggedIn() { |
|
||||||
return _authRepository.isLoggedIn(); |
|
||||||
} |
|
||||||
|
|
||||||
/// 登录逻辑 |
|
||||||
Future<void> login() async { |
|
||||||
final username = usernameController.text.trim(); |
|
||||||
final password = passwordController.text.trim(); |
|
||||||
|
|
||||||
if (username.isEmpty || password.isEmpty) { |
|
||||||
Get.snackbar('输入错误', '用户名和密码不能为空'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
isLoading.value = true; |
|
||||||
final loginData = LoginRequest(username: username, password: password); |
|
||||||
|
|
||||||
if (_authRepository.isOnline) { |
|
||||||
await _onlineLogin(loginData); |
|
||||||
} else { |
|
||||||
_offlineLogin(loginData); |
|
||||||
} |
|
||||||
isLoading.value = false; |
|
||||||
} |
|
||||||
|
|
||||||
/// 在线登录 |
|
||||||
Future<void> _onlineLogin(LoginRequest loginRequest) async { |
|
||||||
try { |
|
||||||
// 调用你的 post 方法 |
|
||||||
var loginResponse = await _authRepository.login(loginRequest); |
|
||||||
// 登录成功后 |
|
||||||
_authRepository.saveToken(loginResponse.token); |
|
||||||
_authRepository.saveRefreshToken(loginResponse.refreshToken); |
|
||||||
_authRepository.addRememberMe(rememberMe.value); |
|
||||||
|
|
||||||
if (rememberMe.value) { |
|
||||||
_authRepository.addLoginKey(loginRequest); |
|
||||||
} else { |
|
||||||
_authRepository.removeLoginKey(); |
|
||||||
} |
|
||||||
Get.offAllNamed(AppRoutes.home); |
|
||||||
// 登录成功,处理响应 |
|
||||||
debugPrint('登录成功: $loginResponse'); |
|
||||||
} on DioException catch (e) { |
|
||||||
// 捕获由拦截器处理后抛出的 DioException |
|
||||||
// 拦截器已经显示了 Snackbar,这里你可以做其他业务处理,例如清空表单等。 |
|
||||||
debugPrint('登录失败,由DioException捕获: ${e.message}'); |
|
||||||
} catch (e) { |
|
||||||
// 捕获其他非 DioException 异常 |
|
||||||
debugPrint('发生未知错误: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void _offlineLogin(LoginRequest loginRequest) { |
|
||||||
final loginData = _authRepository.getLoginKey(); |
|
||||||
|
|
||||||
if (loginData.username == loginRequest.username && |
|
||||||
loginData.password == loginRequest.password) { |
|
||||||
Get.offAllNamed(AppRoutes.home); |
|
||||||
Get.snackbar('离线登录成功', '您已离线登录到系统'); |
|
||||||
} else { |
|
||||||
Get.snackbar('登录失败', '无网络连接,且无法验证本地凭证'); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,189 +0,0 @@ |
|||||||
// lib/modules/auth/views/login_page.dart |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/auth/controllers/login_controller.dart'; |
|
||||||
|
|
||||||
class LoginPage extends GetView<LoginController> { |
|
||||||
const LoginPage({super.key}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Scaffold(body: SingleChildScrollView(child: _buildBackground())); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildBackground() { |
|
||||||
return Container( |
|
||||||
decoration: const BoxDecoration( |
|
||||||
image: DecorationImage( |
|
||||||
image: AssetImage('assets/images/background.png'), |
|
||||||
// 使用 BoxFit.cover 确保图片填充整个容器,不留空白 |
|
||||||
fit: BoxFit.fitWidth, |
|
||||||
alignment: Alignment.topCenter, |
|
||||||
), |
|
||||||
), |
|
||||||
child: Stack( |
|
||||||
children: [ |
|
||||||
Column( |
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||||
children: [ |
|
||||||
SizedBox(height: 89.5.h), |
|
||||||
Padding( |
|
||||||
padding: EdgeInsets.only(left: 28.5.w), |
|
||||||
child: Image.asset( |
|
||||||
'assets/images/label.png', |
|
||||||
width: 171.5.w, |
|
||||||
height: 23.5.h, |
|
||||||
fit: BoxFit.fitWidth, |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(height: 15.5.h), |
|
||||||
Padding( |
|
||||||
padding: EdgeInsets.only(left: 28.5.w), |
|
||||||
child: Image.asset( |
|
||||||
'assets/images/label1.png', |
|
||||||
width: 296.5.w, |
|
||||||
height: 35.5.h, |
|
||||||
fit: BoxFit.fitWidth, |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(height: 56.5.h), |
|
||||||
Center(child: _buildLoginCard()), |
|
||||||
], |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// 修改 _buildLoginCard 方法,它不再需要传入 TextEditingController |
|
||||||
Widget _buildLoginCard() { |
|
||||||
return Container( |
|
||||||
width: 334.w, |
|
||||||
height: 574.5.h, |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: const Color(0xFFFFFFFF).withValues(alpha: 153), |
|
||||||
borderRadius: BorderRadius.all(Radius.circular(23.5.r)), |
|
||||||
), |
|
||||||
padding: EdgeInsets.all(24.w), |
|
||||||
child: Column( |
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||||
children: [ |
|
||||||
const SizedBox(height: 16), |
|
||||||
// 直接使用控制器中的 TextEditingController |
|
||||||
_buildTextFieldSection( |
|
||||||
label: '账号', |
|
||||||
hintText: '请输入您的账号', |
|
||||||
controller: controller.usernameController, |
|
||||||
), |
|
||||||
const SizedBox(height: 22), |
|
||||||
_buildTextFieldSection( |
|
||||||
label: '密码', |
|
||||||
hintText: '请输入您的密码', |
|
||||||
obscureText: true, |
|
||||||
controller: controller.passwordController, |
|
||||||
), |
|
||||||
const SizedBox(height: 9.5), |
|
||||||
_buildRememberPasswordRow(), |
|
||||||
const SizedBox(height: 138.5), |
|
||||||
_buildLoginButton(), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// 修改 _buildTextFieldSection,不再需要 onChanged 回调 |
|
||||||
Widget _buildTextFieldSection({ |
|
||||||
required String label, |
|
||||||
required String hintText, |
|
||||||
required TextEditingController controller, |
|
||||||
bool obscureText = false, |
|
||||||
}) { |
|
||||||
return Column( |
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||||
children: [ |
|
||||||
Text( |
|
||||||
label, |
|
||||||
style: TextStyle(fontSize: 16.5.sp, color: Colors.black), |
|
||||||
), |
|
||||||
const SizedBox(height: 10.5), |
|
||||||
TextField( |
|
||||||
controller: controller, // 使用传入的控制器 |
|
||||||
obscureText: obscureText, |
|
||||||
style: const TextStyle(color: Colors.black), |
|
||||||
decoration: InputDecoration( |
|
||||||
hintText: hintText, |
|
||||||
hintStyle: const TextStyle(color: Colors.grey), |
|
||||||
border: const OutlineInputBorder(), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildRememberPasswordRow() { |
|
||||||
return Row( |
|
||||||
mainAxisAlignment: MainAxisAlignment.end, |
|
||||||
children: [ |
|
||||||
Obx( |
|
||||||
() => Checkbox( |
|
||||||
value: controller.rememberMe.value, |
|
||||||
onChanged: (value) => controller.rememberMe.value = value!, |
|
||||||
), |
|
||||||
), |
|
||||||
Text( |
|
||||||
'记住密码', |
|
||||||
style: TextStyle(color: const Color(0xFF959595), fontSize: 14.sp), |
|
||||||
), |
|
||||||
], |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildLoginButton() { |
|
||||||
return SizedBox( |
|
||||||
width: double.infinity, |
|
||||||
child: ElevatedButton( |
|
||||||
onPressed: controller.login, |
|
||||||
style: ElevatedButton.styleFrom( |
|
||||||
padding: EdgeInsets.zero, |
|
||||||
backgroundColor: Colors.transparent, |
|
||||||
shadowColor: Colors.transparent, |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
minimumSize: Size(double.infinity, 48.h), |
|
||||||
), |
|
||||||
child: Ink( |
|
||||||
decoration: BoxDecoration( |
|
||||||
gradient: const LinearGradient( |
|
||||||
colors: [Color(0xFF418CFC), Color(0xFF3DBFFC)], |
|
||||||
begin: Alignment.centerLeft, |
|
||||||
end: Alignment.centerRight, |
|
||||||
), |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
child: Container( |
|
||||||
constraints: BoxConstraints(minHeight: 48.h), |
|
||||||
alignment: Alignment.center, |
|
||||||
padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 24.w), |
|
||||||
child: Obx(() { |
|
||||||
if (controller.isLoading.value) { |
|
||||||
return const CircularProgressIndicator( |
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), |
|
||||||
); |
|
||||||
} |
|
||||||
return Text( |
|
||||||
'登录', |
|
||||||
style: TextStyle( |
|
||||||
color: Colors.white, |
|
||||||
fontSize: 16.sp, |
|
||||||
fontWeight: FontWeight.w500, |
|
||||||
), |
|
||||||
); |
|
||||||
}), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,25 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/problem_repository.dart'; |
|
||||||
import 'package:problem_check_system/modules/home/controllers/home_controller.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/controllers/my_controller.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
|
|
||||||
class HomeBinding implements Bindings { |
|
||||||
@override |
|
||||||
void dependencies() { |
|
||||||
/// 注册主页控制器 |
|
||||||
Get.lazyPut<HomeController>(() => HomeController()); |
|
||||||
|
|
||||||
/// 注册问题控制器 |
|
||||||
Get.lazyPut<ProblemController>( |
|
||||||
() => ProblemController(problemRepository: Get.find<ProblemRepository>()), |
|
||||||
fenix: true, |
|
||||||
); |
|
||||||
|
|
||||||
/// 注册我的控制器 |
|
||||||
Get.lazyPut<MyController>( |
|
||||||
() => MyController(authRepository: Get.find<AuthRepository>()), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,21 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/views/my_page.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/problem_page.dart'; |
|
||||||
|
|
||||||
class HomeController extends GetxController { |
|
||||||
// 使用 Rx 类型管理响应式状态 |
|
||||||
var selectedIndex = 0.obs; |
|
||||||
|
|
||||||
// 页面列表 |
|
||||||
final List<Widget> pages = [ |
|
||||||
// const Center(child: Text('首页内容')), |
|
||||||
const ProblemPage(), // 使用 const 确保子页面不会频繁重建 |
|
||||||
const MyPage(), |
|
||||||
]; |
|
||||||
|
|
||||||
// 改变选中索引,这个方法将由 NavigationBar 调用 |
|
||||||
void changeIndex(int index) { |
|
||||||
selectedIndex.value = index; |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,21 @@ |
|||||||
|
// 控制器(Logic 层) |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:get/get.dart'; |
||||||
|
import 'package:problem_check_system/modules/problem/problem_page.dart'; |
||||||
|
|
||||||
|
class HomeController extends GetxController { |
||||||
|
// 当前选中索引(状态) |
||||||
|
var selectedIndex = 0.obs; |
||||||
|
|
||||||
|
// 页面列表(View 层) |
||||||
|
final List<Widget> pages = [ |
||||||
|
Center(child: Text('首页')), |
||||||
|
ProblemPage(), |
||||||
|
Center(child: Text('我的内容')), |
||||||
|
]; |
||||||
|
|
||||||
|
// 改变选中索引(更新状态) |
||||||
|
void changeIndex(int index) { |
||||||
|
selectedIndex.value = index; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
class LoginModel { |
||||||
|
String username = ""; |
||||||
|
String password = ""; |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
import 'package:get/get.dart'; |
||||||
|
|
||||||
|
class LoginController extends GetxController { |
||||||
|
var username = ''.obs; |
||||||
|
var password = ''.obs; |
||||||
|
var rememberPassword = false.obs; |
||||||
|
|
||||||
|
void login() { |
||||||
|
if (username.isEmpty || password.isEmpty) { |
||||||
|
Get.snackbar('错误', '请输入完整的信息'); |
||||||
|
} else { |
||||||
|
// 在这里执行登录逻辑,例如 API 调用 |
||||||
|
print('用户名: ${username.value}, 密码: ${password.value}'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,175 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||||
|
import 'package:get/get.dart'; |
||||||
|
import 'package:problem_check_system/modules/login/view_models/login_controller.dart'; |
||||||
|
|
||||||
|
class LoginPage extends StatelessWidget { |
||||||
|
final LoginController controller = Get.put(LoginController()); |
||||||
|
|
||||||
|
LoginPage({super.key}); |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Scaffold( |
||||||
|
resizeToAvoidBottomInset: false, |
||||||
|
body: Stack( |
||||||
|
children: [ |
||||||
|
// 背景设置 |
||||||
|
Container( |
||||||
|
decoration: BoxDecoration( |
||||||
|
image: DecorationImage( |
||||||
|
image: AssetImage('assets/images/background.png'), |
||||||
|
fit: BoxFit.fitWidth, |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
// 绝对定位的图片 |
||||||
|
Positioned( |
||||||
|
left: 28.5.w, |
||||||
|
top: 89.5.h, |
||||||
|
child: Image.asset( |
||||||
|
'assets/images/label.png', |
||||||
|
width: 171.5.w, |
||||||
|
height: 23.5.h, |
||||||
|
fit: BoxFit.fitWidth, |
||||||
|
), |
||||||
|
), |
||||||
|
Positioned( |
||||||
|
left: 28.5.w, |
||||||
|
top: 128.5.h, |
||||||
|
child: Image.asset( |
||||||
|
'assets/images/label1.png', |
||||||
|
width: 296.5.w, |
||||||
|
height: 35.5.h, |
||||||
|
fit: BoxFit.fitWidth, |
||||||
|
), |
||||||
|
), |
||||||
|
Positioned( |
||||||
|
left: 20.5.w, // 左侧距离20.5dp |
||||||
|
top: 220.5.h, // 顶部距离220.5dp |
||||||
|
child: Container( |
||||||
|
width: 334.w, // 宽度334dp |
||||||
|
height: 574.5.h, // 高度574.5dp |
||||||
|
decoration: BoxDecoration( |
||||||
|
color: const Color( |
||||||
|
0xFFFFFFFF, |
||||||
|
).withValues(alpha: 153), // 白色60%透明度 |
||||||
|
borderRadius: BorderRadius.all( |
||||||
|
Radius.circular(23.5.r), // 四个角都是23.5dp圆角 |
||||||
|
), |
||||||
|
), |
||||||
|
padding: EdgeInsets.all(24.w), |
||||||
|
child: Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
const SizedBox(height: 16), |
||||||
|
Text( |
||||||
|
'账号', |
||||||
|
style: TextStyle(fontSize: 16.5.sp, color: Colors.black), |
||||||
|
), |
||||||
|
const SizedBox(height: 10.5), |
||||||
|
TextField( |
||||||
|
style: TextStyle(color: Colors.black), |
||||||
|
decoration: InputDecoration( |
||||||
|
// labelText: '账号', |
||||||
|
hintText: '请输入您的账号', |
||||||
|
hintStyle: TextStyle(color: Colors.grey), |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(height: 22), |
||||||
|
Text( |
||||||
|
'密码', |
||||||
|
style: TextStyle(fontSize: 16.5.sp, color: Colors.black), |
||||||
|
), |
||||||
|
const SizedBox(height: 10.5), |
||||||
|
TextField( |
||||||
|
obscureText: true, |
||||||
|
style: TextStyle(color: Colors.black), |
||||||
|
decoration: InputDecoration( |
||||||
|
// labelText: '密码', |
||||||
|
hintText: '请输入您的密码', |
||||||
|
hintStyle: TextStyle(color: Colors.grey), |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(height: 9.5), |
||||||
|
Row( |
||||||
|
mainAxisAlignment: MainAxisAlignment.end, |
||||||
|
children: [ |
||||||
|
Obx( |
||||||
|
() => Checkbox( |
||||||
|
value: controller.rememberPassword.value, |
||||||
|
onChanged: (value) => |
||||||
|
controller.rememberPassword.value = value!, |
||||||
|
), |
||||||
|
), |
||||||
|
Text( |
||||||
|
'记住密码', |
||||||
|
style: TextStyle( |
||||||
|
color: const Color(0xFF959595), // 颜色值 |
||||||
|
fontSize: 14.sp, // 响应式字体大小 |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
const SizedBox(height: 138.5), |
||||||
|
SizedBox( |
||||||
|
width: double.infinity, |
||||||
|
child: ElevatedButton( |
||||||
|
onPressed: () { |
||||||
|
// 跳转到 HomePage |
||||||
|
Get.toNamed('/home'); |
||||||
|
// Navigator.push( |
||||||
|
// context, |
||||||
|
// MaterialPageRoute( |
||||||
|
// builder: (context) => const HomePage(), |
||||||
|
// ), |
||||||
|
// ); |
||||||
|
}, |
||||||
|
style: ElevatedButton.styleFrom( |
||||||
|
padding: EdgeInsets.zero, |
||||||
|
backgroundColor: Colors.transparent, |
||||||
|
shadowColor: Colors.transparent, |
||||||
|
shape: RoundedRectangleBorder( |
||||||
|
borderRadius: BorderRadius.circular(8.r), // 响应式圆角 |
||||||
|
), |
||||||
|
minimumSize: Size(double.infinity, 48.h), // 响应式高度 |
||||||
|
), |
||||||
|
child: Ink( |
||||||
|
decoration: BoxDecoration( |
||||||
|
gradient: LinearGradient( |
||||||
|
colors: [Color(0xFF418CFC), Color(0xFF3DBFFC)], |
||||||
|
begin: Alignment.centerLeft, |
||||||
|
end: Alignment.centerRight, |
||||||
|
), |
||||||
|
borderRadius: BorderRadius.circular(8.r), |
||||||
|
), |
||||||
|
child: Container( |
||||||
|
constraints: BoxConstraints(minHeight: 48.h), |
||||||
|
alignment: Alignment.center, |
||||||
|
padding: EdgeInsets.symmetric( |
||||||
|
vertical: 12.h, |
||||||
|
horizontal: 24.w, |
||||||
|
), |
||||||
|
child: Text( |
||||||
|
'登录', |
||||||
|
style: TextStyle( |
||||||
|
color: Colors.white, |
||||||
|
fontSize: 16.sp, |
||||||
|
fontWeight: FontWeight.w500, |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -1,13 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/controllers/change_password_controller.dart'; |
|
||||||
|
|
||||||
class ChangePasswordBinding implements Bindings { |
|
||||||
@override |
|
||||||
void dependencies() { |
|
||||||
Get.lazyPut<ChangePasswordController>( |
|
||||||
() => |
|
||||||
ChangePasswordController(authRepository: Get.find<AuthRepository>()), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,54 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
|
|
||||||
class ChangePasswordController extends GetxController { |
|
||||||
// 响应式变量,用于存储新密码和确认密码 |
|
||||||
var newPassword = ''.obs; |
|
||||||
var confirmPassword = ''.obs; |
|
||||||
var isLoading = false.obs; |
|
||||||
final AuthRepository authRepository; |
|
||||||
|
|
||||||
ChangePasswordController({required this.authRepository}); |
|
||||||
|
|
||||||
// 更新新密码 |
|
||||||
void updateNewPassword(String value) { |
|
||||||
newPassword.value = value; |
|
||||||
} |
|
||||||
|
|
||||||
// 更新确认密码 |
|
||||||
void updateConfirmPassword(String value) { |
|
||||||
confirmPassword.value = value; |
|
||||||
} |
|
||||||
|
|
||||||
// 修改密码逻辑 |
|
||||||
Future<void> changePassword() async { |
|
||||||
// 简单验证 |
|
||||||
if (newPassword.value.isEmpty || confirmPassword.value.isEmpty) { |
|
||||||
Get.snackbar('错误', '密码不能为空'); |
|
||||||
return; |
|
||||||
} |
|
||||||
if (newPassword.value != confirmPassword.value) { |
|
||||||
Get.snackbar('错误', '两次输入的密码不一致'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
isLoading.value = true; |
|
||||||
try { |
|
||||||
// final response = await _userProvider.changePassword(newPassword.value); |
|
||||||
// if (response.statusCode == 200) { |
|
||||||
// Get.back(); |
|
||||||
// Get.snackbar('成功', '密码修改成功'); |
|
||||||
// } else { |
|
||||||
// Get.snackbar('失败', '密码修改失败,请重试'); |
|
||||||
// } |
|
||||||
// 模拟网络请求 |
|
||||||
await Future.delayed(const Duration(seconds: 2)); |
|
||||||
Get.back(); |
|
||||||
Get.snackbar('成功', '密码修改成功', snackbarStatus: (status) {}); |
|
||||||
} catch (e) { |
|
||||||
Get.snackbar('错误', '修改密码失败: ${e.toString()}'); |
|
||||||
} finally { |
|
||||||
isLoading.value = false; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,54 +0,0 @@ |
|||||||
import 'package:dio/dio.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/app/routes/app_routes.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/auth_repository.dart'; |
|
||||||
|
|
||||||
class MyController extends GetxController { |
|
||||||
final AuthRepository authRepository; |
|
||||||
|
|
||||||
MyController({required this.authRepository}); |
|
||||||
|
|
||||||
// 响应式变量,用于存储用户信息 |
|
||||||
var userName = '张兰雪'.obs; |
|
||||||
var userPhone = '138****8547'.obs; |
|
||||||
var userImage = "".obs; |
|
||||||
|
|
||||||
@override |
|
||||||
void onInit() { |
|
||||||
super.onInit(); |
|
||||||
_loadUserInfo(); |
|
||||||
} |
|
||||||
|
|
||||||
// 在你的 GetxController 中添加一个可观察的加载状态 |
|
||||||
RxBool isLoading = false.obs; |
|
||||||
|
|
||||||
/// 从本地存储或API加载用户信息 |
|
||||||
Future<void> _loadUserInfo() async { |
|
||||||
// 设置加载状态为 true |
|
||||||
isLoading.value = true; |
|
||||||
try { |
|
||||||
// 调用仓库层方法 |
|
||||||
final userProfile = await authRepository.getUserProfile(); |
|
||||||
|
|
||||||
// 更新用户信息,如果字段为 null,则使用默认值 |
|
||||||
userName.value = userProfile.name ?? "无"; |
|
||||||
userPhone.value = userProfile.email ?? '138****8547'; |
|
||||||
userImage.value = userProfile.signatureImage.toString(); |
|
||||||
} on DioException catch (e) { |
|
||||||
// 捕获由拦截器处理后抛出的 DioException |
|
||||||
// 拦截器已经显示了 Snackbar,这里你可以做其他业务处理,例如清空表单等。 |
|
||||||
Get.log(e.toString()); |
|
||||||
} catch (e) { |
|
||||||
// 捕获其他非 DioException 异常 |
|
||||||
} finally { |
|
||||||
isLoading.value = false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void logout() { |
|
||||||
authRepository.clearAuthData(); |
|
||||||
Get.offAllNamed(AppRoutes.login); |
|
||||||
} |
|
||||||
|
|
||||||
// 未来可以添加更新用户信息的逻辑 |
|
||||||
} |
|
@ -1,155 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/controllers/change_password_controller.dart'; |
|
||||||
|
|
||||||
class ChangePasswordPage extends StatelessWidget { |
|
||||||
const ChangePasswordPage({super.key}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
// 获取控制器实例 |
|
||||||
final ChangePasswordController controller = |
|
||||||
Get.find<ChangePasswordController>(); |
|
||||||
|
|
||||||
return Scaffold( |
|
||||||
appBar: _buildAppBar(), |
|
||||||
body: Padding( |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 24.w), |
|
||||||
child: Column( |
|
||||||
children: [ |
|
||||||
SizedBox(height: 16.h), |
|
||||||
_buildInputField( |
|
||||||
label: '新密码', |
|
||||||
hintText: '请输入新密码', |
|
||||||
onChanged: controller.updateNewPassword, |
|
||||||
obscureText: true, |
|
||||||
), |
|
||||||
SizedBox(height: 24.h), |
|
||||||
_buildInputField( |
|
||||||
label: '确认新密码', |
|
||||||
hintText: '请再次输入新密码', |
|
||||||
onChanged: controller.updateConfirmPassword, |
|
||||||
obscureText: true, |
|
||||||
), |
|
||||||
const Spacer(), // 占据剩余空间 |
|
||||||
_buildButtons(controller), |
|
||||||
SizedBox(height: 50.h), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 自定义 AppBar |
|
||||||
AppBar _buildAppBar() { |
|
||||||
return AppBar( |
|
||||||
backgroundColor: const Color(0xFFF1F7FF), |
|
||||||
elevation: 0, |
|
||||||
centerTitle: true, |
|
||||||
title: const Text( |
|
||||||
'修改密码', |
|
||||||
style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), |
|
||||||
), |
|
||||||
leading: IconButton( |
|
||||||
icon: const Icon(Icons.arrow_back_ios, color: Colors.black), |
|
||||||
onPressed: () => Get.back(), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 输入框组件 |
|
||||||
Widget _buildInputField({ |
|
||||||
required String label, |
|
||||||
required String hintText, |
|
||||||
required Function(String) onChanged, |
|
||||||
bool obscureText = false, |
|
||||||
}) { |
|
||||||
return Container( |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.white, |
|
||||||
borderRadius: BorderRadius.circular(12.r), |
|
||||||
boxShadow: [ |
|
||||||
BoxShadow( |
|
||||||
color: Colors.grey.withValues(alpha: 25.5), |
|
||||||
spreadRadius: 2, |
|
||||||
blurRadius: 5, |
|
||||||
offset: const Offset(0, 3), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
child: Column( |
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||||
children: [ |
|
||||||
Text( |
|
||||||
label, |
|
||||||
style: TextStyle( |
|
||||||
fontSize: 16.sp, |
|
||||||
fontWeight: FontWeight.w500, |
|
||||||
color: Colors.black, |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(height: 8.h), |
|
||||||
TextField( |
|
||||||
onChanged: onChanged, |
|
||||||
obscureText: obscureText, |
|
||||||
decoration: InputDecoration( |
|
||||||
hintText: hintText, |
|
||||||
hintStyle: TextStyle(color: Colors.grey, fontSize: 14.sp), |
|
||||||
border: InputBorder.none, // 移除下划线 |
|
||||||
isDense: true, |
|
||||||
contentPadding: EdgeInsets.zero, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 底部按钮区域 |
|
||||||
Widget _buildButtons(ChangePasswordController controller) { |
|
||||||
return Row( |
|
||||||
children: [ |
|
||||||
// 取消按钮 |
|
||||||
Expanded( |
|
||||||
child: OutlinedButton( |
|
||||||
onPressed: () => Get.back(), |
|
||||||
style: OutlinedButton.styleFrom( |
|
||||||
minimumSize: Size(160.w, 48.h), |
|
||||||
side: const BorderSide(color: Color(0xFF5695FD)), |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
), |
|
||||||
child: Text( |
|
||||||
'取消', |
|
||||||
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF5695FD)), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(width: 16.w), |
|
||||||
// 确定按钮 |
|
||||||
Expanded( |
|
||||||
child: ElevatedButton( |
|
||||||
onPressed: () { |
|
||||||
// 调用控制器中的修改密码方法 |
|
||||||
controller.changePassword(); |
|
||||||
}, |
|
||||||
style: ElevatedButton.styleFrom( |
|
||||||
minimumSize: Size(160.w, 48.h), |
|
||||||
backgroundColor: const Color(0xFF5695FD), |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
), |
|
||||||
child: Text( |
|
||||||
'确定', |
|
||||||
style: TextStyle(fontSize: 16.sp, color: Colors.white), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,222 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/my/controllers/my_controller.dart'; |
|
||||||
|
|
||||||
import 'package:problem_check_system/app/routes/app_routes.dart'; |
|
||||||
|
|
||||||
class MyPage extends GetView<MyController> { |
|
||||||
const MyPage({super.key}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
// Obx listens to changes in the controller's observable state, |
|
||||||
// such as an isLoading flag, and rebuilds the widget accordingly. |
|
||||||
return Obx(() { |
|
||||||
// Check if the controller is in a loading state. |
|
||||||
// Assuming MyController has a `isLoading` RxBool variable. |
|
||||||
if (controller.isLoading.value) { |
|
||||||
return const Scaffold( |
|
||||||
body: Center( |
|
||||||
// Display a CircularProgressIndicator while loading. |
|
||||||
child: CircularProgressIndicator(), |
|
||||||
), |
|
||||||
); |
|
||||||
} else { |
|
||||||
// If not loading, show the main content. |
|
||||||
return Scaffold( |
|
||||||
body: Stack( |
|
||||||
children: [ |
|
||||||
// 顶部背景区域 |
|
||||||
_buildBackground(), |
|
||||||
// 内容区域 |
|
||||||
_buildContent(), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/// 顶部背景和用户信息部分 |
|
||||||
Widget _buildBackground() { |
|
||||||
return Positioned( |
|
||||||
top: 0, |
|
||||||
left: 0, |
|
||||||
right: 0, |
|
||||||
child: Container( |
|
||||||
height: 250.h, |
|
||||||
decoration: BoxDecoration( |
|
||||||
gradient: LinearGradient( |
|
||||||
begin: Alignment.topCenter, |
|
||||||
end: Alignment.bottomCenter, |
|
||||||
colors: [const Color(0xFF418CFC), const Color(0x713DBFFC)], |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 页面核心内容 |
|
||||||
Widget _buildContent() { |
|
||||||
return Positioned( |
|
||||||
top: 100.h, |
|
||||||
left: 20.w, |
|
||||||
right: 20.w, |
|
||||||
child: Column( |
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||||
children: [ |
|
||||||
_buildUserInfoCard(), |
|
||||||
SizedBox(height: 20.h), |
|
||||||
_buildActionButtons(), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 用户信息卡片 |
|
||||||
Widget _buildUserInfoCard() { |
|
||||||
return Obx( |
|
||||||
() => Container( |
|
||||||
width: 335.w, |
|
||||||
height: 106.h, |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 20.w), |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.white, |
|
||||||
borderRadius: BorderRadius.circular(15.r), |
|
||||||
boxShadow: [ |
|
||||||
BoxShadow( |
|
||||||
color: Colors.grey.withValues(alpha: 25.5), |
|
||||||
spreadRadius: 2, |
|
||||||
blurRadius: 5, |
|
||||||
offset: const Offset(0, 3), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
child: Row( |
|
||||||
children: [ |
|
||||||
// 用户头像 |
|
||||||
Container( |
|
||||||
width: 60.w, |
|
||||||
height: 60.w, |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.grey[200], |
|
||||||
shape: BoxShape.circle, |
|
||||||
border: Border.all( |
|
||||||
color: Colors.grey.withValues(alpha: 102), |
|
||||||
width: 1.w, |
|
||||||
), |
|
||||||
), |
|
||||||
child: Image.network( |
|
||||||
controller.userImage.value, |
|
||||||
// Show a CircularProgressIndicator while the image is loading |
|
||||||
loadingBuilder: |
|
||||||
( |
|
||||||
BuildContext context, |
|
||||||
Widget child, |
|
||||||
ImageChunkEvent? loadingProgress, |
|
||||||
) { |
|
||||||
if (loadingProgress == null) { |
|
||||||
return child; |
|
||||||
} |
|
||||||
return Center( |
|
||||||
child: CircularProgressIndicator( |
|
||||||
value: loadingProgress.expectedTotalBytes != null |
|
||||||
? loadingProgress.cumulativeBytesLoaded / |
|
||||||
loadingProgress.expectedTotalBytes! |
|
||||||
: null, |
|
||||||
), |
|
||||||
); |
|
||||||
}, |
|
||||||
// Show a placeholder icon if the image fails to load |
|
||||||
errorBuilder: |
|
||||||
( |
|
||||||
BuildContext context, |
|
||||||
Object exception, |
|
||||||
StackTrace? stackTrace, |
|
||||||
) { |
|
||||||
return const Icon( |
|
||||||
Icons.person, |
|
||||||
size: 40, |
|
||||||
color: Color(0xFFC8E0FF), |
|
||||||
); |
|
||||||
}, |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(width: 15.w), |
|
||||||
// 用户名和手机号 |
|
||||||
Column( |
|
||||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||||
children: [ |
|
||||||
Text( |
|
||||||
controller.userName.value, |
|
||||||
style: TextStyle( |
|
||||||
fontSize: 20.sp, |
|
||||||
fontWeight: FontWeight.bold, |
|
||||||
), |
|
||||||
), |
|
||||||
Text( |
|
||||||
controller.userPhone.value, |
|
||||||
style: TextStyle(fontSize: 14.sp, color: Colors.grey), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 动作按钮区域 |
|
||||||
Widget _buildActionButtons() { |
|
||||||
return Column( |
|
||||||
children: [ |
|
||||||
_buildActionButton( |
|
||||||
label: '修改密码', |
|
||||||
onTap: () { |
|
||||||
Get.toNamed(AppRoutes.changePassword); |
|
||||||
}, |
|
||||||
), |
|
||||||
SizedBox(height: 15.h), |
|
||||||
_buildActionButton( |
|
||||||
label: '退出登录', |
|
||||||
isLogout: true, |
|
||||||
onTap: () { |
|
||||||
// 调用 AuthController 的退出登录方法 |
|
||||||
controller.logout(); |
|
||||||
}, |
|
||||||
), |
|
||||||
], |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 单个按钮 |
|
||||||
Widget _buildActionButton({ |
|
||||||
required String label, |
|
||||||
required VoidCallback onTap, |
|
||||||
bool isLogout = false, |
|
||||||
}) { |
|
||||||
return InkWell( |
|
||||||
onTap: onTap, |
|
||||||
child: Container( |
|
||||||
width: 335.w, |
|
||||||
height: 50.h, |
|
||||||
alignment: Alignment.center, |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.white, |
|
||||||
borderRadius: BorderRadius.circular(10.r), |
|
||||||
border: Border.all(color: const Color(0xFFEEEEEE)), |
|
||||||
), |
|
||||||
child: Text( |
|
||||||
label, |
|
||||||
style: TextStyle( |
|
||||||
fontSize: 16.sp, |
|
||||||
color: isLogout ? const Color(0xFFE50000) : Colors.black, |
|
||||||
fontWeight: isLogout ? FontWeight.bold : FontWeight.normal, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,23 +0,0 @@ |
|||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_form_controller.dart'; |
|
||||||
|
|
||||||
class ProblemFormBinding extends Bindings { |
|
||||||
@override |
|
||||||
void dependencies() { |
|
||||||
final dynamic arguments = Get.arguments; |
|
||||||
final bool readOnly = Get.parameters['isReadOnly'] == 'true'; |
|
||||||
|
|
||||||
Problem? problem; |
|
||||||
if (arguments != null && arguments is Problem) { |
|
||||||
problem = arguments; |
|
||||||
} |
|
||||||
Get.lazyPut<ProblemFormController>( |
|
||||||
() => ProblemFormController( |
|
||||||
problemRepository: Get.find(), |
|
||||||
problem: problem, |
|
||||||
isReadOnly: readOnly, |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,76 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:get/get.dart'; |
||||||
|
import 'package:tdesign_flutter/tdesign_flutter.dart'; |
||||||
|
|
||||||
|
class DatePickerButton extends StatelessWidget { |
||||||
|
DatePickerButton({super.key}); |
||||||
|
final DatePickerController dateController = Get.put(DatePickerController()); |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
ElevatedButton( |
||||||
|
style: ElevatedButton.styleFrom( |
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), |
||||||
|
backgroundColor: Colors.white, |
||||||
|
shape: RoundedRectangleBorder( |
||||||
|
borderRadius: BorderRadius.circular(8), |
||||||
|
), |
||||||
|
elevation: 2, |
||||||
|
), |
||||||
|
onPressed: () { |
||||||
|
TDPicker.showDatePicker( |
||||||
|
context, |
||||||
|
title: '选择时间', |
||||||
|
onConfirm: (selected) { |
||||||
|
dateController.updateDateTime(selected); |
||||||
|
Get.back(); |
||||||
|
}, |
||||||
|
useHour: true, |
||||||
|
useMinute: true, |
||||||
|
useSecond: true, |
||||||
|
dateStart: [1999, 01, 01], |
||||||
|
dateEnd: [2029, 12, 31], |
||||||
|
initialDate: [2025, 1, 1], |
||||||
|
); |
||||||
|
}, |
||||||
|
child: Row( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
Obx( |
||||||
|
() => Text( |
||||||
|
dateController.selectedDateTime.value.isEmpty |
||||||
|
? "选择日期" |
||||||
|
: dateController.selectedDateTime.value, |
||||||
|
style: TextStyle( |
||||||
|
fontSize: 16, |
||||||
|
fontWeight: FontWeight.bold, |
||||||
|
color: Colors.black, |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
SizedBox(width: 8), |
||||||
|
Icon(Icons.keyboard_arrow_down, color: Colors.black), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class DatePickerController extends GetxController { |
||||||
|
var selectedDateTime = ''.obs; |
||||||
|
|
||||||
|
void updateDateTime(Map<String, int> selected) { |
||||||
|
selectedDateTime.value = |
||||||
|
'${selected['year'].toString().padLeft(4, '0')}-' |
||||||
|
'${selected['month'].toString().padLeft(2, '0')}-' |
||||||
|
'${selected['day'].toString().padLeft(2, '0')} '; |
||||||
|
// '${selected['hour'].toString().padLeft(2, '0')}:' |
||||||
|
// '${selected['minute'].toString().padLeft(2, '0')}:' |
||||||
|
// '${selected['second'].toString().padLeft(2, '0')}'; |
||||||
|
} |
||||||
|
} |
@ -1,845 +0,0 @@ |
|||||||
// 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/models/server_problem.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/file_repository.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/image_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.TOP); |
|
||||||
|
|
||||||
// 上传成功后清空选中状态 |
|
||||||
clearSelection(); |
|
||||||
// 重新加载未上传的问题列表 |
|
||||||
loadUnUploadedProblems(); |
|
||||||
// 重新加载problems |
|
||||||
loadProblems(); |
|
||||||
} on DioException catch (e) { |
|
||||||
Get.back(); |
|
||||||
if (CancelToken.isCancel(e)) { |
|
||||||
Get.snackbar('提示', '上传已取消', snackPosition: SnackPosition.TOP); |
|
||||||
} else { |
|
||||||
Get.snackbar( |
|
||||||
'上传失败', |
|
||||||
'错误: ${e.message}', |
|
||||||
snackPosition: SnackPosition.TOP, |
|
||||||
); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Get.back(); |
|
||||||
Get.snackbar('上传失败', '发生未知错误', snackPosition: SnackPosition.TOP); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 显示上传对话框 |
|
||||||
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); |
|
||||||
}, |
|
||||||
); |
|
||||||
|
|
||||||
if (updatedProblem.syncStatus == ProblemSyncStatus.untracked) { |
|
||||||
problemRepository.deleteProblem(updatedProblem.id); |
|
||||||
} else { |
|
||||||
problemRepository.updateProblem(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.pendingUpload) |
|
||||||
.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( |
|
||||||
problem.id, |
|
||||||
apiPayload!, |
|
||||||
cancelToken, |
|
||||||
); |
|
||||||
break; |
|
||||||
case ProblemSyncStatus.pendingDelete: |
|
||||||
response = await problemRepository.delete(problem.id, cancelToken); |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
// 4. 处理服务器响应 |
|
||||||
if (response.isSuccess) { |
|
||||||
final problem = Problem.fromJson(response.data); |
|
||||||
// 更新图片状态(仅对创建和更新操作) |
|
||||||
final updatedImageMetadata = |
|
||||||
problem.syncStatus != ProblemSyncStatus.pendingDelete |
|
||||||
? _updateImageMetadata(problem.imageUrls, remoteUrls) |
|
||||||
: problem.imageUrls; |
|
||||||
|
|
||||||
Get.log(problem.lastModifiedTime.toUtc().toIso8601String()); |
|
||||||
// 返回同步完成的对象,操作类型重置为none |
|
||||||
return problem.copyWith( |
|
||||||
syncStatus: problem.syncStatus != ProblemSyncStatus.pendingDelete |
|
||||||
? ProblemSyncStatus.synced |
|
||||||
: ProblemSyncStatus.untracked, // 同步完成,重置为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.pendingUpload) { |
|
||||||
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.pendingUpload) { |
|
||||||
updatedImageMetadata.add( |
|
||||||
ImageMetadata( |
|
||||||
localPath: image.localPath, |
|
||||||
remoteUrl: newRemoteUrls[uploadedUrlIndex], |
|
||||||
status: ImageStatus.synced, |
|
||||||
), |
|
||||||
); |
|
||||||
uploadedUrlIndex++; |
|
||||||
} else { |
|
||||||
updatedImageMetadata.add(image); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return updatedImageMetadata; |
|
||||||
} |
|
||||||
|
|
||||||
// #endregion |
|
||||||
|
|
||||||
// #region 问题同步 |
|
||||||
// TODO 同步服务器问题到本地 |
|
||||||
Future<void> pullDataFromServer() async { |
|
||||||
isLoading.value = true; |
|
||||||
try { |
|
||||||
// 1. 从服务器获取最新数据 |
|
||||||
final List<ServerProblem> serverProblems = await problemRepository |
|
||||||
.fetchProblemsFromServer(); |
|
||||||
|
|
||||||
// 2. 获取本地数据 |
|
||||||
final List<Problem> localProblems = await problemRepository.getProblems(); |
|
||||||
|
|
||||||
// 3. 同步策略:以服务器数据为准,保留本地未同步的更改 |
|
||||||
final List<Problem> downloadedProblems = await _syncProblems( |
|
||||||
serverProblems, |
|
||||||
localProblems, |
|
||||||
); |
|
||||||
|
|
||||||
// 4. 启动图片下载任务 |
|
||||||
_downloadImagesForProblems(downloadedProblems); |
|
||||||
|
|
||||||
// 5. 重新加载本地问题列表 |
|
||||||
await loadProblems(); |
|
||||||
|
|
||||||
Get.snackbar('成功', '数据同步完成', snackPosition: SnackPosition.TOP); |
|
||||||
} catch (e) { |
|
||||||
Get.snackbar('同步失败', '错误: $e', snackPosition: SnackPosition.TOP); |
|
||||||
} finally { |
|
||||||
isLoading.value = false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 异步下载问题的图片 |
|
||||||
void _downloadImagesForProblems(List<Problem> problems) { |
|
||||||
if (problems.isEmpty) return; |
|
||||||
|
|
||||||
// 在后台执行图片下载 |
|
||||||
Future(() async { |
|
||||||
final imageRepository = Get.find<ImageRepository>(); // 使用GetX获取实例 |
|
||||||
|
|
||||||
for (final problem in problems) { |
|
||||||
try { |
|
||||||
final List<ImageMetadata> downloadedImages = []; |
|
||||||
|
|
||||||
for (final imageMeta in problem.imageUrls) { |
|
||||||
if (imageMeta.remoteUrl != null && |
|
||||||
imageMeta.remoteUrl!.isNotEmpty) { |
|
||||||
// 检查是否已下载 |
|
||||||
final bool isDownloaded = await imageRepository.isImageDownloaded( |
|
||||||
imageMeta.remoteUrl!, |
|
||||||
problem.id, |
|
||||||
); |
|
||||||
|
|
||||||
String localPath; |
|
||||||
if (isDownloaded) { |
|
||||||
// 如果已下载,获取本地路径 |
|
||||||
localPath = (await imageRepository.getLocalImagePath( |
|
||||||
imageMeta.remoteUrl!, |
|
||||||
problem.id, |
|
||||||
))!; |
|
||||||
} else { |
|
||||||
// 下载图片到本地 |
|
||||||
localPath = await imageRepository.downloadImage( |
|
||||||
imageMeta.remoteUrl!, |
|
||||||
problem.id, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// 更新图片元数据 |
|
||||||
final downloadedImage = imageMeta.copyWith( |
|
||||||
localPath: localPath, |
|
||||||
status: ImageStatus.synced, |
|
||||||
); |
|
||||||
downloadedImages.add(downloadedImage); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 更新问题的图片数据 |
|
||||||
if (downloadedImages.isNotEmpty) { |
|
||||||
final updatedProblem = problem.copyWith( |
|
||||||
imageUrls: downloadedImages, |
|
||||||
); |
|
||||||
await problemRepository.updateProblem(updatedProblem); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Get.log('下载问题 ${problem.id} 的图片失败: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/// 同步服务器和本地数据,返回需要下载图片的问题列表 |
|
||||||
Future<List<Problem>> _syncProblems( |
|
||||||
List<ServerProblem> serverProblems, |
|
||||||
List<Problem> localProblems, |
|
||||||
) async { |
|
||||||
final List<Problem> needDownloadImages = []; |
|
||||||
|
|
||||||
// 创建映射以便快速查找 |
|
||||||
final Map<String, ServerProblem> serverProblemsMap = { |
|
||||||
for (var problem in serverProblems) problem.id: problem, |
|
||||||
}; |
|
||||||
|
|
||||||
final Map<String, Problem> localProblemsMap = { |
|
||||||
for (var problem in localProblems) problem.id: problem, |
|
||||||
}; |
|
||||||
|
|
||||||
// 处理服务器有但本地没有的数据(新增) |
|
||||||
for (final serverProblem in serverProblems) { |
|
||||||
if (!localProblemsMap.containsKey(serverProblem.id)) { |
|
||||||
// 服务器新增的问题,添加到本地 |
|
||||||
final newProblem = _convertServerProblemToLocal(serverProblem); |
|
||||||
await problemRepository.insertProblem(newProblem); |
|
||||||
needDownloadImages.add(newProblem); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 处理本地有但服务器没有的数据(删除) |
|
||||||
for (final localProblem in localProblems) { |
|
||||||
if (!serverProblemsMap.containsKey(localProblem.id)) { |
|
||||||
// 只有已同步的数据才从本地删除,未同步的数据保留 |
|
||||||
if (localProblem.syncStatus == ProblemSyncStatus.synced) { |
|
||||||
await problemRepository.deleteProblem(localProblem.id); |
|
||||||
} |
|
||||||
// 如果是未同步的数据(pendingCreate/pendingUpdate),保留在本地等待上传 |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 处理双方都有的数据(更新) |
|
||||||
for (final serverProblem in serverProblems) { |
|
||||||
if (localProblemsMap.containsKey(serverProblem.id)) { |
|
||||||
final localProblem = localProblemsMap[serverProblem.id]!; |
|
||||||
|
|
||||||
// 只有当本地数据已同步时才更新(避免覆盖本地未上传的更改) |
|
||||||
if (localProblem.syncStatus == ProblemSyncStatus.synced) { |
|
||||||
// 比较更新时间,使用最新的数据 |
|
||||||
final serverUpdated = serverProblem.lastModificationTime; |
|
||||||
final localUpdated = localProblem.lastModifiedTime; |
|
||||||
|
|
||||||
if (serverUpdated.isAfter(localUpdated)) { |
|
||||||
// 服务器数据更新,更新本地数据 |
|
||||||
final updatedProblem = _convertServerProblemToLocal(serverProblem); |
|
||||||
await problemRepository.updateProblem(updatedProblem); |
|
||||||
needDownloadImages.add(updatedProblem); |
|
||||||
} |
|
||||||
} |
|
||||||
// 如果本地有未同步的更改,保留本地更改(下次上传时会同步到服务器) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return needDownloadImages; |
|
||||||
} |
|
||||||
|
|
||||||
/// 将服务器问题转换为本地问题模型 |
|
||||||
Problem _convertServerProblemToLocal(ServerProblem serverProblem) { |
|
||||||
// 转换图片URL为ImageMetadata列表(初始状态为待下载) |
|
||||||
final List<ImageMetadata> imageMetadatas = (serverProblem.imageUrls ?? []) |
|
||||||
.map( |
|
||||||
(url) => ImageMetadata( |
|
||||||
remoteUrl: url, |
|
||||||
localPath: '', // 初始为空,等待下载 |
|
||||||
status: ImageStatus.pendingDownload, // 标记为待下载状态 |
|
||||||
), |
|
||||||
) |
|
||||||
.toList(); |
|
||||||
|
|
||||||
return Problem( |
|
||||||
id: serverProblem.id, |
|
||||||
description: serverProblem.title, |
|
||||||
location: serverProblem.location, |
|
||||||
imageUrls: imageMetadatas, |
|
||||||
creationTime: serverProblem.creationTime, |
|
||||||
lastModifiedTime: serverProblem.lastModificationTime, |
|
||||||
syncStatus: ProblemSyncStatus.synced, // 来自服务器的数据标记为已同步 |
|
||||||
censorTaskId: serverProblem.censorTaskId, |
|
||||||
bindData: serverProblem.bindData, |
|
||||||
isChecked: false, // 默认未检查 |
|
||||||
); |
|
||||||
} |
|
||||||
// #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({Problem? problem}) async { |
|
||||||
await Get.toNamed(AppRoutes.problemForm, arguments: problem); |
|
||||||
loadProblems(); |
|
||||||
} |
|
||||||
} |
|
@ -1,257 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:image_picker/image_picker.dart'; |
|
||||||
import 'package:path/path.dart' as path; |
|
||||||
import 'package:permission_handler/permission_handler.dart'; |
|
||||||
import 'package:path_provider/path_provider.dart'; |
|
||||||
import 'package:problem_check_system/data/models/image_status.dart'; |
|
||||||
import 'package:problem_check_system/data/models/image_metadata_model.dart'; |
|
||||||
import 'dart:io'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_sync_status.dart'; |
|
||||||
import 'package:problem_check_system/data/repositories/problem_repository.dart'; |
|
||||||
|
|
||||||
class ProblemFormController extends GetxController { |
|
||||||
final Problem? problem; |
|
||||||
final bool isReadOnly; |
|
||||||
|
|
||||||
final ProblemRepository problemRepository; |
|
||||||
final TextEditingController descriptionController = TextEditingController(); |
|
||||||
final TextEditingController locationController = TextEditingController(); |
|
||||||
final RxList<XFile> selectedImages = <XFile>[].obs; |
|
||||||
final RxBool isLoading = false.obs; |
|
||||||
|
|
||||||
// 使用依赖注入,便于测试 |
|
||||||
ProblemFormController({ |
|
||||||
required this.problemRepository, |
|
||||||
this.problem, |
|
||||||
this.isReadOnly = false, |
|
||||||
}) { |
|
||||||
if (problem != null) { |
|
||||||
descriptionController.text = problem!.description; |
|
||||||
locationController.text = problem!.location; |
|
||||||
final imagePaths = problem!.imageUrls |
|
||||||
.map((meta) => XFile(meta.localPath)) |
|
||||||
.toList(); |
|
||||||
selectedImages.assignAll(imagePaths); |
|
||||||
} else { |
|
||||||
descriptionController.clear(); |
|
||||||
locationController.clear(); |
|
||||||
selectedImages.clear(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 改进的 pickImage 方法 |
|
||||||
Future<void> pickImage(ImageSource source) async { |
|
||||||
try { |
|
||||||
PermissionStatus status; |
|
||||||
|
|
||||||
if (source == ImageSource.camera) { |
|
||||||
status = await Permission.camera.request(); |
|
||||||
} else { |
|
||||||
// 请求相册权限 |
|
||||||
status = await Permission.photos.request(); |
|
||||||
if (!status.isGranted) { |
|
||||||
// 兼容旧版本Android |
|
||||||
status = await Permission.storage.request(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (status.isGranted) { |
|
||||||
final ImagePicker picker = ImagePicker(); |
|
||||||
final XFile? image = await picker.pickImage( |
|
||||||
source: source, |
|
||||||
imageQuality: 85, // 压缩图片质量,减少存储空间 |
|
||||||
maxWidth: 1920, // 限制图片最大宽度 |
|
||||||
); |
|
||||||
|
|
||||||
if (image != null) { |
|
||||||
// 检查图片数量限制 |
|
||||||
if (selectedImages.length >= 10) { |
|
||||||
Get.snackbar('提示', '最多只能选择10张图片'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
selectedImages.add(image); |
|
||||||
} |
|
||||||
} else if (status.isPermanentlyDenied) { |
|
||||||
_showPermissionPermanentlyDeniedDialog(); |
|
||||||
} else { |
|
||||||
Get.snackbar('权限被拒绝', '需要相册权限才能选择图片'); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Get.snackbar('错误', '选择图片失败: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 显示权限被永久拒绝的对话框 |
|
||||||
void _showPermissionPermanentlyDeniedDialog() { |
|
||||||
Get.dialog( |
|
||||||
AlertDialog( |
|
||||||
title: const Text('权限被永久拒绝'), |
|
||||||
content: const Text('需要相册权限来选择图片。请前往设置开启。'), |
|
||||||
actions: [ |
|
||||||
TextButton(onPressed: () => Get.back(), child: const Text('取消')), |
|
||||||
TextButton( |
|
||||||
onPressed: () async { |
|
||||||
Get.back(); |
|
||||||
await openAppSettings(); |
|
||||||
}, |
|
||||||
child: const Text('去设置'), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// 移除图片 |
|
||||||
void removeImage(int index) { |
|
||||||
selectedImages.removeAt(index); |
|
||||||
} |
|
||||||
|
|
||||||
// 验证表单 |
|
||||||
bool _validateForm() { |
|
||||||
if (descriptionController.text.isEmpty) { |
|
||||||
Get.snackbar('提示', '请填写问题描述', snackPosition: SnackPosition.TOP); |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
if (locationController.text.isEmpty) { |
|
||||||
Get.snackbar('提示', '请填写问题位置', snackPosition: SnackPosition.TOP); |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
if (selectedImages.isEmpty) { |
|
||||||
Get.snackbar('提示', '请至少上传一张图片', snackPosition: SnackPosition.TOP); |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
return true; |
|
||||||
} |
|
||||||
|
|
||||||
/// 保存图片 |
|
||||||
Future<void> saveProblem() async { |
|
||||||
if (!_validateForm()) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
isLoading.value = true; |
|
||||||
|
|
||||||
try { |
|
||||||
// 保存图片到本地 |
|
||||||
final List<ImageMetadata> imagePaths = await _saveImagesToLocal(); |
|
||||||
|
|
||||||
if (problem != null) { |
|
||||||
// 修改问题 |
|
||||||
final updatedProblem = problem!.copyWith( |
|
||||||
description: descriptionController.text, |
|
||||||
location: locationController.text, |
|
||||||
imageUrls: imagePaths, |
|
||||||
); |
|
||||||
// 如果原问题是待创建的,修改后仍然应该是创建操作 |
|
||||||
final modifyProblem = ProblemStateManager.modifyProblem(updatedProblem); |
|
||||||
|
|
||||||
await problemRepository.updateProblem(modifyProblem); |
|
||||||
} else { |
|
||||||
// 创建新问题 |
|
||||||
final newProblem = ProblemStateManager.createNewProblem( |
|
||||||
description: descriptionController.text, |
|
||||||
location: locationController.text, |
|
||||||
imageUrls: imagePaths, |
|
||||||
); |
|
||||||
|
|
||||||
await problemRepository.insertProblem(newProblem); |
|
||||||
} |
|
||||||
Get.back(result: true); // 返回成功结果 |
|
||||||
Get.snackbar('成功', '问题已更新'); |
|
||||||
} catch (e) { |
|
||||||
Get.snackbar('错误', '保存问题失败: $e'); |
|
||||||
} finally { |
|
||||||
isLoading.value = false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 保存图片到本地存储 |
|
||||||
Future<List<ImageMetadata>> _saveImagesToLocal() async { |
|
||||||
final List<ImageMetadata> imagePaths = []; |
|
||||||
final directory = await getApplicationDocumentsDirectory(); |
|
||||||
final imagesDir = Directory('${directory.path}/problem_images'); |
|
||||||
|
|
||||||
// 确保目录存在 |
|
||||||
if (!await imagesDir.exists()) { |
|
||||||
await imagesDir.create(recursive: true); |
|
||||||
} |
|
||||||
|
|
||||||
for (var image in selectedImages) { |
|
||||||
try { |
|
||||||
final String fileName = |
|
||||||
'problem_${DateTime.now().millisecondsSinceEpoch}_${path.basename(image.name)}'; |
|
||||||
final String imagePath = '${imagesDir.path}/$fileName'; |
|
||||||
final ImageMetadata imageData = ImageMetadata( |
|
||||||
localPath: imagePath, |
|
||||||
status: ImageStatus.pendingUpload, |
|
||||||
); |
|
||||||
final File imageFile = File(imagePath); |
|
||||||
|
|
||||||
// 读取图片字节并写入文件 |
|
||||||
final imageBytes = await image.readAsBytes(); |
|
||||||
await imageFile.writeAsBytes(imageBytes); |
|
||||||
|
|
||||||
imagePaths.add(imageData); |
|
||||||
} catch (e) { |
|
||||||
throw Exception(e); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return imagePaths; |
|
||||||
} |
|
||||||
|
|
||||||
// 取消编辑/新增 |
|
||||||
void cancel() { |
|
||||||
// 如果是编辑模式且没有修改,直接返回 |
|
||||||
final hasChanges = _hasFormChanges(); |
|
||||||
|
|
||||||
if (hasChanges) { |
|
||||||
Get.dialog( |
|
||||||
AlertDialog( |
|
||||||
title: const Text('放弃编辑'), |
|
||||||
content: const Text('您有未保存的更改,确定要放弃吗?'), |
|
||||||
actions: [ |
|
||||||
TextButton(onPressed: () => Get.back(), child: const Text('取消')), |
|
||||||
TextButton( |
|
||||||
onPressed: () { |
|
||||||
Get.back(); |
|
||||||
Get.back(result: false); // 返回失败结果 |
|
||||||
}, |
|
||||||
child: const Text('确定'), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} else { |
|
||||||
Get.back(result: false); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 检查表单是否有更改 |
|
||||||
bool _hasFormChanges() { |
|
||||||
if (problem == null) { |
|
||||||
// 新增模式:只要有内容就是有更改 |
|
||||||
return descriptionController.text.isNotEmpty || |
|
||||||
locationController.text.isNotEmpty || |
|
||||||
selectedImages.isNotEmpty; |
|
||||||
} |
|
||||||
|
|
||||||
// 编辑模式:检查与原始值的差异 |
|
||||||
return descriptionController.text != problem!.description || |
|
||||||
locationController.text != problem!.location || |
|
||||||
selectedImages.length != problem!.imageUrls.length; |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void onClose() { |
|
||||||
descriptionController.dispose(); |
|
||||||
locationController.dispose(); |
|
||||||
super.onClose(); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,139 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||||
|
import 'package:get/get.dart'; |
||||||
|
import 'package:problem_check_system/modules/problem/custom_button.dart'; |
||||||
|
import 'package:tdesign_flutter/tdesign_flutter.dart'; |
||||||
|
|
||||||
|
class ProblemCard extends StatelessWidget { |
||||||
|
final bool initialBound; |
||||||
|
final bool initialUploaded; |
||||||
|
final ProblemCardController controller; |
||||||
|
|
||||||
|
ProblemCard({ |
||||||
|
super.key, |
||||||
|
required this.initialBound, |
||||||
|
required this.initialUploaded, |
||||||
|
}) : controller = ProblemCardController( |
||||||
|
initialBound: initialBound, |
||||||
|
initialUploaded: initialUploaded, |
||||||
|
); |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Card( |
||||||
|
margin: EdgeInsets.symmetric(vertical: 5.h, horizontal: 9.w), |
||||||
|
child: Column( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: <Widget>[ |
||||||
|
ListTile( |
||||||
|
leading: Image.asset( |
||||||
|
'assets/images/problem_preview.png', |
||||||
|
fit: BoxFit.contain, // 防止图片拉伸 |
||||||
|
), |
||||||
|
title: Text( |
||||||
|
'问题描述', |
||||||
|
style: TextStyle(fontSize: 16.sp), // 动态文字大小 |
||||||
|
), |
||||||
|
subtitle: LayoutBuilder( |
||||||
|
builder: (context, constraints) { |
||||||
|
return Text( |
||||||
|
'硫磺库内南侧地面上存放了阀门、消防水带等物品;12#库存放了脱模剂、愈合成催化剂省略字省略字省略字省略字...', |
||||||
|
maxLines: 2, |
||||||
|
overflow: TextOverflow.ellipsis, |
||||||
|
style: TextStyle(fontSize: 14.sp), // 适配设备 |
||||||
|
); |
||||||
|
}, |
||||||
|
), |
||||||
|
), |
||||||
|
SizedBox(height: 8.h), |
||||||
|
Row( |
||||||
|
children: [ |
||||||
|
SizedBox(width: 16.w), |
||||||
|
Row( |
||||||
|
children: [ |
||||||
|
Icon(Icons.location_on, color: Colors.grey, size: 16.h), |
||||||
|
SizedBox(width: 8.w), |
||||||
|
Text( |
||||||
|
'汽车机厂房作业区1-C', // 替换为实际文本 |
||||||
|
style: TextStyle(fontSize: 12.sp), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
SizedBox(width: 16.w), |
||||||
|
Row( |
||||||
|
children: [ |
||||||
|
Icon(Icons.access_time, color: Colors.grey, size: 16.h), |
||||||
|
SizedBox(width: 8.w), |
||||||
|
Text( |
||||||
|
'2025-07-31 15:30:29', // 替换为实际时间文本 |
||||||
|
style: TextStyle(fontSize: 12.sp), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
SizedBox(height: 8.h), |
||||||
|
Row( |
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||||
|
children: <Widget>[ |
||||||
|
SizedBox(width: 16.w), |
||||||
|
Obx( |
||||||
|
() => Wrap( |
||||||
|
spacing: 8, |
||||||
|
children: [ |
||||||
|
controller.isUploaded.value |
||||||
|
? TDTag('已上传', isLight: true, theme: TDTagTheme.success) |
||||||
|
: TDTag('未上传', isLight: true, theme: TDTagTheme.danger), |
||||||
|
controller.isBound.value |
||||||
|
? TDTag('已绑定', isLight: true, theme: TDTagTheme.primary) |
||||||
|
: TDTag( |
||||||
|
'未绑定', |
||||||
|
isLight: true, |
||||||
|
theme: TDTagTheme.warning, |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
SizedBox(width: 100.w), |
||||||
|
Row( |
||||||
|
children: [ |
||||||
|
CustomButton( |
||||||
|
text: '修改', // 按钮上的文字 |
||||||
|
onTap: () { |
||||||
|
// 点击事件逻辑 |
||||||
|
print('点击修改按钮'); |
||||||
|
}, |
||||||
|
), |
||||||
|
SizedBox(width: 8.w), |
||||||
|
CustomButton( |
||||||
|
text: '查看', // 按钮上的文字 |
||||||
|
onTap: () { |
||||||
|
// 点击事件逻辑 |
||||||
|
print('点击查看按钮'); |
||||||
|
}, |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class ProblemCardController extends GetxController { |
||||||
|
// 是否绑定 |
||||||
|
var isBound = false.obs; |
||||||
|
// 是否上传 |
||||||
|
var isUploaded = false.obs; |
||||||
|
|
||||||
|
// 构造函数传入参数 |
||||||
|
ProblemCardController({ |
||||||
|
bool initialBound = false, |
||||||
|
bool initialUploaded = false, |
||||||
|
}) { |
||||||
|
isBound.value = initialBound; |
||||||
|
isUploaded.value = initialUploaded; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
// import 'package:flutter/material.dart'; |
||||||
|
// import 'package:get/get.dart'; |
||||||
|
// import 'package:tdesign_flutter/tdesign_flutter.dart'; |
||||||
|
|
||||||
|
// class DatePickerController extends GetxController { |
||||||
|
// var selectedDateTime = ''.obs; // Reactive variable for selected date-time |
||||||
|
|
||||||
|
// void updateDateTime(Map<String, int> selected) { |
||||||
|
// selectedDateTime.value = |
||||||
|
// '${selected['year'].toString().padLeft(4, '0')}-' |
||||||
|
// '${selected['month'].toString().padLeft(2, '0')}-' |
||||||
|
// '${selected['day'].toString().padLeft(2, '0')} ' |
||||||
|
// '${selected['hour'].toString().padLeft(2, '0')}:' |
||||||
|
// '${selected['minute'].toString().padLeft(2, '0')}:' |
||||||
|
// '${selected['second'].toString().padLeft(2, '0')}'; |
||||||
|
// } |
||||||
|
// } |
||||||
|
|
||||||
|
// class DatePickerScreen extends StatelessWidget { |
||||||
|
|
||||||
|
// DatePickerScreen({super.key}); |
||||||
|
|
||||||
|
// @override |
||||||
|
// Widget build(BuildContext context) { |
||||||
|
// return GestureDetector( |
||||||
|
// onTap: () {}, |
||||||
|
// child: Obx( |
||||||
|
// () => buildSelectRow( |
||||||
|
// context, |
||||||
|
// dateController.selectedDateTime.value, |
||||||
|
// '选择时间', |
||||||
|
// ), |
||||||
|
// ), |
||||||
|
// ); |
||||||
|
// } |
||||||
|
|
||||||
|
// Widget buildSelectRow(BuildContext context, String selected, String title) { |
||||||
|
// return ListTile( |
||||||
|
// title: Text(title), |
||||||
|
// subtitle: Text(selected.isNotEmpty ? selected : '未选择'), |
||||||
|
// ); |
||||||
|
// } |
||||||
|
// } |
@ -0,0 +1,145 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||||
|
import 'package:problem_check_system/modules/problem/components/date_picker_button.dart'; |
||||||
|
import 'package:problem_check_system/modules/problem/problem_card.dart'; |
||||||
|
|
||||||
|
class ProblemPage extends StatelessWidget { |
||||||
|
ProblemPage({super.key}); |
||||||
|
|
||||||
|
final List<Map<String, bool>> problemData = [ |
||||||
|
{"initialBound": false, "initialUploaded": false}, |
||||||
|
{"initialBound": true, "initialUploaded": true}, |
||||||
|
{"initialBound": true, "initialUploaded": false}, |
||||||
|
{"initialBound": false, "initialUploaded": false}, |
||||||
|
{"initialBound": true, "initialUploaded": true}, |
||||||
|
{"initialBound": true, "initialUploaded": false}, |
||||||
|
{"initialBound": false, "initialUploaded": false}, |
||||||
|
{"initialBound": true, "initialUploaded": true}, |
||||||
|
{"initialBound": true, "initialUploaded": false}, |
||||||
|
{"initialBound": false, "initialUploaded": false}, |
||||||
|
{"initialBound": true, "initialUploaded": true}, |
||||||
|
{"initialBound": true, "initialUploaded": false}, |
||||||
|
]; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return DefaultTabController( |
||||||
|
initialIndex: 0, |
||||||
|
length: 2, |
||||||
|
child: Scaffold( |
||||||
|
body: ConstrainedBox( |
||||||
|
constraints: BoxConstraints(maxHeight: 812.h), |
||||||
|
child: Column( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
Container( |
||||||
|
width: 375.w, |
||||||
|
height: 83.5.h, |
||||||
|
alignment: Alignment.bottomLeft, |
||||||
|
decoration: const BoxDecoration( |
||||||
|
gradient: LinearGradient( |
||||||
|
begin: Alignment.centerLeft, // 从左开始 |
||||||
|
end: Alignment.centerRight, // 到右结束 |
||||||
|
colors: [ |
||||||
|
Color(0xFF418CFC), // 左侧颜色 |
||||||
|
Color(0xFF3DBFFC), // 右侧颜色 |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
child: TabBar( |
||||||
|
indicatorSize: TabBarIndicatorSize.tab, |
||||||
|
indicator: BoxDecoration( |
||||||
|
// border: const Border( |
||||||
|
// bottom: BorderSide(color: Colors.blue, width: 5.0), |
||||||
|
// ), |
||||||
|
color: const Color(0xfffff7f7), |
||||||
|
borderRadius: BorderRadius.only( |
||||||
|
topLeft: Radius.circular(8), |
||||||
|
topRight: Radius.circular(60), |
||||||
|
), |
||||||
|
), |
||||||
|
tabs: const [ |
||||||
|
Tab(text: '问题列表'), |
||||||
|
Tab(text: '历史问题列表'), |
||||||
|
], |
||||||
|
labelStyle: TextStyle( |
||||||
|
fontFamily: 'MyFont', // 字体名称 |
||||||
|
fontWeight: FontWeight.w800, // 字重 |
||||||
|
fontSize: 14.sp, // 字体大小 |
||||||
|
), |
||||||
|
unselectedLabelStyle: TextStyle( |
||||||
|
fontFamily: 'MyFont', |
||||||
|
fontWeight: FontWeight.w800, |
||||||
|
fontSize: 14.sp, |
||||||
|
), |
||||||
|
labelColor: Colors.black, // 选中文字颜色 |
||||||
|
unselectedLabelColor: Colors.white, // 未选中文字颜色 |
||||||
|
), |
||||||
|
), |
||||||
|
Expanded( |
||||||
|
child: TabBarView( |
||||||
|
children: [ |
||||||
|
Column( |
||||||
|
children: [ |
||||||
|
Container( |
||||||
|
margin: EdgeInsets.all(16), |
||||||
|
child: Row( |
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||||
|
children: [DatePickerButton(), DatePickerButton()], |
||||||
|
), |
||||||
|
), |
||||||
|
Expanded( |
||||||
|
child: Stack( |
||||||
|
children: [ |
||||||
|
SingleChildScrollView( |
||||||
|
child: Column( |
||||||
|
children: [ |
||||||
|
...problemData.map((data) { |
||||||
|
return ProblemCard( |
||||||
|
initialBound: |
||||||
|
data["initialBound"] ?? false, |
||||||
|
initialUploaded: |
||||||
|
data["initialUploaded"] ?? false, |
||||||
|
); |
||||||
|
}), |
||||||
|
SizedBox(height: 64.h), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
Positioned( |
||||||
|
bottom: 5.h, |
||||||
|
right: 160.5.w, |
||||||
|
child: FloatingActionButton( |
||||||
|
onPressed: () { |
||||||
|
print('object'); |
||||||
|
}, |
||||||
|
shape: CircleBorder(), |
||||||
|
backgroundColor: Colors.blue[300], |
||||||
|
foregroundColor: Colors.white, |
||||||
|
child: const Icon(Icons.add), |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
ProblemCard(initialBound: false, initialUploaded: false), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
floatingActionButton: FloatingActionButton( |
||||||
|
onPressed: () { |
||||||
|
print('object'); |
||||||
|
}, |
||||||
|
foregroundColor: Colors.white, |
||||||
|
backgroundColor: Colors.red[300], |
||||||
|
child: const Icon(Icons.file_upload_outlined), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -1,343 +0,0 @@ |
|||||||
import 'dart:io'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:image_picker/image_picker.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_form_controller.dart'; |
|
||||||
|
|
||||||
class ProblemFormPage extends GetView<ProblemFormController> { |
|
||||||
// 构造函数,接收只读标志 |
|
||||||
const ProblemFormPage({super.key}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Scaffold( |
|
||||||
appBar: AppBar( |
|
||||||
flexibleSpace: Container( |
|
||||||
decoration: const BoxDecoration( |
|
||||||
gradient: LinearGradient( |
|
||||||
colors: [Color(0xFF418CFC), Color(0xFF3DBFFC)], |
|
||||||
begin: Alignment.centerLeft, |
|
||||||
end: Alignment.centerRight, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
leading: IconButton( |
|
||||||
icon: const Icon( |
|
||||||
Icons.arrow_back_ios_new_rounded, |
|
||||||
color: Colors.white, |
|
||||||
), |
|
||||||
onPressed: () { |
|
||||||
Navigator.pop(context); |
|
||||||
}, |
|
||||||
), |
|
||||||
// 根据只读模式和问题对象动态设置标题 |
|
||||||
title: Text( |
|
||||||
controller.isReadOnly |
|
||||||
? '问题详情' |
|
||||||
: (controller.problem == null ? '新增问题' : '编辑问题'), |
|
||||||
style: const TextStyle(color: Colors.white), |
|
||||||
), |
|
||||||
centerTitle: true, |
|
||||||
backgroundColor: Colors.transparent, |
|
||||||
), |
|
||||||
body: Column( |
|
||||||
children: [ |
|
||||||
Expanded( |
|
||||||
child: SingleChildScrollView( |
|
||||||
child: Column( |
|
||||||
children: [ |
|
||||||
_buildInputCard( |
|
||||||
title: '问题描述', |
|
||||||
textController: controller.descriptionController, |
|
||||||
hintText: '请输入问题描述', |
|
||||||
), |
|
||||||
_buildInputCard( |
|
||||||
title: '所在位置', |
|
||||||
textController: controller.locationController, |
|
||||||
hintText: '请输入问题所在位置', |
|
||||||
), |
|
||||||
_buildImageCard(context), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
// 只有在非只读模式下才显示底部操作按钮 |
|
||||||
if (!controller.isReadOnly) _bottomButton(), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建输入框卡片 |
|
||||||
Widget _buildInputCard({ |
|
||||||
required String title, |
|
||||||
required TextEditingController textController, |
|
||||||
required String hintText, |
|
||||||
}) { |
|
||||||
return Card( |
|
||||||
margin: EdgeInsets.all(17.w), |
|
||||||
child: Column( |
|
||||||
children: [ |
|
||||||
ListTile( |
|
||||||
title: Text( |
|
||||||
title, |
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), |
|
||||||
), |
|
||||||
), |
|
||||||
Padding( |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|
||||||
child: TextField( |
|
||||||
maxLines: null, |
|
||||||
controller: textController, |
|
||||||
readOnly: controller.isReadOnly, // 关键:根据只读标志设置可编辑性 |
|
||||||
decoration: InputDecoration( |
|
||||||
hintText: hintText, |
|
||||||
border: InputBorder.none, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建图片展示卡片 |
|
||||||
Widget _buildImageCard(BuildContext context) { |
|
||||||
return Card( |
|
||||||
margin: EdgeInsets.all(17.w), |
|
||||||
child: Column( |
|
||||||
children: [ |
|
||||||
const ListTile( |
|
||||||
title: Text( |
|
||||||
'问题图片', |
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), |
|
||||||
), |
|
||||||
), |
|
||||||
Padding( |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|
||||||
child: _buildImageGrid(context), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建图片网格,包含添加按钮和图片项 |
|
||||||
Widget _buildImageGrid(BuildContext context) { |
|
||||||
return Obx(() { |
|
||||||
// 在只读模式下不显示添加按钮 |
|
||||||
final bool showAddButton = !controller.isReadOnly; |
|
||||||
final int itemCount = |
|
||||||
controller.selectedImages.length + (showAddButton ? 1 : 0); |
|
||||||
|
|
||||||
return GridView.builder( |
|
||||||
shrinkWrap: true, |
|
||||||
physics: const NeverScrollableScrollPhysics(), |
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( |
|
||||||
crossAxisCount: 3, |
|
||||||
crossAxisSpacing: 8.w, |
|
||||||
mainAxisSpacing: 8.h, |
|
||||||
childAspectRatio: 1, |
|
||||||
), |
|
||||||
itemCount: itemCount, |
|
||||||
itemBuilder: (context, index) { |
|
||||||
if (showAddButton && index == controller.selectedImages.length) { |
|
||||||
return _buildAddImageButton(context); |
|
||||||
} |
|
||||||
return _buildImageItem(index); |
|
||||||
}, |
|
||||||
); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建添加图片按钮 |
|
||||||
Widget _buildAddImageButton(BuildContext context) { |
|
||||||
return InkWell( |
|
||||||
onTap: () => _showImageSourceBottomSheet(context), |
|
||||||
child: Container( |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.grey.shade100, |
|
||||||
borderRadius: BorderRadius.circular(8), |
|
||||||
border: Border.all(color: Colors.grey.shade300, width: 1), |
|
||||||
), |
|
||||||
child: Column( |
|
||||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||||
children: [ |
|
||||||
Icon( |
|
||||||
Icons.add_photo_alternate, |
|
||||||
size: 24.sp, |
|
||||||
color: Colors.grey.shade600, |
|
||||||
), |
|
||||||
SizedBox(height: 4.h), |
|
||||||
Text( |
|
||||||
'添加图片', |
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12.sp), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建单个图片项 |
|
||||||
Widget _buildImageItem(int index) { |
|
||||||
return Container( |
|
||||||
decoration: BoxDecoration( |
|
||||||
borderRadius: BorderRadius.circular(8), |
|
||||||
border: Border.all(color: Colors.grey.shade300), |
|
||||||
), |
|
||||||
child: Stack( |
|
||||||
children: [ |
|
||||||
ClipRRect( |
|
||||||
borderRadius: BorderRadius.circular(8), |
|
||||||
child: Image.file( |
|
||||||
File(controller.selectedImages[index].path), |
|
||||||
width: double.infinity, |
|
||||||
height: double.infinity, |
|
||||||
fit: BoxFit.cover, |
|
||||||
), |
|
||||||
), |
|
||||||
// 只有在非只读模式下才显示删除按钮 |
|
||||||
if (!controller.isReadOnly) |
|
||||||
Positioned( |
|
||||||
top: 0, |
|
||||||
right: 0, |
|
||||||
child: GestureDetector( |
|
||||||
onTap: () => controller.removeImage(index), |
|
||||||
child: Container( |
|
||||||
decoration: const BoxDecoration( |
|
||||||
color: Colors.black54, |
|
||||||
shape: BoxShape.circle, |
|
||||||
), |
|
||||||
padding: EdgeInsets.all(4.w), |
|
||||||
child: Icon(Icons.close, color: Colors.white, size: 16.sp), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 显示底部图片来源选择器 |
|
||||||
void _showImageSourceBottomSheet(BuildContext context) { |
|
||||||
showModalBottomSheet( |
|
||||||
context: context, |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), |
|
||||||
), |
|
||||||
builder: (BuildContext context) { |
|
||||||
return SafeArea( |
|
||||||
child: Column( |
|
||||||
mainAxisSize: MainAxisSize.min, |
|
||||||
children: [ |
|
||||||
// ... (省略 showModalBottomSheet 内部代码,与原代码相同) |
|
||||||
SizedBox(height: 16.h), |
|
||||||
Text( |
|
||||||
'选择图片来源', |
|
||||||
style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold), |
|
||||||
), |
|
||||||
SizedBox(height: 16.h), |
|
||||||
Divider(height: 1.h), |
|
||||||
ListTile( |
|
||||||
leading: const Icon(Icons.camera_alt, color: Colors.blue), |
|
||||||
title: const Text('拍照'), |
|
||||||
onTap: () { |
|
||||||
Navigator.pop(context); |
|
||||||
controller.pickImage(ImageSource.camera); |
|
||||||
}, |
|
||||||
), |
|
||||||
const Divider(height: 1), |
|
||||||
ListTile( |
|
||||||
leading: const Icon(Icons.photo_library, color: Colors.blue), |
|
||||||
title: const Text('从相册选择'), |
|
||||||
onTap: () { |
|
||||||
Navigator.pop(context); |
|
||||||
controller.pickImage(ImageSource.gallery); |
|
||||||
}, |
|
||||||
), |
|
||||||
const Divider(height: 1), |
|
||||||
ListTile( |
|
||||||
leading: const Icon(Icons.cancel, color: Colors.grey), |
|
||||||
title: const Text('取消', style: TextStyle(color: Colors.grey)), |
|
||||||
onTap: () => Navigator.pop(context), |
|
||||||
), |
|
||||||
SizedBox(height: 8.h), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
}, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建底部操作按钮 |
|
||||||
Widget _bottomButton() { |
|
||||||
return Container( |
|
||||||
width: 375.w, |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.grey[200], |
|
||||||
borderRadius: BorderRadius.only( |
|
||||||
topLeft: Radius.circular(12.r), |
|
||||||
topRight: Radius.circular(12.r), |
|
||||||
), |
|
||||||
), |
|
||||||
child: Row( |
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||||
children: [ |
|
||||||
Expanded( |
|
||||||
child: ElevatedButton( |
|
||||||
style: ElevatedButton.styleFrom( |
|
||||||
backgroundColor: Colors.white, |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|
||||||
), |
|
||||||
onPressed: () { |
|
||||||
Get.back(); |
|
||||||
}, |
|
||||||
child: Text( |
|
||||||
'取消', |
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 16.sp), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(width: 10.w), |
|
||||||
Expanded( |
|
||||||
child: Obx( |
|
||||||
() => ElevatedButton( |
|
||||||
style: ElevatedButton.styleFrom( |
|
||||||
backgroundColor: const Color(0xFF418CFC), |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|
||||||
), |
|
||||||
onPressed: controller.isLoading.value |
|
||||||
? null |
|
||||||
: controller.saveProblem, |
|
||||||
child: controller.isLoading.value |
|
||||||
? SizedBox( |
|
||||||
width: 20.w, |
|
||||||
height: 20.h, |
|
||||||
child: const CircularProgressIndicator( |
|
||||||
strokeWidth: 2, |
|
||||||
valueColor: AlwaysStoppedAnimation<Color>( |
|
||||||
Colors.white, |
|
||||||
), |
|
||||||
), |
|
||||||
) |
|
||||||
: Text( |
|
||||||
'确定', |
|
||||||
style: TextStyle(color: Colors.white, fontSize: 16.sp), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,177 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:easy_refresh/easy_refresh.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_sync_status.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/problem_card.dart'; |
|
||||||
|
|
||||||
class ProblemListPage extends GetView<ProblemController> { |
|
||||||
final RxList<Problem> problemsToShow; |
|
||||||
final ProblemCardViewType viewType; |
|
||||||
|
|
||||||
const ProblemListPage({ |
|
||||||
super.key, |
|
||||||
required this.problemsToShow, |
|
||||||
this.viewType = ProblemCardViewType.buttons, |
|
||||||
}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Obx(() { |
|
||||||
if (controller.isLoading.value) { |
|
||||||
return const Center(child: CircularProgressIndicator()); |
|
||||||
} |
|
||||||
|
|
||||||
return EasyRefresh( |
|
||||||
header: ClassicHeader( |
|
||||||
dragText: '下拉刷新'.tr, |
|
||||||
armedText: '释放开始'.tr, |
|
||||||
readyText: '刷新中...'.tr, |
|
||||||
processingText: '刷新中...'.tr, |
|
||||||
processedText: '成功了'.tr, |
|
||||||
noMoreText: 'No more'.tr, |
|
||||||
failedText: '失败'.tr, |
|
||||||
messageText: '最后更新于 %T'.tr, |
|
||||||
), |
|
||||||
onRefresh: () async { |
|
||||||
// 调用控制器的刷新方法 |
|
||||||
await controller.pullDataFromServer(); |
|
||||||
}, |
|
||||||
child: ListView.builder( |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 17.w), |
|
||||||
itemCount: problemsToShow.length, |
|
||||||
itemBuilder: (context, index) { |
|
||||||
// if (index == problemsToShow.length) { |
|
||||||
// return SizedBox(height: 79.5.h); |
|
||||||
// } |
|
||||||
final problem = problemsToShow[index]; |
|
||||||
return _buildSwipeableProblemCard(problem); |
|
||||||
}, |
|
||||||
), |
|
||||||
); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildSwipeableProblemCard(Problem problem) { |
|
||||||
// 对于所有视图类型,如果是待删除状态,都禁用交互 |
|
||||||
final bool isPendingDelete = |
|
||||||
problem.syncStatus == ProblemSyncStatus.pendingDelete; |
|
||||||
|
|
||||||
if (viewType == ProblemCardViewType.buttons) { |
|
||||||
// buttons 视图类型:有条件启用滑动删除 |
|
||||||
if (!isPendingDelete) { |
|
||||||
// 非待删除状态:启用滑动删除 |
|
||||||
return Dismissible( |
|
||||||
key: ValueKey('${problem.id}-${problem.syncStatus}'), |
|
||||||
direction: DismissDirection.endToStart, |
|
||||||
background: Container( |
|
||||||
color: Colors.red, |
|
||||||
alignment: Alignment.centerRight, |
|
||||||
padding: EdgeInsets.only(right: 20.w), |
|
||||||
child: Icon(Icons.delete, color: Colors.white, size: 30.sp), |
|
||||||
), |
|
||||||
confirmDismiss: (direction) async { |
|
||||||
return await _showDeleteConfirmationDialog(problem); |
|
||||||
}, |
|
||||||
onDismissed: (direction) { |
|
||||||
controller.deleteProblem(problem); |
|
||||||
Get.snackbar('成功', '问题已删除'); |
|
||||||
}, |
|
||||||
child: ProblemCard( |
|
||||||
key: ValueKey(problem.id), |
|
||||||
problem: problem, |
|
||||||
viewType: viewType, |
|
||||||
isSelected: false, |
|
||||||
), |
|
||||||
); |
|
||||||
} else { |
|
||||||
// 待删除状态:显示普通卡片(无滑动功能) |
|
||||||
return ProblemCard( |
|
||||||
key: ValueKey(problem.id), |
|
||||||
problem: problem, |
|
||||||
viewType: viewType, |
|
||||||
isSelected: false, |
|
||||||
); |
|
||||||
} |
|
||||||
} else { |
|
||||||
// 其他视图类型(list、grid等):使用 Obx 监听选中状态 |
|
||||||
return Obx(() { |
|
||||||
final isSelected = controller.selectedProblems.contains(problem); |
|
||||||
return ProblemCard( |
|
||||||
key: ValueKey(problem.id), |
|
||||||
problem: problem, |
|
||||||
viewType: viewType, |
|
||||||
isSelected: isSelected, |
|
||||||
onChanged: (problem, isChecked) { |
|
||||||
controller.updateProblemSelection(problem, isChecked); |
|
||||||
}, |
|
||||||
); |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Future<bool> _showDeleteConfirmationDialog(Problem problem) async { |
|
||||||
return await Get.bottomSheet<bool>( |
|
||||||
Container( |
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0), |
|
||||||
decoration: const BoxDecoration( |
|
||||||
color: Colors.white, |
|
||||||
borderRadius: BorderRadius.only( |
|
||||||
topLeft: Radius.circular(16), |
|
||||||
topRight: Radius.circular(16), |
|
||||||
), |
|
||||||
), |
|
||||||
child: SafeArea( |
|
||||||
child: Column( |
|
||||||
mainAxisSize: MainAxisSize.min, |
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||||
children: [ |
|
||||||
const SizedBox(height: 16), |
|
||||||
const Text( |
|
||||||
'确认删除', |
|
||||||
textAlign: TextAlign.center, |
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), |
|
||||||
), |
|
||||||
const SizedBox(height: 8), |
|
||||||
Text( |
|
||||||
'确定要删除这个问题吗?此操作不可撤销。', |
|
||||||
textAlign: TextAlign.center, |
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]), |
|
||||||
), |
|
||||||
const SizedBox(height: 24), |
|
||||||
ElevatedButton( |
|
||||||
onPressed: () => Get.back(result: true), |
|
||||||
style: ElevatedButton.styleFrom( |
|
||||||
backgroundColor: Colors.red, |
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16), |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(10), |
|
||||||
), |
|
||||||
), |
|
||||||
child: const Text( |
|
||||||
'删除', |
|
||||||
style: TextStyle(color: Colors.white, fontSize: 16), |
|
||||||
), |
|
||||||
), |
|
||||||
const SizedBox(height: 8), |
|
||||||
TextButton( |
|
||||||
onPressed: () => Get.back(result: false), |
|
||||||
style: TextButton.styleFrom( |
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16), |
|
||||||
), |
|
||||||
child: Text( |
|
||||||
'取消', |
|
||||||
style: TextStyle(color: Colors.grey[700], fontSize: 16), |
|
||||||
), |
|
||||||
), |
|
||||||
const SizedBox(height: 16), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
) ?? |
|
||||||
false; |
|
||||||
} |
|
||||||
} |
|
@ -1,165 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/problem_list_page.dart'; // 导入修正后的 ProblemListPage |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/current_filter_bar.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/history_filter_bar.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/problem_card.dart'; // 导入自定义下拉菜单 |
|
||||||
|
|
||||||
class ProblemPage extends GetView<ProblemController> { |
|
||||||
const ProblemPage({super.key}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return DefaultTabController( |
|
||||||
initialIndex: 0, |
|
||||||
length: 2, |
|
||||||
child: Scaffold( |
|
||||||
body: Column( |
|
||||||
mainAxisSize: MainAxisSize.min, |
|
||||||
children: [ |
|
||||||
Container( |
|
||||||
width: 375.w, |
|
||||||
height: 83.5.h, |
|
||||||
alignment: Alignment.bottomLeft, |
|
||||||
decoration: const BoxDecoration( |
|
||||||
gradient: LinearGradient( |
|
||||||
begin: Alignment.centerLeft, |
|
||||||
end: Alignment.centerRight, |
|
||||||
colors: [Color(0xFF418CFC), Color(0xFF3DBFFC)], |
|
||||||
), |
|
||||||
), |
|
||||||
child: TabBar( |
|
||||||
controller: controller.tabController, |
|
||||||
indicatorSize: TabBarIndicatorSize.tab, |
|
||||||
indicator: const BoxDecoration( |
|
||||||
color: Color(0xfff7f7f7), |
|
||||||
borderRadius: BorderRadius.only( |
|
||||||
topLeft: Radius.circular(8), |
|
||||||
topRight: Radius.circular(60), |
|
||||||
), |
|
||||||
), |
|
||||||
tabs: const [ |
|
||||||
Tab(text: '问题列表'), |
|
||||||
Tab(text: '历史问题列表'), |
|
||||||
], |
|
||||||
labelStyle: TextStyle( |
|
||||||
fontFamily: 'MyFont', |
|
||||||
fontWeight: FontWeight.w800, |
|
||||||
fontSize: 14.sp, |
|
||||||
), |
|
||||||
unselectedLabelStyle: TextStyle( |
|
||||||
fontFamily: 'MyFont', |
|
||||||
fontWeight: FontWeight.w800, |
|
||||||
fontSize: 14.sp, |
|
||||||
), |
|
||||||
labelColor: Colors.black, |
|
||||||
unselectedLabelColor: Color(0xfff7f7f7), |
|
||||||
), |
|
||||||
), |
|
||||||
Expanded( |
|
||||||
child: TabBarView( |
|
||||||
controller: controller.tabController, |
|
||||||
children: [ |
|
||||||
// 问题列表 Tab |
|
||||||
DecoratedBox( |
|
||||||
decoration: BoxDecoration(color: Color(0xfff7f7f7)), |
|
||||||
child: Column( |
|
||||||
children: [ |
|
||||||
CurrentFilterBar(), |
|
||||||
Expanded( |
|
||||||
child: // 使用通用列表组件 |
|
||||||
ProblemListPage( |
|
||||||
problemsToShow: controller.problems, |
|
||||||
viewType: ProblemCardViewType.buttons, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
// 历史问题列表 Tab |
|
||||||
DecoratedBox( |
|
||||||
decoration: BoxDecoration(color: Color(0xfff7f7f7)), |
|
||||||
child: Column( |
|
||||||
children: [ |
|
||||||
HistoryFilterBar(), |
|
||||||
Expanded( |
|
||||||
child: // 使用通用列表组件 |
|
||||||
ProblemListPage( |
|
||||||
problemsToShow: controller.historyProblems, |
|
||||||
viewType: ProblemCardViewType.buttons, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
|
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, |
|
||||||
// 使用 Stack 统一管理所有浮动按钮 |
|
||||||
floatingActionButton: Stack( |
|
||||||
children: [ |
|
||||||
// 固定位置的 "添加" 按钮 |
|
||||||
// 使用 Align 和 Positioned |
|
||||||
Align( |
|
||||||
alignment: Alignment.bottomCenter, |
|
||||||
child: Padding( |
|
||||||
padding: EdgeInsets.only(bottom: 24.h), // 底部间距 |
|
||||||
child: FloatingActionButton( |
|
||||||
heroTag: "btn_add", |
|
||||||
onPressed: () { |
|
||||||
controller.toProblemFormPageAndRefresh(); |
|
||||||
}, |
|
||||||
shape: const CircleBorder(), |
|
||||||
backgroundColor: Colors.blue[300], |
|
||||||
foregroundColor: Colors.white, |
|
||||||
child: const Icon(Icons.add), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
|
|
||||||
// 可拖动的 "上传" 按钮 |
|
||||||
Obx(() { |
|
||||||
final isOnline = controller.isOnline.value; |
|
||||||
return Positioned( |
|
||||||
// 使用正确的坐标,left/right 对应 dx,top/bottom 对应 dy |
|
||||||
left: controller.fabUploadPosition.value.dx, |
|
||||||
top: controller.fabUploadPosition.value.dy, |
|
||||||
child: GestureDetector( |
|
||||||
onPanUpdate: (details) { |
|
||||||
// 调用控制器中的方法来更新位置 |
|
||||||
controller.updateFabUploadPosition(details.delta); |
|
||||||
}, |
|
||||||
onPanEnd: (details) { |
|
||||||
// 拖动结束后调用吸附方法 |
|
||||||
controller.snapToEdge(); |
|
||||||
}, |
|
||||||
child: FloatingActionButton( |
|
||||||
heroTag: "btn_upload", |
|
||||||
onPressed: isOnline |
|
||||||
? () => controller.showUploadPage() |
|
||||||
: null, |
|
||||||
foregroundColor: Colors.white, |
|
||||||
backgroundColor: isOnline |
|
||||||
? Colors.red[300] |
|
||||||
: Colors.grey[400], |
|
||||||
child: Icon( |
|
||||||
isOnline |
|
||||||
? Icons.file_upload_outlined |
|
||||||
: Icons.cloud_off_outlined, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
}), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,93 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/problem_list_page.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/problem_card.dart'; |
|
||||||
|
|
||||||
class ProblemUploadPage extends GetView<ProblemController> { |
|
||||||
const ProblemUploadPage({super.key}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Scaffold( |
|
||||||
appBar: _buildAppBar(), |
|
||||||
body: _buildBody(), |
|
||||||
bottomNavigationBar: _buildBottomBar(), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar() { |
|
||||||
return AppBar( |
|
||||||
title: Obx(() { |
|
||||||
final selectedCount = controller.selectedCount; |
|
||||||
return Text('已选择$selectedCount项'); |
|
||||||
}), |
|
||||||
centerTitle: true, |
|
||||||
// leading: IconButton( |
|
||||||
// icon: Icon(Icons.close), |
|
||||||
// onPressed: () { |
|
||||||
// Get.back(); |
|
||||||
// controller.loadProblems(); |
|
||||||
// }, |
|
||||||
// ), |
|
||||||
actions: [ |
|
||||||
Obx( |
|
||||||
() => TextButton( |
|
||||||
onPressed: controller.unUploadedProblems.isNotEmpty |
|
||||||
? controller.selectAll |
|
||||||
: null, |
|
||||||
child: Text( |
|
||||||
controller.allSelected.value ? '取消全选' : '全选', |
|
||||||
style: TextStyle( |
|
||||||
color: controller.unUploadedProblems.isNotEmpty |
|
||||||
? Colors.blue |
|
||||||
: Colors.grey, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildBody() { |
|
||||||
return Obx(() { |
|
||||||
if (controller.unUploadedProblems.isEmpty) { |
|
||||||
return Center( |
|
||||||
child: Text('暂无未上传的问题', style: TextStyle(fontSize: 16.sp)), |
|
||||||
); |
|
||||||
} |
|
||||||
return ProblemListPage( |
|
||||||
problemsToShow: controller.unUploadedProblems, |
|
||||||
viewType: ProblemCardViewType.checkbox, |
|
||||||
); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildBottomBar() { |
|
||||||
return Container( |
|
||||||
padding: EdgeInsets.all(16.w), |
|
||||||
decoration: BoxDecoration( |
|
||||||
color: Colors.white, |
|
||||||
border: Border(top: BorderSide(color: Colors.grey.shade300)), |
|
||||||
), |
|
||||||
child: Obx( |
|
||||||
() => ElevatedButton( |
|
||||||
onPressed: controller.selectedCount > 0 |
|
||||||
? controller.handleUpload |
|
||||||
: null, |
|
||||||
style: ElevatedButton.styleFrom( |
|
||||||
backgroundColor: Colors.blue, |
|
||||||
foregroundColor: Colors.white, |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
minimumSize: Size(double.infinity, 48.h), |
|
||||||
), |
|
||||||
child: Text('点击上传 (${controller.selectedCount})'), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,66 +0,0 @@ |
|||||||
// widgets/compact_filter_bar.dart |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
|
|
||||||
import 'custom_filter_dropdown.dart'; |
|
||||||
|
|
||||||
class CurrentFilterBar extends GetView<ProblemController> { |
|
||||||
final EdgeInsetsGeometry? padding; |
|
||||||
|
|
||||||
const CurrentFilterBar({super.key, this.padding}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Container( |
|
||||||
padding: padding ?? EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h), |
|
||||||
color: Colors.grey[50], |
|
||||||
child: Row( |
|
||||||
children: [ |
|
||||||
// 日期范围筛选 |
|
||||||
...[ |
|
||||||
Obx( |
|
||||||
() => CustomFilterDropdown( |
|
||||||
title: '时间范围', |
|
||||||
options: controller.dateRangeOptions, |
|
||||||
selectedValue: controller.currentDateRange.value.name, |
|
||||||
onChanged: controller.updateCurrentDateRange, |
|
||||||
width: 100.w, |
|
||||||
showBorder: false, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
|
|
||||||
// 上传状态筛选 |
|
||||||
...[ |
|
||||||
Obx( |
|
||||||
() => CustomFilterDropdown( |
|
||||||
title: '上传状态', |
|
||||||
options: controller.uploadOptions, |
|
||||||
selectedValue: controller.currentUploadFilter.value, |
|
||||||
onChanged: controller.updateCurrentUpload, |
|
||||||
width: 100.w, |
|
||||||
showBorder: false, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
|
|
||||||
// 绑定状态筛选 |
|
||||||
...[ |
|
||||||
Obx( |
|
||||||
() => CustomFilterDropdown( |
|
||||||
title: '绑定状态', |
|
||||||
options: controller.bindOptions, |
|
||||||
selectedValue: controller.currentBindFilter.value, |
|
||||||
onChanged: controller.updateCurrentBind, |
|
||||||
width: 100.w, |
|
||||||
showBorder: false, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,78 +0,0 @@ |
|||||||
// widgets/custom_filter_dropdown.dart |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/models/dropdown_option.dart'; |
|
||||||
|
|
||||||
class CustomFilterDropdown extends StatelessWidget { |
|
||||||
final String title; |
|
||||||
final List<DropdownOption> options; |
|
||||||
final String selectedValue; |
|
||||||
final ValueChanged<String> onChanged; |
|
||||||
final double? width; |
|
||||||
final bool showBorder; |
|
||||||
|
|
||||||
const CustomFilterDropdown({ |
|
||||||
super.key, |
|
||||||
required this.title, |
|
||||||
required this.options, |
|
||||||
required this.selectedValue, |
|
||||||
required this.onChanged, |
|
||||||
this.width, |
|
||||||
this.showBorder = true, |
|
||||||
}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Container( |
|
||||||
width: width ?? 120.w, |
|
||||||
decoration: showBorder |
|
||||||
? BoxDecoration( |
|
||||||
border: Border.all(color: Colors.grey.shade300), |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
) |
|
||||||
: null, |
|
||||||
// padding: EdgeInsets.only(left: 10.w), |
|
||||||
child: DropdownButtonHideUnderline( |
|
||||||
child: DropdownButton<String>( |
|
||||||
value: selectedValue, |
|
||||||
isExpanded: true, |
|
||||||
icon: Icon(Icons.arrow_drop_down, size: 16.sp, color: Colors.grey), |
|
||||||
style: TextStyle( |
|
||||||
fontSize: 14.sp, |
|
||||||
color: Colors.black87, |
|
||||||
fontWeight: FontWeight.normal, |
|
||||||
), |
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10.w), |
|
||||||
dropdownColor: Colors.white, |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
items: options.map((DropdownOption option) { |
|
||||||
return DropdownMenuItem<String>( |
|
||||||
value: option.value, |
|
||||||
child: Row( |
|
||||||
children: [ |
|
||||||
if (option.icon != null) |
|
||||||
Padding( |
|
||||||
padding: EdgeInsets.only(right: 4.w), |
|
||||||
child: Icon(option.icon, size: 16.sp, color: Colors.grey), |
|
||||||
), |
|
||||||
Expanded( |
|
||||||
child: Text( |
|
||||||
option.label, |
|
||||||
style: TextStyle(fontSize: 14.sp), |
|
||||||
// overflow: TextOverflow.ellipsis, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
}).toList(), |
|
||||||
onChanged: (String? newValue) { |
|
||||||
if (newValue != null) { |
|
||||||
onChanged(newValue); |
|
||||||
} |
|
||||||
}, |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,97 +0,0 @@ |
|||||||
// widgets/compact_filter_bar.dart |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
|
|
||||||
import 'custom_filter_dropdown.dart'; |
|
||||||
|
|
||||||
class HistoryFilterBar extends GetView<ProblemController> { |
|
||||||
final EdgeInsetsGeometry? padding; |
|
||||||
|
|
||||||
const HistoryFilterBar({super.key, this.padding}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
return Container( |
|
||||||
padding: padding ?? EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h), |
|
||||||
color: Colors.grey[50], |
|
||||||
child: Row( |
|
||||||
children: [ |
|
||||||
// 显示日期选择 |
|
||||||
...[ |
|
||||||
SizedBox( |
|
||||||
width: 110.w, |
|
||||||
// decoration: BoxDecoration( |
|
||||||
// border: Border.all(color: Colors.grey.shade300), |
|
||||||
// borderRadius: BorderRadius.circular(8.r), |
|
||||||
// ), |
|
||||||
child: TextButton( |
|
||||||
onPressed: () { |
|
||||||
controller.selectDateRange(context); |
|
||||||
}, |
|
||||||
style: TextButton.styleFrom( |
|
||||||
padding: EdgeInsets.symmetric( |
|
||||||
horizontal: 12.w, |
|
||||||
vertical: 4.h, |
|
||||||
), |
|
||||||
shape: RoundedRectangleBorder( |
|
||||||
borderRadius: BorderRadius.circular(8.r), |
|
||||||
), |
|
||||||
), |
|
||||||
child: Row( |
|
||||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||||
children: [ |
|
||||||
Icon( |
|
||||||
Icons.date_range, |
|
||||||
size: 16.sp, |
|
||||||
color: Colors.grey[700], |
|
||||||
), |
|
||||||
SizedBox(width: 4.w), |
|
||||||
|
|
||||||
Text( |
|
||||||
'选择日期', |
|
||||||
style: TextStyle( |
|
||||||
fontSize: 14.sp, |
|
||||||
color: Colors.black87, |
|
||||||
fontWeight: FontWeight.normal, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
|
|
||||||
// 上传状态筛选 |
|
||||||
...[ |
|
||||||
Obx( |
|
||||||
() => CustomFilterDropdown( |
|
||||||
title: '上传状态', |
|
||||||
options: controller.uploadOptions, |
|
||||||
selectedValue: controller.historyUploadFilter.value, |
|
||||||
onChanged: controller.updateHistoryUpload, |
|
||||||
width: 100.w, |
|
||||||
showBorder: false, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
|
|
||||||
// 绑定状态筛选 |
|
||||||
...[ |
|
||||||
Obx( |
|
||||||
() => CustomFilterDropdown( |
|
||||||
title: '绑定状态', |
|
||||||
options: controller.bindOptions, |
|
||||||
selectedValue: controller.historyBindFilter.value, |
|
||||||
onChanged: controller.updateHistoryBind, |
|
||||||
width: 100.w, |
|
||||||
showBorder: false, |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -1,68 +0,0 @@ |
|||||||
// models/date_range_enum.dart |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/models/dropdown_option.dart'; |
|
||||||
|
|
||||||
enum DateRange { threeDays, oneWeek, oneMonth } |
|
||||||
|
|
||||||
extension DateRangeExtension on DateRange { |
|
||||||
String get displayName { |
|
||||||
switch (this) { |
|
||||||
case DateRange.threeDays: |
|
||||||
return '近三天'; |
|
||||||
case DateRange.oneWeek: |
|
||||||
return '近一周'; |
|
||||||
case DateRange.oneMonth: |
|
||||||
return '近一月'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
DateTime get startDate { |
|
||||||
final now = DateTime.now(); |
|
||||||
switch (this) { |
|
||||||
case DateRange.threeDays: |
|
||||||
return now.subtract(const Duration(days: 3)); |
|
||||||
case DateRange.oneWeek: |
|
||||||
return now.subtract(const Duration(days: 7)); |
|
||||||
case DateRange.oneMonth: |
|
||||||
return now.subtract(const Duration(days: 30)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
DateTime get endDate { |
|
||||||
return DateTime.now(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 为 DateRange 枚举创建 DropdownOption 转换扩展 |
|
||||||
extension DateRangeDropdown on DateRange { |
|
||||||
DropdownOption toDropdownOption() { |
|
||||||
return DropdownOption( |
|
||||||
label: displayName, |
|
||||||
value: name, // 使用枚举的名称作为值 |
|
||||||
icon: _getIcon(), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
IconData _getIcon() { |
|
||||||
switch (this) { |
|
||||||
case DateRange.threeDays: |
|
||||||
return Icons.calendar_today; |
|
||||||
case DateRange.oneWeek: |
|
||||||
return Icons.date_range; |
|
||||||
case DateRange.oneMonth: |
|
||||||
return Icons.calendar_month; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 添加反向转换方法 |
|
||||||
extension StringToDateRange on String { |
|
||||||
DateRange? toDateRange() { |
|
||||||
for (var range in DateRange.values) { |
|
||||||
if (range.name == this) { |
|
||||||
return range; |
|
||||||
} |
|
||||||
} |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
@ -1,23 +0,0 @@ |
|||||||
// models/dropdown_option.dart |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
|
|
||||||
class DropdownOption { |
|
||||||
final String label; |
|
||||||
final String value; |
|
||||||
final IconData? icon; |
|
||||||
|
|
||||||
const DropdownOption({required this.label, required this.value, this.icon}); |
|
||||||
|
|
||||||
@override |
|
||||||
bool operator ==(Object other) => |
|
||||||
identical(this, other) || |
|
||||||
other is DropdownOption && |
|
||||||
runtimeType == other.runtimeType && |
|
||||||
value == other.value; |
|
||||||
|
|
||||||
@override |
|
||||||
int get hashCode => value.hashCode; |
|
||||||
|
|
||||||
@override |
|
||||||
String toString() => label; |
|
||||||
} |
|
@ -1,247 +0,0 @@ |
|||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||||
import 'package:get/get.dart'; |
|
||||||
import 'package:intl/intl.dart'; |
|
||||||
import 'package:problem_check_system/app/routes/app_routes.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_sync_status.dart'; |
|
||||||
import 'package:problem_check_system/data/models/problem_model.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/controllers/problem_controller.dart'; |
|
||||||
import 'package:problem_check_system/modules/problem/views/widgets/custom_button.dart'; |
|
||||||
import 'package:tdesign_flutter/tdesign_flutter.dart'; |
|
||||||
import 'dart:io'; // 添加文件操作支持 |
|
||||||
|
|
||||||
// 定义枚举类型 |
|
||||||
enum ProblemCardViewType { buttons, checkbox } |
|
||||||
|
|
||||||
class ProblemCard extends GetView<ProblemController> { |
|
||||||
final Problem problem; |
|
||||||
final ProblemCardViewType viewType; |
|
||||||
final Function(Problem, bool)? onChanged; |
|
||||||
final bool isSelected; // 改为必需参数 |
|
||||||
|
|
||||||
const ProblemCard({ |
|
||||||
super.key, |
|
||||||
required this.problem, |
|
||||||
this.viewType = ProblemCardViewType.buttons, |
|
||||||
this.onChanged, |
|
||||||
required this.isSelected, // 改为必需参数 |
|
||||||
}); |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
// 根据是否已删除决定卡片的颜色 |
|
||||||
final bool isDeleted = |
|
||||||
problem.syncStatus == ProblemSyncStatus.pendingDelete; |
|
||||||
final Color cardColor = isDeleted |
|
||||||
? Colors.grey[300]! |
|
||||||
: Theme.of(context).cardColor; |
|
||||||
final Color contentColor = isDeleted |
|
||||||
? Colors.grey[600]! |
|
||||||
: Theme.of(context).textTheme.bodyMedium!.color!; |
|
||||||
|
|
||||||
return Card( |
|
||||||
color: cardColor, |
|
||||||
child: InkWell( |
|
||||||
onTap: viewType == ProblemCardViewType.checkbox |
|
||||||
? () { |
|
||||||
onChanged?.call(problem, !isSelected); |
|
||||||
} |
|
||||||
: null, |
|
||||||
child: Column( |
|
||||||
mainAxisSize: MainAxisSize.min, |
|
||||||
children: <Widget>[ |
|
||||||
ListTile( |
|
||||||
leading: _buildImageWidget(isDeleted), // 使用新的图片构建方法 |
|
||||||
title: Text( |
|
||||||
'问题描述', |
|
||||||
style: TextStyle(fontSize: 16.sp, color: contentColor), |
|
||||||
), |
|
||||||
subtitle: LayoutBuilder( |
|
||||||
builder: (context, constraints) { |
|
||||||
return Text( |
|
||||||
problem.description, |
|
||||||
maxLines: 2, |
|
||||||
overflow: TextOverflow.ellipsis, |
|
||||||
style: TextStyle(fontSize: 14.sp, color: contentColor), |
|
||||||
); |
|
||||||
}, |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(height: 8.h), |
|
||||||
Row( |
|
||||||
children: [ |
|
||||||
SizedBox(width: 16.w), |
|
||||||
Icon(Icons.location_on, color: contentColor, size: 16.h), |
|
||||||
SizedBox(width: 8.w), |
|
||||||
SizedBox( |
|
||||||
width: 100.w, |
|
||||||
child: Text( |
|
||||||
problem.location, |
|
||||||
style: TextStyle(fontSize: 12.sp, color: contentColor), |
|
||||||
overflow: TextOverflow.ellipsis, |
|
||||||
maxLines: 1, |
|
||||||
), |
|
||||||
), |
|
||||||
SizedBox(width: 16.w), |
|
||||||
Icon(Icons.access_time, color: contentColor, size: 16.h), |
|
||||||
SizedBox(width: 8.w), |
|
||||||
Expanded( |
|
||||||
child: Text( |
|
||||||
DateFormat( |
|
||||||
'yyyy-MM-dd HH:mm:ss', |
|
||||||
).format(problem.creationTime), |
|
||||||
style: TextStyle(fontSize: 12.sp, color: contentColor), |
|
||||||
), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
SizedBox(height: 8.h), |
|
||||||
Row( |
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||||
children: <Widget>[ |
|
||||||
SizedBox(width: 16.w), |
|
||||||
Wrap( |
|
||||||
spacing: 8, |
|
||||||
children: [ |
|
||||||
...problem.syncStatus == ProblemSyncStatus.pendingDelete |
|
||||||
? [ |
|
||||||
TDTag( |
|
||||||
'服务器未删除', |
|
||||||
isLight: true, |
|
||||||
theme: TDTagTheme.defaultTheme, |
|
||||||
textColor: isDeleted ? Colors.grey[700] : null, |
|
||||||
// backgroundColor: isDeleted |
|
||||||
// ? Colors.grey[400] |
|
||||||
// : null, |
|
||||||
), |
|
||||||
] |
|
||||||
: [ |
|
||||||
problem.syncStatus == ProblemSyncStatus.synced |
|
||||||
? TDTag( |
|
||||||
'已上传', |
|
||||||
isLight: true, |
|
||||||
theme: TDTagTheme.success, |
|
||||||
) |
|
||||||
: TDTag( |
|
||||||
'未上传', |
|
||||||
isLight: true, |
|
||||||
theme: TDTagTheme.danger, |
|
||||||
), |
|
||||||
problem.bindData != null && |
|
||||||
problem.bindData!.isNotEmpty |
|
||||||
? TDTag( |
|
||||||
'已绑定', |
|
||||||
isLight: true, |
|
||||||
theme: TDTagTheme.primary, |
|
||||||
) |
|
||||||
: TDTag( |
|
||||||
'未绑定', |
|
||||||
isLight: true, |
|
||||||
theme: TDTagTheme.warning, |
|
||||||
), |
|
||||||
], |
|
||||||
], |
|
||||||
), |
|
||||||
const Spacer(), |
|
||||||
_buildBottomActions(isDeleted), |
|
||||||
], |
|
||||||
), |
|
||||||
SizedBox(height: 8.h), |
|
||||||
], |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildImageWidget(bool isDeleted) { |
|
||||||
return AspectRatio( |
|
||||||
aspectRatio: 1, // 强制正方形 |
|
||||||
child: _buildImageContent(isDeleted), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildImageContent(bool isDeleted) { |
|
||||||
// 检查是否有图片路径 |
|
||||||
if (problem.imageUrls.isEmpty || problem.imageUrls[0].localPath.isEmpty) { |
|
||||||
// 如果没有图片,显示默认占位图 |
|
||||||
return Image.asset( |
|
||||||
'assets/images/problem_preview.png', |
|
||||||
fit: BoxFit.cover, // 使用 cover 来填充正方形区域 |
|
||||||
color: isDeleted ? Colors.grey[500] : null, |
|
||||||
colorBlendMode: isDeleted ? BlendMode.saturation : null, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
final String imagePath = problem.imageUrls[0].localPath; |
|
||||||
|
|
||||||
// 检查文件是否存在 |
|
||||||
final File imageFile = File(imagePath); |
|
||||||
if (!imageFile.existsSync()) { |
|
||||||
// 如果文件不存在,显示默认占位图 |
|
||||||
return Image.asset( |
|
||||||
'assets/images/problem_preview.png', |
|
||||||
fit: BoxFit.cover, |
|
||||||
color: isDeleted ? Colors.grey[500] : null, |
|
||||||
colorBlendMode: isDeleted ? BlendMode.saturation : null, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// 如果文件存在,使用 Image.file 加载 |
|
||||||
return Image.file( |
|
||||||
imageFile, |
|
||||||
fit: BoxFit.cover, // 使用 cover 来填充正方形区域 |
|
||||||
color: isDeleted ? Colors.grey[500] : null, |
|
||||||
colorBlendMode: isDeleted ? BlendMode.saturation : null, |
|
||||||
errorBuilder: (context, error, stackTrace) { |
|
||||||
// 如果加载失败,显示默认图片 |
|
||||||
return Image.asset( |
|
||||||
'assets/images/problem_preview.png', |
|
||||||
fit: BoxFit.cover, |
|
||||||
color: isDeleted ? Colors.grey[500] : null, |
|
||||||
colorBlendMode: isDeleted ? BlendMode.saturation : null, |
|
||||||
); |
|
||||||
}, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
Widget _buildBottomActions(bool isDeleted) { |
|
||||||
switch (viewType) { |
|
||||||
case ProblemCardViewType.buttons: |
|
||||||
return Row( |
|
||||||
children: [ |
|
||||||
if (!isDeleted) |
|
||||||
CustomButton( |
|
||||||
text: '修改', |
|
||||||
onTap: () { |
|
||||||
controller.toProblemFormPageAndRefresh(problem: problem); |
|
||||||
}, |
|
||||||
), |
|
||||||
if (!isDeleted) SizedBox(width: 8.w), |
|
||||||
CustomButton( |
|
||||||
text: '查看', |
|
||||||
onTap: () { |
|
||||||
Get.toNamed( |
|
||||||
AppRoutes.problemForm, |
|
||||||
arguments: problem, |
|
||||||
parameters: {'isReadOnly': 'true'}, |
|
||||||
); |
|
||||||
}, |
|
||||||
), |
|
||||||
SizedBox(width: 16.w), |
|
||||||
], |
|
||||||
); |
|
||||||
case ProblemCardViewType.checkbox: |
|
||||||
return Padding( |
|
||||||
padding: EdgeInsets.only(right: 16.w), |
|
||||||
child: Checkbox( |
|
||||||
value: isSelected, |
|
||||||
onChanged: (bool? value) { |
|
||||||
if (value != null) { |
|
||||||
onChanged?.call(problem, value); |
|
||||||
} |
|
||||||
}, |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
Loading…
Reference in new issue