Browse Source

新增:本地数据库,新增问题,本地存储

dev
徐振升 3 weeks ago
parent
commit
2267d6fc91
  1. 23
      README.md
  2. 15
      android/app/src/main/AndroidManifest.xml
  3. 5
      android/gradle.properties
  4. 130
      lib/controllers/add_problem_controller.dart
  5. 73
      lib/controllers/auth_controller.dart
  6. 115
      lib/controllers/problem_controller.dart
  7. 60
      lib/models/problem_model.dart
  8. 2
      lib/modules/home/home_controller.dart
  9. 0
      lib/modules/login/views/problem_page.dart
  10. 0
      lib/modules/problem/components/custom_button.dart
  11. 135
      lib/modules/problem/models/problem.dart
  12. 137
      lib/modules/problem/problem_card.dart
  13. 244
      lib/modules/problem/problem_new_page.dart
  14. 29
      lib/modules/problem/view_model/image_controller.dart
  15. 39
      lib/modules/problem/view_model/problem_new_controller.dart
  16. 78
      lib/services/local_database.dart
  17. 348
      lib/views/add_problem_page.dart
  18. 74
      lib/views/problem_page.dart
  19. 4
      macos/Flutter/GeneratedPluginRegistrant.swift
  20. 136
      pubspec.lock
  21. 4
      pubspec.yaml

23
README.md

@ -1,3 +1,24 @@
# problem_check_system
A new Flutter project.
这个应用需要实现以下功能:
离线登录系统
问题数据收集(描述、位置、图片等)
本地数据存储
有网络时手动上传功能
技术栈
Flutter SDK
GetX (状态管理、路由管理、依赖注入)
SQFlite (本地数据库)
Image Picker (图片选择)
Geolocator (位置信息)
HTTP/Dio (网络请求)

15
android/app/src/main/AndroidManifest.xml

@ -1,6 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- 对于 Android 10 (API 29) 及以上版本,需要添加以下权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application
android:label="problem_check_system"
android:name="${applicationName}"

5
android/gradle.properties

@ -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

130
lib/controllers/add_problem_controller.dart

@ -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();
}
}

73
lib/controllers/auth_controller.dart

@ -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 = '';
}
}

115
lib/controllers/problem_controller.dart

@ -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();
}
}

60
lib/models/problem_model.dart

@ -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'],
);
}
}

2
lib/modules/home/home_controller.dart

@ -1,7 +1,7 @@
// Logic
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:problem_check_system/modules/problem/problem_page.dart';
import 'package:problem_check_system/views/problem_page.dart';
class HomeController extends GetxController {
//

0
lib/modules/login/views/problem_page.dart

0
lib/modules/problem/custom_button.dart → lib/modules/problem/components/custom_button.dart

135
lib/modules/problem/models/problem.dart

@ -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;
}

137
lib/modules/problem/problem_card.dart

@ -1,22 +1,23 @@
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:intl/intl.dart';
import 'package:problem_check_system/models/problem_model.dart';
import 'package:problem_check_system/modules/problem/components/custom_button.dart';
import 'package:tdesign_flutter/tdesign_flutter.dart';
//
enum ProblemCardViewType { buttons, checkbox }
class ProblemCard extends StatelessWidget {
final bool initialBound;
final bool initialUploaded;
final ProblemCardController controller;
final Problem problem;
final ProblemCardViewType viewType;
ProblemCard({
const ProblemCard(
this.problem, {
super.key,
required this.initialBound,
required this.initialUploaded,
}) : controller = ProblemCardController(
initialBound: initialBound,
initialUploaded: initialUploaded,
);
this.viewType = ProblemCardViewType.buttons,
});
@override
Widget build(BuildContext context) {
@ -28,19 +29,16 @@ class ProblemCard extends StatelessWidget {
ListTile(
leading: Image.asset(
'assets/images/problem_preview.png',
fit: BoxFit.contain, //
),
title: Text(
'问题描述',
style: TextStyle(fontSize: 16.sp), //
fit: BoxFit.contain,
),
title: Text('问题描述', style: TextStyle(fontSize: 16.sp)),
subtitle: LayoutBuilder(
builder: (context, constraints) {
return Text(
'硫磺库内南侧地面上存放了阀门、消防水带等物品;12#库存放了脱模剂、愈合成催化剂省略字省略字省略字省略字...',
problem.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14.sp), //
style: TextStyle(fontSize: 14.sp),
);
},
),
@ -53,10 +51,7 @@ class ProblemCard extends StatelessWidget {
children: [
Icon(Icons.location_on, color: Colors.grey, size: 16.h),
SizedBox(width: 8.w),
Text(
'汽车机厂房作业区1-C', //
style: TextStyle(fontSize: 12.sp),
),
Text(problem.location, style: TextStyle(fontSize: 12.sp)),
],
),
SizedBox(width: 16.w),
@ -65,7 +60,7 @@ class ProblemCard extends StatelessWidget {
Icon(Icons.access_time, color: Colors.grey, size: 16.h),
SizedBox(width: 8.w),
Text(
'2025-07-31 15:30:29', //
DateFormat('yyyy-MM-dd HH:mm').format(problem.createdAt),
style: TextStyle(fontSize: 12.sp),
),
],
@ -77,63 +72,63 @@ class ProblemCard extends StatelessWidget {
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(
Wrap(
spacing: 8,
children: [
CustomButton(
text: '修改', //
onTap: () {
//
print('点击修改按钮');
},
),
SizedBox(width: 8.w),
CustomButton(
text: '查看', //
onTap: () {
//
print('点击查看按钮');
},
),
problem.isUploaded
? TDTag('已上传', isLight: true, theme: TDTagTheme.success)
: TDTag('未上传', isLight: true, theme: TDTagTheme.danger),
problem.boundInfo != null && problem.boundInfo!.isNotEmpty
? TDTag('已绑定', isLight: true, theme: TDTagTheme.primary)
: TDTag('未绑定', isLight: true, theme: TDTagTheme.warning),
],
),
const Spacer(), // 使 Spacer SizedBox
_buildBottomActions(),
],
),
SizedBox(height: 8.h),
],
),
);
}
}
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;
// UI
Widget _buildBottomActions() {
switch (viewType) {
case ProblemCardViewType.buttons:
return Row(
children: [
CustomButton(
text: '修改',
onTap: () {
print('点击修改按钮');
},
),
SizedBox(width: 8.w),
CustomButton(
text: '查看',
onTap: () {
print('点击查看按钮');
},
),
SizedBox(width: 16.w),
],
);
case ProblemCardViewType.checkbox:
return Padding(
padding: EdgeInsets.only(right: 16.w),
child: Obx(
() => Checkbox(
// Checkbox value controller.isChecked.value
value: problem.isChecked.value,
// Checkbox controller
onChanged: (bool? value) {
problem.isChecked.value = value ?? false;
},
),
),
);
}
}
}

