21 changed files with 1111 additions and 540 deletions
@ -1,3 +1,24 @@
|
||||
# problem_check_system |
||||
|
||||
A new Flutter project. |
||||
这个应用需要实现以下功能: |
||||
|
||||
离线登录系统 |
||||
|
||||
问题数据收集(描述、位置、图片等) |
||||
|
||||
本地数据存储 |
||||
|
||||
有网络时手动上传功能 |
||||
|
||||
技术栈 |
||||
Flutter SDK |
||||
|
||||
GetX (状态管理、路由管理、依赖注入) |
||||
|
||||
SQFlite (本地数据库) |
||||
|
||||
Image Picker (图片选择) |
||||
|
||||
Geolocator (位置信息) |
||||
|
||||
HTTP/Dio (网络请求) |
||||
|
@ -1,3 +1,8 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError |
||||
android.useAndroidX=true |
||||
android.enableJetifier=true |
||||
|
||||
systemProp.http.proxyHost=127.0.0.1 |
||||
systemProp.http.proxyPort=7890 |
||||
systemProp.https.proxyHost=127.0.0.1 |
||||
systemProp.https.proxyPort=7890 |
||||
|
@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
||||
import 'package:image_picker/image_picker.dart'; |
||||
import '../controllers/problem_controller.dart'; |
||||
import 'package:permission_handler/permission_handler.dart'; |
||||
|
||||
class AddProblemController extends GetxController { |
||||
final ProblemController _problemController = Get.put(ProblemController()); |
||||
|
||||
final RxList<XFile> _selectedImages = <XFile>[].obs; |
||||
final TextEditingController descriptionController = TextEditingController(); |
||||
final TextEditingController locationController = TextEditingController(); |
||||
final RxBool isLoading = false.obs; |
||||
|
||||
List<XFile> get selectedImages => _selectedImages; |
||||
|
||||
// 改进的 pickImage 方法 |
||||
Future<void> pickImage(ImageSource source) async { |
||||
try { |
||||
PermissionStatus status; |
||||
String permissionName; |
||||
String actionName; |
||||
|
||||
if (source == ImageSource.camera) { |
||||
permissionName = "相机"; |
||||
actionName = "拍照"; |
||||
status = await Permission.camera.request(); |
||||
} else { |
||||
permissionName = "相册"; |
||||
actionName = "选择图片"; |
||||
|
||||
// 处理不同 Android 版本的相册权限 |
||||
if (await Permission.photos.isGranted) { |
||||
status = PermissionStatus.granted; |
||||
} else if (await Permission.storage.isGranted) { |
||||
status = PermissionStatus.granted; |
||||
} else { |
||||
// 请求适当的权限 |
||||
status = await Permission.photos.request(); |
||||
|
||||
// 如果 photos 权限被拒绝,尝试请求 storage 权限 |
||||
if (status.isDenied || status.isPermanentlyDenied) { |
||||
status = await Permission.storage.request(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (status.isGranted) { |
||||
final ImagePicker picker = ImagePicker(); |
||||
final XFile? image = await picker.pickImage(source: source); |
||||
|
||||
if (image != null) { |
||||
_selectedImages.add(image); |
||||
} |
||||
} else if (status.isPermanentlyDenied) { |
||||
// 权限被永久拒绝,引导用户到设置 |
||||
_showPermissionDeniedDialog(permissionName, actionName); |
||||
} else { |
||||
Get.snackbar('权限被拒绝', '需要$permissionName权限才能$actionName'); |
||||
} |
||||
} catch (e) { |
||||
Get.snackbar('错误', '选择图片失败: $e'); |
||||
} |
||||
} |
||||
|
||||
// 显示权限被拒绝的对话框 |
||||
void _showPermissionDeniedDialog(String permissionName, String actionName) { |
||||
Get.dialog( |
||||
AlertDialog( |
||||
title: Text('权限被拒绝'), |
||||
content: Text('需要$permissionName权限才能$actionName。是否要前往设置开启权限?'), |
||||
actions: [ |
||||
TextButton(onPressed: () => Get.back(), child: Text('取消')), |
||||
TextButton( |
||||
onPressed: () async { |
||||
Get.back(); |
||||
await openAppSettings(); |
||||
}, |
||||
child: Text('去设置'), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
Future<void> removeImage(int index) async { |
||||
_selectedImages.removeAt(index); |
||||
} |
||||
|
||||
bool get isFormValid { |
||||
return descriptionController.text.isNotEmpty && |
||||
locationController.text.isNotEmpty; |
||||
} |
||||
|
||||
Future<void> saveProblem() async { |
||||
if (descriptionController.text.isEmpty) { |
||||
Get.snackbar('错误', '请填写问题描述'); |
||||
return; |
||||
} |
||||
|
||||
isLoading.value = true; |
||||
|
||||
try { |
||||
await _problemController.addProblem( |
||||
descriptionController.text, |
||||
locationController.text, |
||||
_selectedImages.toList(), |
||||
); |
||||
|
||||
// 清空表单 |
||||
descriptionController.clear(); |
||||
locationController.clear(); |
||||
_selectedImages.clear(); |
||||
|
||||
Get.back(); |
||||
Get.snackbar('成功', '问题已保存'); |
||||
} catch (e) { |
||||
Get.snackbar('错误', '保存问题失败: $e'); |
||||
} finally { |
||||
isLoading.value = false; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void onClose() { |
||||
descriptionController.dispose(); |
||||
locationController.dispose(); |
||||
super.onClose(); |
||||
} |
||||
} |
@ -0,0 +1,73 @@
|
||||
import 'package:get/get.dart'; |
||||
import 'package:shared_preferences/shared_preferences.dart'; |
||||
import 'package:dio/dio.dart'; |
||||
import '../services/local_database.dart'; |
||||
|
||||
class AuthController extends GetxController { |
||||
final LocalDatabase _localDatabase = LocalDatabase(); |
||||
final RxBool isLoggedIn = false.obs; |
||||
final RxBool isOnlineMode = false.obs; |
||||
final RxString username = ''.obs; |
||||
|
||||
@override |
||||
void onInit() { |
||||
super.onInit(); |
||||
checkLoginStatus(); |
||||
} |
||||
|
||||
Future<void> checkLoginStatus() async { |
||||
final prefs = await SharedPreferences.getInstance(); |
||||
final offlineLogin = prefs.getBool('offlineLogin') ?? false; |
||||
isLoggedIn.value = offlineLogin; |
||||
|
||||
if (offlineLogin) { |
||||
username.value = prefs.getString('username') ?? '离线用户'; |
||||
} |
||||
} |
||||
|
||||
Future<bool> offlineLogin(String username) async { |
||||
final prefs = await SharedPreferences.getInstance(); |
||||
await prefs.setBool('offlineLogin', true); |
||||
await prefs.setString('username', username); |
||||
|
||||
isLoggedIn.value = true; |
||||
this.username.value = username; |
||||
isOnlineMode.value = false; |
||||
|
||||
return true; |
||||
} |
||||
|
||||
Future<bool> onlineLogin(String username, String password) async { |
||||
try { |
||||
final dio = Dio(); |
||||
final response = await dio.post( |
||||
'https://your-server.com/api/login', |
||||
data: {'username': username, 'password': password}, |
||||
); |
||||
|
||||
if (response.statusCode == 200) { |
||||
final prefs = await SharedPreferences.getInstance(); |
||||
await prefs.setBool('offlineLogin', false); |
||||
await prefs.setString('username', username); |
||||
|
||||
isLoggedIn.value = true; |
||||
this.username.value = username; |
||||
isOnlineMode.value = true; |
||||
|
||||
return true; |
||||
} |
||||
return false; |
||||
} catch (e) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
Future<void> logout() async { |
||||
final prefs = await SharedPreferences.getInstance(); |
||||
await prefs.remove('offlineLogin'); |
||||
await prefs.remove('username'); |
||||
|
||||
isLoggedIn.value = false; |
||||
username.value = ''; |
||||
} |
||||
} |
@ -0,0 +1,115 @@
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:get/get.dart' hide MultipartFile, FormData; |
||||
import 'package:image_picker/image_picker.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
import 'dart:io'; |
||||
import '../models/problem_model.dart'; |
||||
import '../services/local_database.dart'; |
||||
|
||||
class ProblemController extends GetxController { |
||||
final LocalDatabase _localDatabase = LocalDatabase(); |
||||
final RxList<Problem> problems = <Problem>[].obs; |
||||
final RxBool isLoading = false.obs; |
||||
|
||||
// 这个方法返回所有已选中的问题对象 |
||||
List<Problem> get selectedProblems { |
||||
return problems.where((p) => p.isChecked.value).toList(); |
||||
} |
||||
|
||||
@override |
||||
void onInit() { |
||||
super.onInit(); |
||||
loadProblems(); |
||||
} |
||||
|
||||
Future<void> loadProblems() async { |
||||
isLoading.value = true; |
||||
problems.value = await _localDatabase.getProblems(); |
||||
isLoading.value = false; |
||||
} |
||||
|
||||
Future<void> addProblem( |
||||
String description, |
||||
String location, |
||||
List<XFile> images, |
||||
) async { |
||||
try { |
||||
// 保存图片到本地 |
||||
final List<String> imagePaths = []; |
||||
final directory = await getApplicationDocumentsDirectory(); |
||||
|
||||
for (var i = 0; i < images.length; i++) { |
||||
final String imagePath = |
||||
'${directory.path}/problem_${DateTime.now().millisecondsSinceEpoch}_$i.jpg'; |
||||
final File imageFile = File(imagePath); |
||||
await imageFile.writeAsBytes(await images[i].readAsBytes()); |
||||
imagePaths.add(imagePath); |
||||
} |
||||
|
||||
final Problem problem = Problem( |
||||
description: description, |
||||
location: location, |
||||
imagePaths: imagePaths, |
||||
createdAt: DateTime.now(), |
||||
isUploaded: false, |
||||
); |
||||
|
||||
await _localDatabase.insertProblem(problem); |
||||
problems.add(problem); |
||||
} catch (e) { |
||||
Get.snackbar('错误', '保存问题失败: $e'); |
||||
rethrow; |
||||
} |
||||
} |
||||
|
||||
Future<void> uploadProblem(Problem problem) async { |
||||
try { |
||||
final dio = Dio(); |
||||
|
||||
// 准备表单数据 |
||||
final formData = FormData.fromMap({ |
||||
'description': problem.description, |
||||
'location': problem.location, |
||||
'createdAt': problem.createdAt.toIso8601String(), |
||||
'boundInfo': problem.boundInfo ?? '', |
||||
}); |
||||
|
||||
// 添加图片文件 |
||||
for (var path in problem.imagePaths) { |
||||
formData.files.add( |
||||
MapEntry('images', await MultipartFile.fromFile(path)), |
||||
); |
||||
} |
||||
|
||||
final response = await dio.post( |
||||
'https://your-server.com/api/problems', |
||||
data: formData, |
||||
); |
||||
|
||||
if (response.statusCode == 200) { |
||||
// 更新上传状态 |
||||
problem.isUploaded = true; |
||||
await _localDatabase.updateProblem(problem); |
||||
problems.refresh(); |
||||
|
||||
Get.snackbar('成功', '问题已上传到服务器'); |
||||
} |
||||
} catch (e) { |
||||
Get.snackbar('错误', '上传问题失败: $e'); |
||||
} |
||||
} |
||||
|
||||
Future<void> uploadAllUnuploaded() async { |
||||
final unuploaded = await _localDatabase.getUnuploadedProblems(); |
||||
for (var problem in unuploaded) { |
||||
await uploadProblem(problem); |
||||
} |
||||
} |
||||
|
||||
Future<void> bindInfoToProblem(int problemId, String info) async { |
||||
final problem = problems.firstWhere((p) => p.id == problemId); |
||||
problem.boundInfo = info; |
||||
await _localDatabase.updateProblem(problem); |
||||
problems.refresh(); |
||||
} |
||||
} |
@ -0,0 +1,60 @@
|
||||
import 'package:get/get.dart'; |
||||
|
||||
class Problem { |
||||
String? id; |
||||
String description; |
||||
String location; |
||||
List<String> imagePaths; |
||||
DateTime createdAt; |
||||
bool isUploaded; |
||||
String? boundInfo; |
||||
// 添加可观察的选中状态 |
||||
final RxBool isChecked = false.obs; |
||||
|
||||
Problem({ |
||||
this.id, |
||||
required this.description, |
||||
required this.location, |
||||
required this.imagePaths, |
||||
required this.createdAt, |
||||
this.isUploaded = false, |
||||
this.boundInfo, |
||||
}); |
||||
|
||||
// 添加 toJson 方法 |
||||
Map<String, dynamic> toJson() { |
||||
return { |
||||
'id': id, |
||||
'description': description, |
||||
'location': location, |
||||
'imagePaths': imagePaths, // List<String> 类型可以直接序列化 |
||||
'createdAt': createdAt.toIso8601String(), |
||||
'isUploaded': isUploaded, // bool 类型可以直接序列化 |
||||
'boundInfo': boundInfo, |
||||
}; |
||||
} |
||||
|
||||
Map<String, dynamic> toMap() { |
||||
return { |
||||
'id': id, |
||||
'description': description, |
||||
'location': location, |
||||
'imagePaths': imagePaths.join(';;'), |
||||
'createdAt': createdAt.toIso8601String(), |
||||
'isUploaded': isUploaded ? 1 : 0, |
||||
'boundInfo': boundInfo, |
||||
}; |
||||
} |
||||
|
||||
factory Problem.fromMap(Map<String, dynamic> map) { |
||||
return Problem( |
||||
id: map['id'], |
||||
description: map['description'], |
||||
location: map['location'], |
||||
imagePaths: (map['imagePaths'] as String).split(';;'), |
||||
createdAt: DateTime.parse(map['createdAt']), |
||||
isUploaded: map['isUploaded'] == 1, |
||||
boundInfo: map['boundInfo'], |
||||
); |
||||
} |
||||
} |
@ -1,135 +0,0 @@
|
||||
import 'package:uuid/uuid.dart'; |
||||
|
||||
class Problem { |
||||
final String id; |
||||
final String description; |
||||
final String location; |
||||
final String imageUrl; |
||||
final DateTime createdAt; |
||||
final DateTime updatedAt; |
||||
|
||||
static final Uuid _uuid = const Uuid(); |
||||
|
||||
Problem._({ |
||||
required this.id, |
||||
required this.description, |
||||
required this.location, |
||||
required this.imageUrl, |
||||
required this.createdAt, |
||||
required this.updatedAt, |
||||
}); |
||||
|
||||
// 主工厂构造函数 |
||||
factory Problem({ |
||||
required String description, |
||||
required String location, |
||||
required String imageUrl, |
||||
String? id, |
||||
DateTime? createdAt, |
||||
DateTime? updatedAt, |
||||
}) { |
||||
// 参数验证 |
||||
_validateParameters(description, location, imageUrl); |
||||
|
||||
final now = DateTime.now(); |
||||
return Problem._( |
||||
id: id ?? _uuid.v4(), |
||||
description: description.trim(), |
||||
location: location.trim(), |
||||
imageUrl: imageUrl, |
||||
createdAt: createdAt ?? now, |
||||
updatedAt: updatedAt ?? now, |
||||
); |
||||
} |
||||
|
||||
// 参数验证 |
||||
static void _validateParameters( |
||||
String description, |
||||
String location, |
||||
String imageUrl, |
||||
) { |
||||
if (description.isEmpty) { |
||||
throw ArgumentError('Description cannot be empty'); |
||||
} |
||||
if (location.isEmpty) { |
||||
throw ArgumentError('Location cannot be empty'); |
||||
} |
||||
if (imageUrl.isEmpty) { |
||||
throw ArgumentError('Image URL cannot be empty'); |
||||
} |
||||
|
||||
// 验证 URL 格式(简单验证) |
||||
if (!imageUrl.startsWith('http')) { |
||||
throw ArgumentError('Image URL must be a valid URL'); |
||||
} |
||||
} |
||||
|
||||
// 从 JSON 反序列化 |
||||
factory Problem.fromJson(Map<String, dynamic> json) { |
||||
return Problem( |
||||
id: json['id'], |
||||
description: json['description'], |
||||
location: json['location'], |
||||
imageUrl: json['imageUrl'], |
||||
createdAt: json['createdAt'] != null |
||||
? DateTime.parse(json['createdAt']) |
||||
: null, |
||||
updatedAt: json['updatedAt'] != null |
||||
? DateTime.parse(json['updatedAt']) |
||||
: null, |
||||
); |
||||
} |
||||
|
||||
// 转换为 JSON |
||||
Map<String, dynamic> toJson() { |
||||
return { |
||||
'id': id, |
||||
'description': description, |
||||
'location': location, |
||||
'imageUrl': imageUrl, |
||||
'createdAt': createdAt.toIso8601String(), |
||||
'updatedAt': updatedAt.toIso8601String(), |
||||
}; |
||||
} |
||||
|
||||
// 复制方法 |
||||
Problem copyWith({ |
||||
String? id, |
||||
String? description, |
||||
String? location, |
||||
String? imageUrl, |
||||
DateTime? createdAt, |
||||
DateTime? updatedAt, |
||||
}) { |
||||
return Problem( |
||||
id: id ?? this.id, |
||||
description: description ?? this.description, |
||||
location: location ?? this.location, |
||||
imageUrl: imageUrl ?? this.imageUrl, |
||||
createdAt: createdAt ?? this.createdAt, |
||||
updatedAt: updatedAt ?? DateTime.now(), // 更新时间为当前时间 |
||||
); |
||||
} |
||||
|
||||
// 检查是否有效 |
||||
bool get isValid => description.isNotEmpty && location.isNotEmpty; |
||||
|
||||
// 获取简短描述 |
||||
String get shortDescription { |
||||
if (description.length <= 50) return description; |
||||
return '${description.substring(0, 47)}...'; |
||||
} |
||||
|
||||
@override |
||||
String toString() { |
||||
return 'Problem(id: $id, description: $shortDescription, location: $location)'; |
||||
} |
||||
|
||||
@override |
||||
bool operator ==(Object other) => |
||||
identical(this, other) || |
||||
other is Problem && runtimeType == other.runtimeType && id == other.id; |
||||
|
||||
@override |
||||
int get hashCode => id.hashCode; |
||||
} |
@ -1,244 +0,0 @@
|
||||
import 'dart:io'; |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
import 'package:get/get.dart'; |
||||
import 'package:problem_check_system/modules/problem/models/problem.dart'; |
||||
import 'package:problem_check_system/modules/problem/view_model/image_controller.dart'; |
||||
import 'package:problem_check_system/modules/problem/view_model/problem_new_controller.dart'; |
||||
|
||||
class ProblemNewPage extends StatelessWidget { |
||||
ProblemNewPage({super.key}); |
||||
|
||||
final ImageController imageController = Get.put(ImageController()); |
||||
final ProblemNewController problemNewController = Get.put( |
||||
ProblemNewController(), |
||||
); |
||||
|
||||
@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: const Text('新增问题', style: TextStyle(color: Colors.white)), |
||||
centerTitle: true, |
||||
backgroundColor: Colors.transparent, // 设置背景透明以显示渐变 |
||||
), |
||||
body: Column( |
||||
children: [ |
||||
Expanded( |
||||
child: Column( |
||||
children: [ |
||||
Card( |
||||
margin: EdgeInsets.all(17.w), |
||||
child: Column( |
||||
children: [ |
||||
ListTile( |
||||
title: const Text( |
||||
'问题描述', // 标题 |
||||
style: TextStyle( |
||||
fontSize: 16, |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
subtitle: TextField( |
||||
maxLines: null, |
||||
decoration: const InputDecoration( |
||||
hintText: '请输入问题描述', |
||||
border: InputBorder.none, |
||||
), |
||||
onChanged: (value) { |
||||
// 输入值发生变化时的逻辑 |
||||
}, |
||||
), |
||||
), |
||||
Align( |
||||
alignment: Alignment.centerRight, // 设置为右对齐 |
||||
child: Container( |
||||
margin: const EdgeInsets.all(10), |
||||
child: ElevatedButton( |
||||
onPressed: () { |
||||
print("按钮被点击"); |
||||
}, |
||||
child: const Icon(Icons.keyboard_voice), |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
Card( |
||||
margin: EdgeInsets.all(17.w), |
||||
child: Column( |
||||
children: [ |
||||
ListTile( |
||||
title: const Text( |
||||
'所在位置', // 标题 |
||||
style: TextStyle( |
||||
fontSize: 16, |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
subtitle: TextField( |
||||
maxLines: null, |
||||
decoration: const InputDecoration( |
||||
hintText: '请输入问题所在位置', |
||||
border: InputBorder.none, |
||||
), |
||||
onChanged: (value) { |
||||
// 输入值发生变化时的逻辑 |
||||
}, |
||||
), |
||||
), |
||||
Align( |
||||
alignment: Alignment.centerRight, // 设置为右对齐 |
||||
child: Container( |
||||
margin: const EdgeInsets.all(10), |
||||
child: ElevatedButton( |
||||
onPressed: () { |
||||
print("按钮被点击"); |
||||
}, |
||||
child: const Icon(Icons.keyboard_voice), |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
Card( |
||||
margin: EdgeInsets.all(17.w), |
||||
child: Column( |
||||
children: [ |
||||
ListTile( |
||||
title: const Text( |
||||
'问题图片', // 标题 |
||||
style: TextStyle( |
||||
fontSize: 16, |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
subtitle: Row( |
||||
mainAxisAlignment: MainAxisAlignment.start, |
||||
children: [ |
||||
// 展示图片区域 |
||||
Obx(() { |
||||
final file = imageController.selectedImage.value; |
||||
if (file == null) { |
||||
return const Text('尚未选取图片'); |
||||
} |
||||
return Image.file( |
||||
file, |
||||
width: 100, |
||||
height: 100, |
||||
fit: BoxFit.cover, |
||||
); |
||||
}), |
||||
const SizedBox(width: 20), |
||||
Container( |
||||
color: Color(0xffF7F7F7), |
||||
width: 100, |
||||
height: 100, |
||||
child: IconButton( |
||||
icon: const Icon(Icons.image), |
||||
iconSize: 40, // 图标大小 |
||||
onPressed: imageController.pickAndCopyImage, |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
// 底部按钮 |
||||
Container( |
||||
width: 375.w, |
||||
height: 81.h, |
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), |
||||
decoration: BoxDecoration( |
||||
color: Colors.grey[200], |
||||
borderRadius: BorderRadius.circular(12), |
||||
), |
||||
child: Row( |
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
children: [ |
||||
Expanded( |
||||
child: ElevatedButton( |
||||
style: ElevatedButton.styleFrom( |
||||
backgroundColor: Colors.white, |
||||
shape: RoundedRectangleBorder( |
||||
borderRadius: BorderRadius.circular(8), |
||||
), |
||||
), |
||||
onPressed: () { |
||||
// 取消按钮逻辑 |
||||
Get.back(); |
||||
}, |
||||
child: const Text( |
||||
'取消', |
||||
style: TextStyle(color: Colors.grey), |
||||
), |
||||
), |
||||
), |
||||
const SizedBox(width: 10), |
||||
Expanded( |
||||
child: ElevatedButton( |
||||
style: ElevatedButton.styleFrom( |
||||
backgroundColor: Color(0xFF418CFC), |
||||
shape: RoundedRectangleBorder( |
||||
borderRadius: BorderRadius.circular(8), |
||||
), |
||||
), |
||||
onPressed: () async { |
||||
// 确定按钮逻辑 |
||||
final problem1 = Problem( |
||||
description: '墙面裂缝需要修复', |
||||
location: 'A栋101房间', |
||||
imageUrl: 'https://example.com/images/wall_crack.jpg', |
||||
); |
||||
await problemNewController.saveJson( |
||||
problem1.toJson(), |
||||
problem1.id, |
||||
); |
||||
Get.back(); |
||||
Get.snackbar( |
||||
'保存成功', |
||||
'${problem1.id}.json 已创建', |
||||
snackPosition: SnackPosition.TOP, |
||||
snackStyle: SnackStyle.FLOATING, |
||||
backgroundColor: Colors.black87, |
||||
colorText: Colors.white, |
||||
); |
||||
}, |
||||
child: const Text( |
||||
'确定', |
||||
style: TextStyle(color: Colors.white), |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
@ -1,29 +0,0 @@
|
||||
import 'dart:io'; |
||||
import 'package:get/get.dart'; |
||||
import 'package:image_picker/image_picker.dart'; |
||||
import 'package:path/path.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
|
||||
class ImageController extends GetxController { |
||||
// Rxn 表示 nullable 的响应式类型 |
||||
final Rxn<File> selectedImage = Rxn<File>(); |
||||
|
||||
// 从相册选取并复制 |
||||
Future<void> pickAndCopyImage() async { |
||||
// 调用 ImagePicker 选取 |
||||
final picked = await ImagePicker().pickImage(source: ImageSource.gallery); |
||||
if (picked == null) return; |
||||
|
||||
final originalFile = File(picked.path); |
||||
|
||||
// 获取应用文档目录 |
||||
final appDir = await getApplicationDocumentsDirectory(); |
||||
final fileName = basename(picked.path); |
||||
|
||||
// 复制到应用目录 |
||||
final copiedFile = await originalFile.copy('${appDir.path}/$fileName'); |
||||
|
||||
// 更新响应式状态 |
||||
selectedImage.value = copiedFile; |
||||
} |
||||
} |
@ -1,39 +0,0 @@
|
||||
import 'dart:convert'; |
||||
import 'dart:io'; |
||||
|
||||
import 'package:get/get.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
|
||||
class ProblemNewController extends GetxController { |
||||
late Directory appDocDir; |
||||
|
||||
@override |
||||
void onInit() { |
||||
super.onInit(); |
||||
_initDirectory(); |
||||
} |
||||
|
||||
Future<void> _initDirectory() async { |
||||
appDocDir = await getApplicationDocumentsDirectory(); |
||||
} |
||||
|
||||
// todo problems文件不存在,需要创建 |
||||
/// 保存 Map 数据为 JSON 文件 |
||||
Future<File> saveJson(Map<String, dynamic> data, String fileName) async { |
||||
final path = '${appDocDir.path}/problems/$fileName.json'; |
||||
final file = File(path); |
||||
final jsonStr = jsonEncode(data); |
||||
return file.writeAsString(jsonStr); |
||||
} |
||||
|
||||
/// 读取本地 JSON 并解析为 Map |
||||
Future<Map<String, dynamic>?> readJson(String fileName) async { |
||||
final path = '${appDocDir.path}/problems/$fileName.json'; |
||||
final file = File(path); |
||||
if (!await file.exists()) { |
||||
return null; |
||||
} |
||||
final content = await file.readAsString(); |
||||
return jsonDecode(content) as Map<String, dynamic>; |
||||
} |
||||
} |
@ -0,0 +1,78 @@
|
||||
import 'package:sqflite/sqflite.dart'; |
||||
import 'package:path/path.dart'; |
||||
import 'package:uuid/uuid.dart'; |
||||
import '../models/problem_model.dart'; |
||||
|
||||
class LocalDatabase { |
||||
static final LocalDatabase _instance = LocalDatabase._internal(); |
||||
factory LocalDatabase() => _instance; |
||||
static Database? _database; |
||||
|
||||
LocalDatabase._internal(); |
||||
|
||||
Future<Database> get database async { |
||||
if (_database != null) return _database!; |
||||
_database = await _initDatabase(); |
||||
return _database!; |
||||
} |
||||
|
||||
Future<Database> _initDatabase() async { |
||||
String path = join(await getDatabasesPath(), 'problems.db'); |
||||
return await openDatabase(path, version: 1, onCreate: _onCreate); |
||||
} |
||||
|
||||
Future<void> _onCreate(Database db, int version) async { |
||||
await db.execute(''' |
||||
CREATE TABLE problems( |
||||
id TEXT PRIMARY KEY, |
||||
description TEXT NOT NULL, |
||||
location TEXT NOT NULL, |
||||
imagePaths TEXT NOT NULL, |
||||
createdAt TEXT NOT NULL, |
||||
isUploaded INTEGER NOT NULL, |
||||
boundInfo TEXT |
||||
) |
||||
'''); |
||||
} |
||||
|
||||
Future<int> insertProblem(Problem problem) async { |
||||
final db = await database; |
||||
problem.id = Uuid().v4(); |
||||
return await db.insert('problems', problem.toMap()); |
||||
} |
||||
|
||||
Future<List<Problem>> getProblems() async { |
||||
final db = await database; |
||||
final List<Map<String, dynamic>> maps = await db.query('problems'); |
||||
return List.generate(maps.length, (i) { |
||||
return Problem.fromMap(maps[i]); |
||||
}); |
||||
} |
||||
|
||||
Future<int> updateProblem(Problem problem) async { |
||||
final db = await database; |
||||
return await db.update( |
||||
'problems', |
||||
problem.toMap(), |
||||
where: 'id = ?', |
||||
whereArgs: [problem.id], |
||||
); |
||||
} |
||||
|
||||
Future<int> deleteProblem(int id) async { |
||||
final db = await database; |
||||
return await db.delete('problems', where: 'id = ?', whereArgs: [id]); |
||||
} |
||||
|
||||
Future<List<Problem>> getUnuploadedProblems() async { |
||||
final db = await database; |
||||
final List<Map<String, dynamic>> maps = await db.query( |
||||
'problems', |
||||
where: 'isUploaded = ?', |
||||
whereArgs: [0], |
||||
); |
||||
return List.generate(maps.length, (i) { |
||||
return Problem.fromMap(maps[i]); |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,348 @@
|
||||
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/controllers/add_problem_controller.dart'; |
||||
|
||||
class AddProblemPage extends StatelessWidget { |
||||
AddProblemPage({super.key}); |
||||
|
||||
final AddProblemController controller = Get.put(AddProblemController()); |
||||
|
||||
// 显示底部选择器的方法 |
||||
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: [ |
||||
SizedBox(height: 16.h), |
||||
Text( |
||||
'选择图片来源', |
||||
style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold), |
||||
), |
||||
SizedBox(height: 16.h), |
||||
Divider(height: 1.h), |
||||
ListTile( |
||||
leading: Icon(Icons.camera_alt, color: Colors.blue), |
||||
title: Text('拍照'), |
||||
onTap: () { |
||||
Navigator.pop(context); |
||||
controller.pickImage(ImageSource.camera); |
||||
}, |
||||
), |
||||
Divider(height: 1.h), |
||||
ListTile( |
||||
leading: Icon(Icons.photo_library, color: Colors.blue), |
||||
title: Text('从相册选择'), |
||||
onTap: () { |
||||
Navigator.pop(context); |
||||
controller.pickImage(ImageSource.gallery); |
||||
}, |
||||
), |
||||
Divider(height: 1.h), |
||||
ListTile( |
||||
leading: Icon(Icons.cancel, color: Colors.grey), |
||||
title: Text('取消', style: TextStyle(color: Colors.grey)), |
||||
onTap: () => Navigator.pop(context), |
||||
), |
||||
SizedBox(height: 8.h), |
||||
], |
||||
), |
||||
); |
||||
}, |
||||
); |
||||
} |
||||
|
||||
@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: const Text('新增问题', style: TextStyle(color: Colors.white)), |
||||
centerTitle: true, |
||||
backgroundColor: Colors.transparent, |
||||
), |
||||
body: Column( |
||||
children: [ |
||||
Expanded( |
||||
child: SingleChildScrollView( |
||||
child: Column( |
||||
children: [ |
||||
Card( |
||||
margin: EdgeInsets.all(17.w), |
||||
child: Column( |
||||
children: [ |
||||
ListTile( |
||||
title: const Text( |
||||
'问题描述', |
||||
style: TextStyle( |
||||
fontSize: 16, |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
subtitle: TextField( |
||||
maxLines: null, |
||||
controller: controller.descriptionController, |
||||
decoration: const InputDecoration( |
||||
hintText: '请输入问题描述', |
||||
border: InputBorder.none, |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
Card( |
||||
margin: EdgeInsets.all(17.w), |
||||
child: Column( |
||||
children: [ |
||||
ListTile( |
||||
title: const Text( |
||||
'所在位置', |
||||
style: TextStyle( |
||||
fontSize: 16, |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
subtitle: TextField( |
||||
maxLines: null, |
||||
controller: controller.locationController, |
||||
decoration: const InputDecoration( |
||||
hintText: '请输入问题所在位置', |
||||
border: InputBorder.none, |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
Card( |
||||
margin: EdgeInsets.all(17.w), |
||||
child: Column( |
||||
children: [ |
||||
ListTile( |
||||
title: const Text( |
||||
'问题图片', |
||||
style: TextStyle( |
||||
fontSize: 16, |
||||
fontWeight: FontWeight.bold, |
||||
), |
||||
), |
||||
subtitle: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
SizedBox(height: 8.h), |
||||
_buildImageGridWithAddButton(context), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
_bottomButton(), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildImageGridWithAddButton(BuildContext context) { |
||||
return Obx(() { |
||||
// 计算总项目数(图片数 + 添加按钮) |
||||
int totalItems = controller.selectedImages.length + 1; |
||||
|
||||
return GridView.builder( |
||||
shrinkWrap: true, |
||||
physics: const NeverScrollableScrollPhysics(), |
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( |
||||
crossAxisCount: 3, |
||||
crossAxisSpacing: 8.w, |
||||
mainAxisSpacing: 8.h, |
||||
childAspectRatio: 1, |
||||
), |
||||
itemCount: totalItems, |
||||
itemBuilder: (context, index) { |
||||
// 如果是最后一个项目,显示添加按钮 |
||||
if (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, |
||||
), |
||||
), |
||||
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), |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
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 |
||||
: () { |
||||
if (controller.descriptionController.text.isEmpty) { |
||||
Get.snackbar( |
||||
'提示', |
||||
'问题描述不能为空', |
||||
snackPosition: SnackPosition.TOP, |
||||
backgroundColor: Colors.black87, |
||||
colorText: Colors.white, |
||||
); |
||||
return; |
||||
} |
||||
controller.saveProblem(); |
||||
}, |
||||
child: controller.isLoading.value |
||||
? SizedBox( |
||||
width: 20.w, |
||||
height: 20.h, |
||||
child: CircularProgressIndicator( |
||||
strokeWidth: 2, |
||||
valueColor: const AlwaysStoppedAnimation<Color>( |
||||
Colors.white, |
||||
), |
||||
), |
||||
) |
||||
: Text( |
||||
'确定', |
||||
style: TextStyle(color: Colors.white, fontSize: 16.sp), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
Loading…
Reference in new issue