244
lib/modules/problem/problem_new_page.dart

@ -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),
),
),
),
],
),
),
],
),
);
}
}

29
lib/modules/problem/view_model/image_controller.dart

@ -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;
}
}

39
lib/modules/problem/view_model/problem_new_controller.dart

@ -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>;
}
}

78
lib/services/local_database.dart

@ -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]);
});
}
}

348
lib/views/add_problem_page.dart

@ -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),
),
),
),
),
],
),
);
}
}

74
lib/modules/problem/problem_page.dart → lib/views/problem_page.dart

@ -1,13 +1,20 @@
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/controllers/auth_controller.dart';
import 'package:problem_check_system/controllers/problem_controller.dart';
import 'package:problem_check_system/modules/problem/components/date_picker_button.dart';
import 'package:problem_check_system/modules/problem/problem_card.dart';
import 'package:problem_check_system/modules/problem/problem_new_page.dart';
import 'package:problem_check_system/views/add_problem_page.dart';
class ProblemPage extends StatelessWidget {
ProblemPage({super.key});
final AuthController authController = Get.put(AuthController());
final ProblemController problemController = Get.put(ProblemController());
final List<Map<String, bool>> problemData = [
{"initialBound": false, "initialUploaded": false},
{"initialBound": true, "initialUploaded": true},
@ -93,28 +100,44 @@ class ProblemPage extends StatelessWidget {
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),
],
),
),
// SingleChildScrollView(
// child: Column(
// children: [
// ...problemData.map((data) {
// return ProblemCard(
// initialBound:
// data["initialBound"] ?? false,
// initialUploaded:
// data["initialUploaded"] ?? false,
// );
// }),
// SizedBox(height: 64.h),
// ],
// ),
// ),
Obx(() {
if (problemController.isLoading.value) {
return Center(
child: CircularProgressIndicator(),
);
}
return ListView.builder(
itemCount: problemController.problems.length,
itemBuilder: (context, index) {
final problem =
problemController.problems[index];
return ProblemCard(problem);
},
);
}),
Positioned(
bottom: 5.h,
right: 160.5.w,
child: FloatingActionButton(
heroTag: "123",
onPressed: () {
Get.to(() => ProblemNewPage());
Get.to(() => AddProblemPage());
},
shape: CircleBorder(),
backgroundColor: Colors.blue[300],
@ -127,7 +150,22 @@ class ProblemPage extends StatelessWidget {
),
],
),
ProblemCard(initialBound: false, initialUploaded: false),
Obx(() {
if (problemController.isLoading.value) {
return Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: problemController.problems.length,
itemBuilder: (context, index) {
final problem = problemController.problems[index];
return ProblemCard(
problem,
viewType: ProblemCardViewType.checkbox,
);
},
);
}),
],
),
),

4
macos/Flutter/GeneratedPluginRegistrant.swift

@ -7,8 +7,12 @@ import Foundation
import file_selector_macos
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}

136
pubspec.lock

@ -57,6 +57,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.6"
dio:
dependency: "direct main"
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
easy_refresh:
dependency: transitive
description:
@ -81,6 +97,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
@ -264,6 +288,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.2"
leak_tracker:
dependency: transitive
description:
@ -464,6 +496,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.11"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@ -485,6 +573,46 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@ -509,6 +637,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.0"
tdesign_flutter:
dependency: "direct main"
description:

4
pubspec.yaml

@ -7,14 +7,18 @@ environment:
sdk: ^3.8.1
dependencies:
dio: ^5.9.0
flutter:
sdk: flutter
flutter_screenutil: ^5.9.3
get: ^4.7.2
image_picker: ^1.1.2
intl: ^0.20.2
path: ^1.9.1
path_provider: ^2.1.5
permission_handler: ^12.0.1
shared_preferences: ^2.5.3
sqflite: ^2.4.2
tdesign_flutter: ^0.2.4
uuid: ^4.5.1

Loading…
Cancel
Save