diff --git a/mobile-app/.gitignore b/mobile-app/.gitignore new file mode 100644 index 00000000..7b917607 --- /dev/null +++ b/mobile-app/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Generated files +*.g.dart +*.freezed.dart diff --git a/mobile-app/README.md b/mobile-app/README.md new file mode 100644 index 00000000..8a32d960 --- /dev/null +++ b/mobile-app/README.md @@ -0,0 +1,90 @@ +# 供水管理系统 - 移动端APP + +三合一移动应用:供水管理 / 巡检管理 / 营收管理 + +## 技术栈 + +- **框架**: Flutter 3.x +- **状态管理**: Provider +- **路由**: GoRouter +- **HTTP**: Dio +- **本地存储**: Hive + SharedPreferences +- **定位**: Geolocator +- **相机**: Image Picker +- **通知**: Flutter Local Notifications + +## 项目结构 + +``` +mobile-app/ +├── lib/ +│ ├── main.dart # 入口文件 +│ ├── core/ # 核心模块 +│ │ ├── theme/ # 主题配置 +│ │ ├── constants/ # 常量定义 +│ │ ├── network/ # 网络层(Dio + 拦截器) +│ │ └── utils/ # 工具类 +│ ├── features/ # 功能模块 +│ │ ├── auth/ # 认证(登录/Token) +│ │ ├── main_shell/ # 主框架(Tab导航) +│ │ ├── water_supply/ # 供水管理 +│ │ ├── patrol/ # 巡检管理 +│ │ └── revenue/ # 营收管理 +│ ├── shared/ # 共享模块 +│ │ ├── widgets/ # 通用组件 +│ │ └── services/ # 共享服务 +│ └── config/ # 配置(路由等) +├── android/ # Android 配置 +├── ios/ # iOS 配置 +└── pubspec.yaml # 依赖配置 +``` + +## 功能模块 + +### 1. 统一登录 + Token 管理 +- 账号密码登录 +- Token 自动附加(AuthInterceptor) +- Token 刷新机制 +- 登录状态持久化 + +### 2. 三合一 Tab 导航 +- 供水管理 Tab +- 巡检管理 Tab +- 营收管理 Tab + +### 3. 供水管理 +- 监测站点列表 +- 实时水压/流量/水质数据 +- 状态告警 + +### 4. 巡检管理 +- 巡检任务列表(按状态筛选) +- 任务进度展示 +- 支持新建巡检任务 + +### 5. 营收管理 +- 抄表任务列表 +- 账单列表(缴费状态筛选) +- 抄表数据录入 + +### 6. 核心服务 +- **PushService**: 消息推送(初始化 + 回调) +- **LocationService**: GPS定位 + 权限管理 +- **CameraService**: 拍照/相册选择 +- **CacheService**: 离线缓存(Hive) + +## 运行 + +```bash +# 安装依赖 +flutter pub get + +# 运行开发模式 +flutter run + +# 构建 APK +flutter build apk + +# 构建 iOS +flutter build ios +``` diff --git a/mobile-app/analysis_options.yaml b/mobile-app/analysis_options.yaml new file mode 100644 index 00000000..8d615798 --- /dev/null +++ b/mobile-app/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_const_declarations: true + avoid_print: false diff --git a/mobile-app/android/app/build.gradle b/mobile-app/android/app/build.gradle new file mode 100644 index 00000000..e06af766 --- /dev/null +++ b/mobile-app/android/app/build.gradle @@ -0,0 +1,65 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.xayunmei.water_management" + compileSdk 34 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.xayunmei.water_management" + minSdk 23 + targetSdk 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + signingConfig signingConfigs.debug + minifyEnabled false + shrinkResources false + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10" +} diff --git a/mobile-app/android/app/src/main/AndroidManifest.xml b/mobile-app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..66569e37 --- /dev/null +++ b/mobile-app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile-app/android/app/src/main/java/com/example/water_management/MainActivity.kt b/mobile-app/android/app/src/main/java/com/example/water_management/MainActivity.kt new file mode 100644 index 00000000..49160d61 --- /dev/null +++ b/mobile-app/android/app/src/main/java/com/example/water_management/MainActivity.kt @@ -0,0 +1,6 @@ +package com.xayunmei.water_management + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/mobile-app/android/app/src/main/res/values/styles.xml b/mobile-app/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..ff81bae8 --- /dev/null +++ b/mobile-app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/mobile-app/android/build.gradle b/mobile-app/android/build.gradle new file mode 100644 index 00000000..bc157bd1 --- /dev/null +++ b/mobile-app/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/mobile-app/android/settings.gradle b/mobile-app/android/settings.gradle new file mode 100644 index 00000000..e0ddd691 --- /dev/null +++ b/mobile-app/android/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.10" apply false +} + +include ":app" diff --git a/mobile-app/assets/images/.gitkeep b/mobile-app/assets/images/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/mobile-app/ios/Runner/AppDelegate.swift b/mobile-app/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/mobile-app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/mobile-app/ios/Runner/Info.plist b/mobile-app/ios/Runner/Info.plist new file mode 100644 index 00000000..26825653 --- /dev/null +++ b/mobile-app/ios/Runner/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 供水管理 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + water_management_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + + + NSLocationWhenInUseUsageDescription + 需要获取您的位置信息以支持巡检定位功能 + NSLocationAlwaysUsageDescription + 需要持续获取您的位置信息以支持巡检轨迹记录 + + + NSCameraUsageDescription + 需要使用相机进行巡检拍照记录 + + + NSPhotoLibraryUsageDescription + 需要访问相册以选择巡检相关图片 + NSPhotoLibraryAddUsageDescription + 需要保存巡检照片到相册 + + + UIBackgroundModes + + remote-notification + + + diff --git a/mobile-app/lib/config/app_config.dart b/mobile-app/lib/config/app_config.dart new file mode 100644 index 00000000..b155464f --- /dev/null +++ b/mobile-app/lib/config/app_config.dart @@ -0,0 +1,39 @@ +/// 应用全局配置 +class AppConfig { + static AppConfig? _instance; + + AppConfig._internal(); + + factory AppConfig() { + _instance ??= AppConfig._internal(); + return _instance!; + } + + /// 环境配置 + AppEnvironment _environment = AppEnvironment.development; + AppEnvironment get environment => _environment; + + /// 切换环境 + void setEnvironment(AppEnvironment env) { + _environment = env; + } + + /// API 基础地址 + String get baseUrl { + switch (_environment) { + case AppEnvironment.development: + return 'https://dev-api.xayunmei.com'; + case AppEnvironment.staging: + return 'https://staging-api.xayunmei.com'; + case AppEnvironment.production: + return 'https://api.xayunmei.com'; + } + } +} + +/// 环境枚举 +enum AppEnvironment { + development, + staging, + production, +} diff --git a/mobile-app/lib/config/app_routes.dart b/mobile-app/lib/config/app_routes.dart new file mode 100644 index 00000000..201ae464 --- /dev/null +++ b/mobile-app/lib/config/app_routes.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../features/auth/pages/login_page.dart'; +import '../features/auth/services/token_service.dart'; +import '../features/main_shell/pages/main_shell_page.dart'; +import '../features/revenue/pages/bill_list_page.dart'; + +/// 应用路由配置 +class AppRoutes { + static const String login = '/login'; + static const String main = '/main'; + static const String bills = '/bills'; + + static GoRouter createRouter() { + return GoRouter( + initialLocation: login, + redirect: (BuildContext context, GoRouterState state) async { + final tokenService = TokenService(); + final isLoggedIn = await tokenService.isLoggedIn(); + final isGoingToLogin = state.matchedLocation == login; + + if (!isLoggedIn && !isGoingToLogin) return login; + if (isLoggedIn && isGoingToLogin) return main; + return null; + }, + routes: [ + GoRoute( + path: login, + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: main, + builder: (context, state) => const MainShellPage(), + ), + GoRoute( + path: bills, + builder: (context, state) => const BillListPage(), + ), + ], + ); + } +} diff --git a/mobile-app/lib/core/constants/app_constants.dart b/mobile-app/lib/core/constants/app_constants.dart new file mode 100644 index 00000000..582d7190 --- /dev/null +++ b/mobile-app/lib/core/constants/app_constants.dart @@ -0,0 +1,25 @@ +/// 应用常量配置 +class AppConstants { + // API 基础地址 + static const String baseUrl = 'https://api.xayunmei.com'; + static const String apiVersion = '/api/v1'; + + // Token 相关 + static const String tokenKey = 'auth_token'; + static const String refreshTokenKey = 'refresh_token'; + static const String tokenExpiryKey = 'token_expiry'; + + // 缓存相关 + static const String cacheBoxName = 'app_cache'; + static const String userBoxName = 'user_cache'; + + // 超时配置 + static const int connectTimeout = 15000; + static const int receiveTimeout = 15000; + + // 分页配置 + static const int defaultPageSize = 20; + + // 应用名称 + static const String appName = '供水管理系统'; +} diff --git a/mobile-app/lib/core/network/api_response.dart b/mobile-app/lib/core/network/api_response.dart new file mode 100644 index 00000000..be46839b --- /dev/null +++ b/mobile-app/lib/core/network/api_response.dart @@ -0,0 +1,39 @@ +/// 统一 API 响应模型 +class ApiResponse { + final int code; + final String message; + final T? data; + + ApiResponse({ + required this.code, + required this.message, + this.data, + }); + + bool get isSuccess => code == 200 || code == 0; + + factory ApiResponse.fromJson(Map json, T Function(dynamic)? fromJsonT) { + return ApiResponse( + code: json['code'] ?? 0, + message: json['message'] ?? '', + data: json['data'] != null && fromJsonT != null ? fromJsonT(json['data']) : json['data'] as T?, + ); + } +} + +/// 分页响应模型 +class PaginatedResponse { + final List list; + final int total; + final int page; + final int pageSize; + + PaginatedResponse({ + required this.list, + required this.total, + required this.page, + required this.pageSize, + }); + + bool get hasMore => page * pageSize < total; +} diff --git a/mobile-app/lib/core/network/auth_interceptor.dart b/mobile-app/lib/core/network/auth_interceptor.dart new file mode 100644 index 00000000..a8a59387 --- /dev/null +++ b/mobile-app/lib/core/network/auth_interceptor.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; +import '../../features/auth/services/token_service.dart'; +import '../constants/app_constants.dart'; + +/// Token 认证拦截器 +/// 自动在请求头中附加 Token,处理 Token 刷新 +class AuthInterceptor extends Interceptor { + final TokenService _tokenService = TokenService(); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final token = await _tokenService.getToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401) { + // Token 过期,尝试刷新 + try { + final refreshed = await _tokenService.refreshToken(); + if (refreshed) { + // 使用新 Token 重试请求 + final token = await _tokenService.getToken(); + err.requestOptions.headers['Authorization'] = 'Bearer $token'; + + final response = await Dio().fetch(err.requestOptions); + handler.resolve(response); + return; + } + } catch (_) { + // 刷新失败,清除 Token 并跳转登录 + await _tokenService.clearTokens(); + } + } + handler.next(err); + } +} diff --git a/mobile-app/lib/core/network/dio_client.dart b/mobile-app/lib/core/network/dio_client.dart new file mode 100644 index 00000000..09004a7a --- /dev/null +++ b/mobile-app/lib/core/network/dio_client.dart @@ -0,0 +1,79 @@ +import 'package:dio/dio.dart'; +import '../constants/app_constants.dart'; +import 'auth_interceptor.dart'; + +/// Dio HTTP 客户端配置 +class DioClient { + static DioClient? _instance; + late Dio _dio; + + Dio get dio => _dio; + + DioClient._internal() { + _dio = Dio( + BaseOptions( + baseUrl: AppConstants.baseUrl + AppConstants.apiVersion, + connectTimeout: const Duration(milliseconds: AppConstants.connectTimeout), + receiveTimeout: const Duration(milliseconds: AppConstants.receiveTimeout), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + // 添加拦截器 + _dio.interceptors.addAll([ + AuthInterceptor(), + LogInterceptor( + requestBody: true, + responseBody: true, + logPrint: (obj) => print('[DioLog] $obj'), + ), + ]); + } + + factory DioClient() { + _instance ??= DioClient._internal(); + return _instance!; + } + + /// GET 请求 + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) async { + return _dio.get(path, queryParameters: queryParameters, options: options); + } + + /// POST 请求 + Future> post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + return _dio.post(path, data: data, queryParameters: queryParameters, options: options); + } + + /// PUT 请求 + Future> put( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + return _dio.put(path, data: data, queryParameters: queryParameters, options: options); + } + + /// DELETE 请求 + Future> delete( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + return _dio.delete(path, data: data, queryParameters: queryParameters, options: options); + } +} diff --git a/mobile-app/lib/core/theme/app_theme.dart b/mobile-app/lib/core/theme/app_theme.dart new file mode 100644 index 00000000..f8f6f216 --- /dev/null +++ b/mobile-app/lib/core/theme/app_theme.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +/// 应用主题配置 +class AppTheme { + static const Color primaryColor = Color(0xFF1976D2); + static const Color secondaryColor = Color(0xFF42A5F5); + static const Color accentColor = Color(0xFF00BCD4); + static const Color backgroundColor = Color(0xFFF5F5F5); + static const Color errorColor = Color(0xFFD32F2F); + static const Color successColor = Color(0xFF388E3C); + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + primaryColor: primaryColor, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + secondary: secondaryColor, + background: backgroundColor, + error: errorColor, + ), + appBarTheme: const AppBarTheme( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.white, + ), + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/auth/models/user_model.dart b/mobile-app/lib/features/auth/models/user_model.dart new file mode 100644 index 00000000..f680e75f --- /dev/null +++ b/mobile-app/lib/features/auth/models/user_model.dart @@ -0,0 +1,44 @@ +/// 用户模型 +class UserModel { + final String id; + final String username; + final String name; + final String? avatar; + final String role; + final String? department; + final String? phone; + + UserModel({ + required this.id, + required this.username, + required this.name, + this.avatar, + required this.role, + this.department, + this.phone, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id']?.toString() ?? '', + username: json['username'] ?? '', + name: json['name'] ?? '', + avatar: json['avatar'], + role: json['role'] ?? '', + department: json['department'], + phone: json['phone'], + ); + } + + Map toJson() { + return { + 'id': id, + 'username': username, + 'name': name, + 'avatar': avatar, + 'role': role, + 'department': department, + 'phone': phone, + }; + } +} diff --git a/mobile-app/lib/features/auth/pages/login_page.dart b/mobile-app/lib/features/auth/pages/login_page.dart new file mode 100644 index 00000000..3b5c159e --- /dev/null +++ b/mobile-app/lib/features/auth/pages/login_page.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/auth_provider.dart'; + +/// 登录页面 +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + final success = await authProvider.login( + username: _usernameController.text.trim(), + password: _passwordController.text, + ); + + if (success && mounted) { + // 登录成功后路由由 AuthGuard 自动处理 + Navigator.of(context).pushReplacementNamed('/main'); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(authProvider.errorMessage ?? '登录失败'), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo 和应用名 + const Icon( + Icons.water_drop, + size: 80, + color: Color(0xFF1976D2), + ), + const SizedBox(height: 16), + const Text( + '供水管理系统', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF1976D2), + ), + ), + const SizedBox(height: 8), + Text( + '供水 · 巡检 · 营收', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 48), + + // 用户名输入 + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: '用户名', + prefixIcon: Icon(Icons.person), + hintText: '请输入用户名', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入用户名'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // 密码输入 + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: '密码', + prefixIcon: const Icon(Icons.lock), + hintText: '请输入密码', + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + obscureText: _obscurePassword, + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入密码'; + } + if (value.length < 6) { + return '密码长度不能少于6位'; + } + return null; + }, + ), + const SizedBox(height: 32), + + // 登录按钮 + Consumer( + builder: (context, auth, child) { + return ElevatedButton( + onPressed: auth.isLoading ? null : _handleLogin, + child: auth.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + '登 录', + style: TextStyle(fontSize: 16), + ), + ); + }, + ), + const SizedBox(height: 16), + + // 版本信息 + Text( + 'v1.0.0', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[400], fontSize: 12), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/auth/services/auth_provider.dart b/mobile-app/lib/features/auth/services/auth_provider.dart new file mode 100644 index 00000000..f95e98eb --- /dev/null +++ b/mobile-app/lib/features/auth/services/auth_provider.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'token_service.dart'; +import '../models/user_model.dart'; + +/// 认证状态管理 +class AuthProvider extends ChangeNotifier { + final TokenService _tokenService = TokenService(); + + bool _isLoading = false; + bool _isAuthenticated = false; + UserModel? _currentUser; + String? _errorMessage; + + bool get isLoading => _isLoading; + bool get isAuthenticated => _isAuthenticated; + UserModel? get currentUser => _currentUser; + String? get errorMessage => _errorMessage; + + /// 初始化认证状态 + Future init() async { + _isAuthenticated = await _tokenService.isLoggedIn(); + if (_isAuthenticated) { + // TODO: 从接口获取用户信息 + _currentUser = UserModel( + id: '1', + username: 'admin', + name: '管理员', + role: 'admin', + ); + } + notifyListeners(); + } + + /// 登录 + Future login({ + required String username, + required String password, + }) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + // TODO: 调用实际登录接口 + // final response = await DioClient().post('/auth/login', data: { + // 'username': username, + // 'password': password, + // }); + + // 模拟登录成功 + await _tokenService.saveTokens( + token: 'mock_access_token_${DateTime.now().millisecondsSinceEpoch}', + refreshToken: 'mock_refresh_token', + expiresIn: 7200, + ); + + _currentUser = UserModel( + id: '1', + username: username, + name: username == 'admin' ? '管理员' : username, + role: 'admin', + ); + _isAuthenticated = true; + _isLoading = false; + notifyListeners(); + return true; + } catch (e) { + _errorMessage = '登录失败,请检查用户名和密码'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + /// 登出 + Future logout() async { + await _tokenService.clearTokens(); + _isAuthenticated = false; + _currentUser = null; + notifyListeners(); + } +} diff --git a/mobile-app/lib/features/auth/services/token_service.dart b/mobile-app/lib/features/auth/services/token_service.dart new file mode 100644 index 00000000..131fda7b --- /dev/null +++ b/mobile-app/lib/features/auth/services/token_service.dart @@ -0,0 +1,90 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../../core/constants/app_constants.dart'; + +/// Token 管理服务 +/// 负责 Token 的存储、读取、刷新和清除 +class TokenService { + static TokenService? _instance; + SharedPreferences? _prefs; + + TokenService(); + + factory TokenService.getInstance() { + _instance ??= TokenService(); + return _instance!; + } + + Future get _preferences async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + /// 获取 Access Token + Future getToken() async { + final prefs = await _preferences; + return prefs.getString(AppConstants.tokenKey); + } + + /// 获取 Refresh Token + Future getRefreshToken() async { + final prefs = await _preferences; + return prefs.getString(AppConstants.refreshTokenKey); + } + + /// 保存 Token + Future saveTokens({ + required String token, + String? refreshToken, + int? expiresIn, + }) async { + final prefs = await _preferences; + await prefs.setString(AppConstants.tokenKey, token); + if (refreshToken != null) { + await prefs.setString(AppConstants.refreshTokenKey, refreshToken); + } + if (expiresIn != null) { + final expiry = DateTime.now().millisecondsSinceEpoch + expiresIn * 1000; + await prefs.setInt(AppConstants.tokenExpiryKey, expiry); + } + } + + /// 刷新 Token + Future refreshToken() async { + // TODO: 实现实际的 Token 刷新逻辑 + // 使用 refreshToken 调用后端接口获取新的 accessToken + final refreshTok = await getRefreshToken(); + if (refreshTok == null) return false; + + try { + // 模拟刷新 + // final response = await DioClient().post('/auth/refresh', data: {'refresh_token': refreshTok}); + // await saveTokens(token: response.data['data']['token'], refreshToken: response.data['data']['refresh_token']); + return true; + } catch (e) { + return false; + } + } + + /// 检查 Token 是否过期 + Future isTokenExpired() async { + final prefs = await _preferences; + final expiry = prefs.getInt(AppConstants.tokenExpiryKey); + if (expiry == null) return true; + return DateTime.now().millisecondsSinceEpoch > expiry; + } + + /// 清除所有 Token + Future clearTokens() async { + final prefs = await _preferences; + await prefs.remove(AppConstants.tokenKey); + await prefs.remove(AppConstants.refreshTokenKey); + await prefs.remove(AppConstants.tokenExpiryKey); + } + + /// 是否已登录 + Future isLoggedIn() async { + final token = await getToken(); + if (token == null) return false; + return !(await isTokenExpired()); + } +} diff --git a/mobile-app/lib/features/main_shell/pages/main_shell_page.dart b/mobile-app/lib/features/main_shell/pages/main_shell_page.dart new file mode 100644 index 00000000..67ee6870 --- /dev/null +++ b/mobile-app/lib/features/main_shell/pages/main_shell_page.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../../water_supply/pages/monitor_list_page.dart'; +import '../../patrol/pages/patrol_task_list_page.dart'; +import '../../revenue/pages/meter_reading_page.dart'; + +/// 主页面 - 底部三Tab导航(供水/巡检/营收) +class MainShellPage extends StatefulWidget { + const MainShellPage({super.key}); + + @override + State createState() => _MainShellPageState(); +} + +class _MainShellPageState extends State { + int _currentIndex = 0; + + final List _pages = const [ + MonitorListPage(), + PatrolTaskListPage(), + MeterReadingPage(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _pages, + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() { + _currentIndex = index; + }); + }, + type: BottomNavigationBarType.fixed, + selectedItemColor: const Color(0xFF1976D2), + unselectedItemColor: Colors.grey, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.water_drop), + label: '供水管理', + ), + BottomNavigationBarItem( + icon: Icon(Icons.assignment), + label: '巡检', + ), + BottomNavigationBarItem( + icon: Icon(Icons.receipt_long), + label: '营收', + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/patrol/models/patrol_task_model.dart b/mobile-app/lib/features/patrol/models/patrol_task_model.dart new file mode 100644 index 00000000..87912bd5 --- /dev/null +++ b/mobile-app/lib/features/patrol/models/patrol_task_model.dart @@ -0,0 +1,59 @@ +/// 巡检任务模型 +class PatrolTaskModel { + final String id; + final String taskName; + final String taskCode; + final String routeName; + final String assignee; + final String status; // pending, in_progress, completed, overdue + final DateTime planDate; + final int checkpointTotal; + final int checkpointCompleted; + final String? remark; + + PatrolTaskModel({ + required this.id, + required this.taskName, + required this.taskCode, + required this.routeName, + required this.assignee, + required this.status, + required this.planDate, + required this.checkpointTotal, + required this.checkpointCompleted, + this.remark, + }); + + double get progress => + checkpointTotal > 0 ? checkpointCompleted / checkpointTotal : 0; + + factory PatrolTaskModel.fromJson(Map json) { + return PatrolTaskModel( + id: json['id']?.toString() ?? '', + taskName: json['taskName'] ?? '', + taskCode: json['taskCode'] ?? '', + routeName: json['routeName'] ?? '', + assignee: json['assignee'] ?? '', + status: json['status'] ?? 'pending', + planDate: DateTime.tryParse(json['planDate'] ?? '') ?? DateTime.now(), + checkpointTotal: json['checkpointTotal'] ?? 0, + checkpointCompleted: json['checkpointCompleted'] ?? 0, + remark: json['remark'], + ); + } + + Map toJson() { + return { + 'id': id, + 'taskName': taskName, + 'taskCode': taskCode, + 'routeName': routeName, + 'assignee': assignee, + 'status': status, + 'planDate': planDate.toIso8601String(), + 'checkpointTotal': checkpointTotal, + 'checkpointCompleted': checkpointCompleted, + 'remark': remark, + }; + } +} diff --git a/mobile-app/lib/features/patrol/pages/patrol_task_list_page.dart b/mobile-app/lib/features/patrol/pages/patrol_task_list_page.dart new file mode 100644 index 00000000..0c016e43 --- /dev/null +++ b/mobile-app/lib/features/patrol/pages/patrol_task_list_page.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import '../models/patrol_task_model.dart'; + +/// 巡检任务列表页面 +class PatrolTaskListPage extends StatefulWidget { + const PatrolTaskListPage({super.key}); + + @override + State createState() => _PatrolTaskListPageState(); +} + +class _PatrolTaskListPageState extends State + with AutomaticKeepAliveClientMixin { + List _tasks = []; + bool _isLoading = true; + String _filterStatus = 'all'; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + Future.delayed(const Duration(milliseconds: 500), () { + setState(() { + _tasks = _generateMockData(); + _isLoading = false; + }); + }); + } + + List _generateMockData() { + return [ + PatrolTaskModel( + id: '1', taskName: '城东管网日常巡检', taskCode: 'PT-2024-001', + routeName: '城东A线', assignee: '张工', + status: 'in_progress', planDate: DateTime.now(), + checkpointTotal: 12, checkpointCompleted: 7, + ), + PatrolTaskModel( + id: '2', taskName: '城西水厂设备巡检', taskCode: 'PT-2024-002', + routeName: '城西厂区', assignee: '李工', + status: 'pending', planDate: DateTime.now().add(const Duration(days: 1)), + checkpointTotal: 8, checkpointCompleted: 0, + ), + PatrolTaskModel( + id: '3', taskName: '南区管网夜间巡检', taskCode: 'PT-2024-003', + routeName: '南区B线', assignee: '王工', + status: 'completed', planDate: DateTime.now().subtract(const Duration(days: 1)), + checkpointTotal: 10, checkpointCompleted: 10, + ), + PatrolTaskModel( + id: '4', taskName: '北区调蓄池安全巡检', taskCode: 'PT-2024-004', + routeName: '北区站点', assignee: '赵工', + status: 'overdue', planDate: DateTime.now().subtract(const Duration(days: 2)), + checkpointTotal: 6, checkpointCompleted: 2, + ), + PatrolTaskModel( + id: '5', taskName: '开发区阀门巡检', taskCode: 'PT-2024-005', + routeName: '开发区C线', assignee: '刘工', + status: 'pending', planDate: DateTime.now().add(const Duration(days: 2)), + checkpointTotal: 15, checkpointCompleted: 0, + ), + ]; + } + + List get _filteredTasks { + if (_filterStatus == 'all') return _tasks; + return _tasks.where((t) => t.status == _filterStatus).toList(); + } + + Color _getStatusColor(String status) { + switch (status) { + case 'pending': return Colors.blue; + case 'in_progress': return Colors.orange; + case 'completed': return Colors.green; + case 'overdue': return Colors.red; + default: return Colors.grey; + } + } + + String _getStatusText(String status) { + switch (status) { + case 'pending': return '待执行'; + case 'in_progress': return '进行中'; + case 'completed': return '已完成'; + case 'overdue': return '已超期'; + default: return status; + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + appBar: AppBar( + title: const Text('巡检任务'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + _buildFilterChip('全部', 'all'), + _buildFilterChip('待执行', 'pending'), + _buildFilterChip('进行中', 'in_progress'), + _buildFilterChip('已完成', 'completed'), + _buildFilterChip('超期', 'overdue'), + ], + ), + ), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredTasks.isEmpty + ? const Center(child: Text('暂无巡检任务')) + : ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _filteredTasks.length, + itemBuilder: (context, index) { + return _buildTaskCard(_filteredTasks[index]); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + // TODO: 新建巡检任务 + }, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildFilterChip(String label, String value) { + final isSelected = _filterStatus == value; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(label, style: const TextStyle(fontSize: 12)), + selected: isSelected, + onSelected: (selected) { + setState(() { + _filterStatus = value; + }); + }, + selectedColor: const Color(0xFF1976D2).withOpacity(0.2), + checkmarkColor: const Color(0xFF1976D2), + ), + ); + } + + Widget _buildTaskCard(PatrolTaskModel task) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.assignment, color: Color(0xFF1976D2), size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.taskName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + Text( + task.taskCode, + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getStatusColor(task.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _getStatusText(task.status), + style: TextStyle( + fontSize: 12, + color: _getStatusColor(task.status), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _buildInfoItem(Icons.route, '路线: ${task.routeName}'), + const SizedBox(width: 16), + _buildInfoItem(Icons.person, '巡检人: ${task.assignee}'), + ], + ), + const SizedBox(height: 8), + // 进度条 + Row( + children: [ + Text( + '巡检进度: ${task.checkpointCompleted}/${task.checkpointTotal}', + style: const TextStyle(fontSize: 12), + ), + const Spacer(), + Text( + '${(task.progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 12, + color: _getStatusColor(task.status), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: task.progress, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(_getStatusColor(task.status)), + minHeight: 6, + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoItem(IconData icon, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey[600]), + const SizedBox(width: 4), + Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[700])), + ], + ); + } +} diff --git a/mobile-app/lib/features/revenue/models/bill_model.dart b/mobile-app/lib/features/revenue/models/bill_model.dart new file mode 100644 index 00000000..666ab161 --- /dev/null +++ b/mobile-app/lib/features/revenue/models/bill_model.dart @@ -0,0 +1,64 @@ +/// 账单模型 +class BillModel { + final String id; + final String billNo; + final String userName; + final String userAddress; + final String meterNo; + final double previousReading; + final double currentReading; + final double usage; + final double amount; + final String status; // unpaid, paid, overdue + final String period; + final DateTime dueDate; + + BillModel({ + required this.id, + required this.billNo, + required this.userName, + required this.userAddress, + required this.meterNo, + required this.previousReading, + required this.currentReading, + required this.usage, + required this.amount, + required this.status, + required this.period, + required this.dueDate, + }); + + factory BillModel.fromJson(Map json) { + return BillModel( + id: json['id']?.toString() ?? '', + billNo: json['billNo'] ?? '', + userName: json['userName'] ?? '', + userAddress: json['userAddress'] ?? '', + meterNo: json['meterNo'] ?? '', + previousReading: (json['previousReading'] ?? 0).toDouble(), + currentReading: (json['currentReading'] ?? 0).toDouble(), + usage: (json['usage'] ?? 0).toDouble(), + amount: (json['amount'] ?? 0).toDouble(), + status: json['status'] ?? 'unpaid', + period: json['period'] ?? '', + dueDate: DateTime.tryParse(json['dueDate'] ?? '') ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'billNo': billNo, + 'userName': userName, + 'userAddress': userAddress, + 'meterNo': meterNo, + 'previousReading': previousReading, + 'currentReading': currentReading, + 'usage': usage, + 'amount': amount, + 'status': status, + 'period': period, + 'dueDate': dueDate.toIso8601String(), + }; + } +} diff --git a/mobile-app/lib/features/revenue/models/meter_reading_model.dart b/mobile-app/lib/features/revenue/models/meter_reading_model.dart new file mode 100644 index 00000000..118a33a9 --- /dev/null +++ b/mobile-app/lib/features/revenue/models/meter_reading_model.dart @@ -0,0 +1,59 @@ +/// 抄表记录模型 +class MeterReadingModel { + final String id; + final String meterNo; + final String userName; + final String address; + final double previousReading; + final double? currentReading; + final String status; // pending, completed, abnormal + final DateTime readingDate; + final String? reader; + final String? remark; + + MeterReadingModel({ + required this.id, + required this.meterNo, + required this.userName, + required this.address, + required this.previousReading, + this.currentReading, + required this.status, + required this.readingDate, + this.reader, + this.remark, + }); + + double? get usage => + currentReading != null ? currentReading! - previousReading : null; + + factory MeterReadingModel.fromJson(Map json) { + return MeterReadingModel( + id: json['id']?.toString() ?? '', + meterNo: json['meterNo'] ?? '', + userName: json['userName'] ?? '', + address: json['address'] ?? '', + previousReading: (json['previousReading'] ?? 0).toDouble(), + currentReading: json['currentReading']?.toDouble(), + status: json['status'] ?? 'pending', + readingDate: DateTime.tryParse(json['readingDate'] ?? '') ?? DateTime.now(), + reader: json['reader'], + remark: json['remark'], + ); + } + + Map toJson() { + return { + 'id': id, + 'meterNo': meterNo, + 'userName': userName, + 'address': address, + 'previousReading': previousReading, + 'currentReading': currentReading, + 'status': status, + 'readingDate': readingDate.toIso8601String(), + 'reader': reader, + 'remark': remark, + }; + } +} diff --git a/mobile-app/lib/features/revenue/pages/bill_list_page.dart b/mobile-app/lib/features/revenue/pages/bill_list_page.dart new file mode 100644 index 00000000..5beec55e --- /dev/null +++ b/mobile-app/lib/features/revenue/pages/bill_list_page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import '../models/bill_model.dart'; + +/// 账单列表页面 +class BillListPage extends StatefulWidget { + const BillListPage({super.key}); + + @override + State createState() => _BillListPageState(); +} + +class _BillListPageState extends State { + List _bills = []; + bool _isLoading = true; + String _filterStatus = 'all'; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + Future.delayed(const Duration(milliseconds: 500), () { + setState(() { + _bills = _generateMockData(); + _isLoading = false; + }); + }); + } + + List _generateMockData() { + return [ + BillModel( + id: '1', billNo: 'BILL-2024-001', userName: '张三', + userAddress: '城东街道12号', meterNo: 'WM-2024-001', + previousReading: 1250.5, currentReading: 1285.0, + usage: 34.5, amount: 138.0, status: 'unpaid', + period: '2024-01', dueDate: DateTime.now().add(const Duration(days: 15)), + ), + BillModel( + id: '2', billNo: 'BILL-2024-002', userName: '李四', + userAddress: '城西大道88号', meterNo: 'WM-2024-002', + previousReading: 3420.0, currentReading: 3456.0, + usage: 36.0, amount: 144.0, status: 'paid', + period: '2024-01', dueDate: DateTime.now().subtract(const Duration(days: 5)), + ), + BillModel( + id: '3', billNo: 'BILL-2024-003', userName: '王五', + userAddress: '南区花园5栋', meterNo: 'WM-2024-003', + previousReading: 890.0, currentReading: 932.0, + usage: 42.0, amount: 168.0, status: 'overdue', + period: '2023-12', dueDate: DateTime.now().subtract(const Duration(days: 10)), + ), + BillModel( + id: '4', billNo: 'BILL-2024-004', userName: '赵六', + userAddress: '北区商铺16号', meterNo: 'WM-2024-004', + previousReading: 5600.0, currentReading: 5780.0, + usage: 180.0, amount: 720.0, status: 'unpaid', + period: '2024-01', dueDate: DateTime.now().add(const Duration(days: 20)), + ), + ]; + } + + List get _filteredBills { + if (_filterStatus == 'all') return _bills; + return _bills.where((b) => b.status == _filterStatus).toList(); + } + + Color _getStatusColor(String status) { + switch (status) { + case 'unpaid': return Colors.orange; + case 'paid': return Colors.green; + case 'overdue': return Colors.red; + default: return Colors.grey; + } + } + + String _getStatusText(String status) { + switch (status) { + case 'unpaid': return '未缴费'; + case 'paid': return '已缴费'; + case 'overdue': return '已逾期'; + default: return status; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('账单列表'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + // 筛选 + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _buildFilterChip('全部', 'all'), + _buildFilterChip('未缴费', 'unpaid'), + _buildFilterChip('已缴费', 'paid'), + _buildFilterChip('已逾期', 'overdue'), + ], + ), + ), + // 列表 + Expanded( + child: _filteredBills.isEmpty + ? const Center(child: Text('暂无账单')) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: _filteredBills.length, + itemBuilder: (context, index) { + return _buildBillCard(_filteredBills[index]); + }, + ), + ), + ], + ), + ); + } + + Widget _buildFilterChip(String label, String value) { + final isSelected = _filterStatus == value; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(label, style: const TextStyle(fontSize: 12)), + selected: isSelected, + onSelected: (selected) { + setState(() { + _filterStatus = value; + }); + }, + selectedColor: const Color(0xFF1976D2).withOpacity(0.2), + checkmarkColor: const Color(0xFF1976D2), + ), + ); + } + + Widget _buildBillCard(BillModel bill) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.receipt, color: Color(0xFF1976D2), size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(bill.userName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text(bill.billNo, style: TextStyle(fontSize: 12, color: Colors.grey[500])), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getStatusColor(bill.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _getStatusText(bill.status), + style: TextStyle(fontSize: 12, color: _getStatusColor(bill.status)), + ), + ), + ], + ), + const Divider(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildDetailItem('用水量', '${bill.usage.toStringAsFixed(1)} m³'), + _buildDetailItem('水费', '¥${bill.amount.toStringAsFixed(2)}'), + _buildDetailItem('账期', bill.period), + ], + ), + const SizedBox(height: 8), + Text( + '地址: ${bill.userAddress}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + if (bill.status != 'paid') ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: () { + // TODO: 缴费操作 + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 36), + ), + child: const Text('去缴费'), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildDetailItem(String label, String value) { + return Column( + children: [ + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ], + ); + } +} diff --git a/mobile-app/lib/features/revenue/pages/meter_reading_page.dart b/mobile-app/lib/features/revenue/pages/meter_reading_page.dart new file mode 100644 index 00000000..8eb149a4 --- /dev/null +++ b/mobile-app/lib/features/revenue/pages/meter_reading_page.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import '../models/meter_reading_model.dart'; +import 'bill_list_page.dart'; + +/// 抄表页面(营收 Tab 入口,包含抄表列表和账单入口) +class MeterReadingPage extends StatefulWidget { + const MeterReadingPage({super.key}); + + @override + State createState() => _MeterReadingPageState(); +} + +class _MeterReadingPageState extends State + with AutomaticKeepAliveClientMixin { + List _readings = []; + bool _isLoading = true; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + Future.delayed(const Duration(milliseconds: 500), () { + setState(() { + _readings = _generateMockData(); + _isLoading = false; + }); + }); + } + + List _generateMockData() { + return [ + MeterReadingModel( + id: '1', meterNo: 'WM-2024-001', userName: '张三', + address: '城东街道12号', previousReading: 1250.5, + status: 'pending', readingDate: DateTime.now(), + ), + MeterReadingModel( + id: '2', meterNo: 'WM-2024-002', userName: '李四', + address: '城西大道88号', previousReading: 3420.0, + currentReading: 3456.0, status: 'completed', + readingDate: DateTime.now().subtract(const Duration(days: 1)), + reader: '王工', + ), + MeterReadingModel( + id: '3', meterNo: 'WM-2024-003', userName: '王五', + address: '南区花园5栋', previousReading: 890.0, + status: 'pending', readingDate: DateTime.now(), + ), + MeterReadingModel( + id: '4', meterNo: 'WM-2024-004', userName: '赵六', + address: '北区商铺16号', previousReading: 5600.0, + currentReading: 5780.0, status: 'completed', + readingDate: DateTime.now().subtract(const Duration(days: 2)), + reader: '李工', + ), + MeterReadingModel( + id: '5', meterNo: 'WM-2024-005', userName: '孙七', + address: '中心路28号', previousReading: 2100.0, + status: 'abnormal', readingDate: DateTime.now(), + remark: '水表损坏', + ), + ]; + } + + Color _getStatusColor(String status) { + switch (status) { + case 'pending': return Colors.orange; + case 'completed': return Colors.green; + case 'abnormal': return Colors.red; + default: return Colors.grey; + } + } + + String _getStatusText(String status) { + switch (status) { + case 'pending': return '待抄表'; + case 'completed': return '已完成'; + case 'abnormal': return '异常'; + default: return status; + } + } + + void _showReadingDialog(MeterReadingModel reading) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('抄表 - ${reading.userName}'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('表号: ${reading.meterNo}'), + Text('上次读数: ${reading.previousReading}'), + const SizedBox(height: 12), + TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: '当前读数', + hintText: '请输入当前水表读数', + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + ElevatedButton( + onPressed: () { + // TODO: 提交抄表数据 + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('抄表数据已提交')), + ); + }, + child: const Text('提交'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + appBar: AppBar( + title: const Text('营收管理'), + actions: [ + IconButton( + icon: const Icon(Icons.receipt_long), + tooltip: '账单列表', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BillListPage()), + ); + }, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 统计卡片 + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _buildStatCard('待抄表', '${_readings.where((r) => r.status == 'pending').length}', Colors.orange), + const SizedBox(width: 8), + _buildStatCard('已完成', '${_readings.where((r) => r.status == 'completed').length}', Colors.green), + const SizedBox(width: 8), + _buildStatCard('异常', '${_readings.where((r) => r.status == 'abnormal').length}', Colors.red), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text('抄表任务', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + // 抄表列表 + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _readings.length, + itemBuilder: (context, index) { + final reading = _readings[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: _getStatusColor(reading.status).withOpacity(0.1), + child: Icon( + Icons.speed, + color: _getStatusColor(reading.status), + ), + ), + title: Text(reading.userName), + subtitle: Text('${reading.meterNo} · ${reading.address}'), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _getStatusText(reading.status), + style: TextStyle( + fontSize: 12, + color: _getStatusColor(reading.status), + fontWeight: FontWeight.bold, + ), + ), + if (reading.status == 'pending') + TextButton( + onPressed: () => _showReadingDialog(reading), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('抄表', style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildStatCard(String label, String count, Color color) { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Text(count, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + ], + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/water_supply/models/monitor_data_model.dart b/mobile-app/lib/features/water_supply/models/monitor_data_model.dart new file mode 100644 index 00000000..f88f06db --- /dev/null +++ b/mobile-app/lib/features/water_supply/models/monitor_data_model.dart @@ -0,0 +1,48 @@ +/// 监测数据模型 +class MonitorDataModel { + final String id; + final String stationName; + final String stationCode; + final double pressure; + final double flow; + final double quality; + final String status; + final DateTime updateTime; + + MonitorDataModel({ + required this.id, + required this.stationName, + required this.stationCode, + required this.pressure, + required this.flow, + required this.quality, + required this.status, + required this.updateTime, + }); + + factory MonitorDataModel.fromJson(Map json) { + return MonitorDataModel( + id: json['id']?.toString() ?? '', + stationName: json['stationName'] ?? '', + stationCode: json['stationCode'] ?? '', + pressure: (json['pressure'] ?? 0).toDouble(), + flow: (json['flow'] ?? 0).toDouble(), + quality: (json['quality'] ?? 0).toDouble(), + status: json['status'] ?? 'normal', + updateTime: DateTime.tryParse(json['updateTime'] ?? '') ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'id': id, + 'stationName': stationName, + 'stationCode': stationCode, + 'pressure': pressure, + 'flow': flow, + 'quality': quality, + 'status': status, + 'updateTime': updateTime.toIso8601String(), + }; + } +} diff --git a/mobile-app/lib/features/water_supply/pages/monitor_list_page.dart b/mobile-app/lib/features/water_supply/pages/monitor_list_page.dart new file mode 100644 index 00000000..6bf7ca17 --- /dev/null +++ b/mobile-app/lib/features/water_supply/pages/monitor_list_page.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import '../models/monitor_data_model.dart'; + +/// 供水监测数据列表页面 +class MonitorListPage extends StatefulWidget { + const MonitorListPage({super.key}); + + @override + State createState() => _MonitorListPageState(); +} + +class _MonitorListPageState extends State + with AutomaticKeepAliveClientMixin { + List _monitorData = []; + bool _isLoading = true; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + // 模拟数据加载 + Future.delayed(const Duration(milliseconds: 500), () { + setState(() { + _monitorData = _generateMockData(); + _isLoading = false; + }); + }); + } + + List _generateMockData() { + final stations = [ + ('城东加压站', 'ST-001'), + ('城西水厂', 'ST-002'), + ('南区配水站', 'ST-003'), + ('北区调蓄池', 'ST-004'), + ('中心泵站', 'ST-005'), + ('开发区监测点', 'ST-006'), + ('高新区水厂', 'ST-007'), + ('工业园加压站', 'ST-008'), + ]; + + return stations.map((station) { + return MonitorDataModel( + id: station.$2, + stationName: station.$1, + stationCode: station.$2, + pressure: 0.2 + (station.$2.hashCode % 30) / 100.0, + flow: 100 + (station.$2.hashCode % 500).toDouble(), + quality: 95 + (station.$2.hashCode % 5).toDouble(), + status: station.$2.hashCode % 7 == 0 ? 'warning' : 'normal', + updateTime: DateTime.now().subtract( + Duration(minutes: station.$2.hashCode % 60), + ), + ); + }).toList(); + } + + Color _getStatusColor(String status) { + switch (status) { + case 'normal': + return Colors.green; + case 'warning': + return Colors.orange; + case 'error': + return Colors.red; + default: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + appBar: AppBar( + title: const Text('供水监测'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + setState(() { + _isLoading = true; + }); + _loadData(); + }, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: () async { + setState(() { + _isLoading = true; + }); + _loadData(); + }, + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _monitorData.length, + itemBuilder: (context, index) { + final item = _monitorData[index]; + return _buildMonitorCard(item); + }, + ), + ), + ); + } + + Widget _buildMonitorCard(MonitorDataModel item) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.water_drop, color: Color(0xFF1976D2), size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + item.stationName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getStatusColor(item.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getStatusColor(item.status), + ), + ), + child: Text( + item.status == 'normal' ? '正常' : '告警', + style: TextStyle( + fontSize: 12, + color: _getStatusColor(item.status), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _buildDataItem('水压', '${item.pressure.toStringAsFixed(2)} MPa'), + _buildDataItem('流量', '${item.flow.toStringAsFixed(1)} m³/h'), + _buildDataItem('水质', '${item.quality.toStringAsFixed(1)}%'), + ], + ), + const SizedBox(height: 8), + Text( + '更新时间: ${item.updateTime.month}/${item.updateTime.day} ' + '${item.updateTime.hour.toString().padLeft(2, '0')}:' + '${item.updateTime.minute.toString().padLeft(2, '0')}', + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + ], + ), + ), + ); + } + + Widget _buildDataItem(String label, String value) { + return Expanded( + child: Column( + children: [ + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/main.dart b/mobile-app/lib/main.dart new file mode 100644 index 00000000..6c533c05 --- /dev/null +++ b/mobile-app/lib/main.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'core/theme/app_theme.dart'; +import 'config/app_routes.dart'; +import 'features/auth/services/auth_provider.dart'; +import 'shared/services/push_service.dart'; +import 'shared/services/cache_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 强制竖屏 + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + // 设置状态栏样式 + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + ), + ); + + // 初始化服务 + await PushService().initialize(); + await CacheService().initialize(); + + runApp(const WaterManagementApp()); +} + +/// 供水管理系统三合一APP +class WaterManagementApp extends StatelessWidget { + const WaterManagementApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => AuthProvider()..init(), + ), + ], + child: MaterialApp.router( + title: '供水管理系统', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + routerConfig: AppRoutes.createRouter(), + ), + ); + } +} diff --git a/mobile-app/lib/shared/services/cache_service.dart b/mobile-app/lib/shared/services/cache_service.dart new file mode 100644 index 00000000..90ef7eea --- /dev/null +++ b/mobile-app/lib/shared/services/cache_service.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +/// 离线缓存服务 +/// 基于 Hive 提供本地数据存储能力 +class CacheService { + static CacheService? _instance; + final Map _memoryCache = {}; + bool _isInitialized = false; + + CacheService._internal(); + + factory CacheService() { + _instance ??= CacheService._internal(); + return _instance!; + } + + /// 初始化缓存(打开 Hive Box) + Future initialize() async { + if (_isInitialized) return; + + try { + // TODO: 初始化 Hive + // await Hive.initFlutter(); + // await Hive.openBox(AppConstants.cacheBoxName); + // await Hive.openBox(AppConstants.userBoxName); + _isInitialized = true; + print('[CacheService] 缓存服务已初始化(模拟)'); + } catch (e) { + print('[CacheService] 初始化失败: $e'); + } + } + + /// 写入缓存 + Future put(String key, dynamic value, {String box = 'default'}) async { + try { + // TODO: 使用 Hive 持久化 + // final cacheBox = Hive.box(AppConstants.cacheBoxName); + // await cacheBox.put(key, value); + _memoryCache['$box:$key'] = value; + } catch (e) { + print('[CacheService] 写入缓存失败: $e'); + } + } + + /// 读取缓存 + Future get(String key, {String box = 'default'}) async { + try { + // TODO: 使用 Hive 读取 + // final cacheBox = Hive.box(AppConstants.cacheBoxName); + // return cacheBox.get(key) as T?; + return _memoryCache['$box:$key'] as T?; + } catch (e) { + print('[CacheService] 读取缓存失败: $e'); + return null; + } + } + + /// 删除缓存 + Future delete(String key, {String box = 'default'}) async { + try { + _memoryCache.remove('$box:$key'); + } catch (e) { + print('[CacheService] 删除缓存失败: $e'); + } + } + + /// 清空指定 Box 的所有缓存 + Future clearBox({String box = 'default'}) async { + try { + _memoryCache.removeWhere((key, _) => key.startsWith('$box:')); + } catch (e) { + print('[CacheService] 清空缓存失败: $e'); + } + } + + /// 清空所有缓存 + Future clearAll() async { + try { + _memoryCache.clear(); + } catch (e) { + print('[CacheService] 清空所有缓存失败: $e'); + } + } + + /// 缓存 JSON 对象 + Future cacheJson(String key, Map data, {String box = 'default'}) async { + await put(key, json.encode(data), box: box); + } + + /// 读取缓存的 JSON 对象 + Future?> getCachedJson(String key, {String box = 'default'}) async { + final data = await get(key, box: box); + if (data == null) return null; + try { + return json.decode(data) as Map; + } catch (e) { + return null; + } + } + + /// 缓存列表数据 + Future cacheList(String key, List> items, {String box = 'default'}) async { + await put(key, json.encode(items), box: box); + } + + /// 读取缓存的列表数据 + Future>?> getCachedList(String key, {String box = 'default'}) async { + final data = await get(key, box: box); + if (data == null) return null; + try { + final decoded = json.decode(data) as List; + return decoded.cast>(); + } catch (e) { + return null; + } + } + + /// 检查是否有缓存 + Future has(String key, {String box = 'default'}) async { + return _memoryCache.containsKey('$box:$key'); + } +} diff --git a/mobile-app/lib/shared/services/camera_service.dart b/mobile-app/lib/shared/services/camera_service.dart new file mode 100644 index 00000000..f112d25d --- /dev/null +++ b/mobile-app/lib/shared/services/camera_service.dart @@ -0,0 +1,141 @@ +import 'dart:io'; + +/// 拍照/相册服务 +/// 提供拍照和从相册选择图片的功能 +class CameraService { + static CameraService? _instance; + + CameraService._internal(); + + factory CameraService() { + _instance ??= CameraService._internal(); + return _instance!; + } + + /// 拍照 + Future takePicture() async { + try { + // TODO: 使用 image_picker 拍照 + // final picker = ImagePicker(); + // final XFile? image = await picker.pickImage( + // source: ImageSource.camera, + // maxWidth: 1920, + // maxHeight: 1080, + // imageQuality: 85, + // ); + // if (image != null) { + // return CapturedImage( + // path: image.path, + // name: image.name, + // size: await image.length(), + // ); + // } + + // 模拟拍照结果 + print('[CameraService] 拍照(模拟)'); + return CapturedImage( + path: '/mock/camera/photo_${DateTime.now().millisecondsSinceEpoch}.jpg', + name: 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg', + size: 1024 * 1024, // 1MB + source: ImageSource.camera, + ); + } catch (e) { + print('[CameraService] 拍照失败: $e'); + return null; + } + } + + /// 从相册选择图片 + Future pickFromGallery() async { + try { + // TODO: 使用 image_picker 从相册选择 + // final picker = ImagePicker(); + // final XFile? image = await picker.pickImage( + // source: ImageSource.gallery, + // maxWidth: 1920, + // maxHeight: 1080, + // imageQuality: 85, + // ); + // if (image != null) { + // return CapturedImage( + // path: image.path, + // name: image.name, + // size: await image.length(), + // ); + // } + + // 模拟选择结果 + print('[CameraService] 从相册选择(模拟)'); + return CapturedImage( + path: '/mock/gallery/image_${DateTime.now().millisecondsSinceEpoch}.jpg', + name: 'image_${DateTime.now().millisecondsSinceEpoch}.jpg', + size: 2 * 1024 * 1024, // 2MB + source: ImageSource.gallery, + ); + } catch (e) { + print('[CameraService] 选择图片失败: $e'); + return null; + } + } + + /// 选择多张图片 + Future> pickMultipleImages() async { + try { + // TODO: 使用 image_picker 多选 + // final picker = ImagePicker(); + // final List images = await picker.pickMultiImage(); + // return images.map((img) => CapturedImage(...)).toList(); + + print('[CameraService] 多选图片(模拟)'); + return []; + } catch (e) { + print('[CameraService] 多选图片失败: $e'); + return []; + } + } + + /// 上传图片文件 + Future uploadImage(File file, {String? uploadPath}) async { + try { + // TODO: 使用 Dio 上传文件 + // final formData = FormData.fromMap({ + // 'file': await MultipartFile.fromFile(file.path), + // }); + // final response = await DioClient().post('/upload', data: formData); + // return response.data['data']['url']; + + print('[CameraService] 上传图片(模拟): ${file.path}'); + return 'https://api.example.com/uploads/mock_image.jpg'; + } catch (e) { + print('[CameraService] 上传图片失败: $e'); + return null; + } + } +} + +/// 拍摄/选择的图片模型 +class CapturedImage { + final String path; + final String name; + final int size; + final ImageSource source; + + CapturedImage({ + required this.path, + required this.name, + required this.size, + required this.source, + }); + + String get sizeFormatted { + if (size < 1024) return '$size B'; + if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB'; + return '${(size / 1024 / 1024).toStringAsFixed(1)} MB'; + } +} + +/// 图片来源 +enum ImageSource { + camera, + gallery, +} diff --git a/mobile-app/lib/shared/services/location_service.dart b/mobile-app/lib/shared/services/location_service.dart new file mode 100644 index 00000000..89f67292 --- /dev/null +++ b/mobile-app/lib/shared/services/location_service.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +/// GPS 定位服务 +/// 提供获取当前位置、权限请求等功能 +class LocationService { + static LocationService? _instance; + + LocationService._internal(); + + factory LocationService() { + _instance ??= LocationService._internal(); + return _instance!; + } + + /// 请求位置权限 + Future requestPermission() async { + // TODO: 使用 geolocator 请求权限 + // final permission = await Geolocator.requestPermission(); + // return permission == LocationPermission.always || permission == LocationPermission.whileInUse; + print('[LocationService] 请求位置权限(模拟)'); + return true; + } + + /// 检查位置权限状态 + Future hasPermission() async { + // TODO: 使用 geolocator 检查权限 + return true; + } + + /// 检查 GPS 是否开启 + Future isGpsEnabled() async { + // TODO: 使用 geolocator 检查 GPS 状态 + // return await Geolocator.isLocationServiceEnabled(); + return true; + } + + /// 获取当前位置 + Future getCurrentLocation() async { + try { + final hasPermission = await requestPermission(); + if (!hasPermission) return null; + + // TODO: 使用 geolocator 获取位置 + // final position = await Geolocator.getCurrentPosition( + // desiredAccuracy: LocationAccuracy.high, + // ); + // return LocationData( + // latitude: position.latitude, + // longitude: position.longitude, + // accuracy: position.accuracy, + // ); + + // 模拟位置数据(杭州市中心) + return LocationData( + latitude: 30.2741, + longitude: 120.1551, + accuracy: 10.0, + altitude: 15.0, + timestamp: DateTime.now(), + ); + } catch (e) { + print('[LocationService] 获取位置失败: $e'); + return null; + } + } + + /// 持续监听位置变化 + Stream watchLocation() async* { + // TODO: 使用 geolocator 的位置流 + // final positionStream = Geolocator.getPositionStream( + // locationSettings: LocationSettings(accuracy: LocationAccuracy.high), + // ); + // await for (final position in positionStream) { + // yield LocationData.fromPosition(position); + // } + + // 模拟位置流 + while (true) { + await Future.delayed(const Duration(seconds: 5)); + yield LocationData( + latitude: 30.2741 + (DateTime.now().millisecondsSinceEpoch % 100) / 10000, + longitude: 120.1551 + (DateTime.now().millisecondsSinceEpoch % 100) / 10000, + accuracy: 10.0, + timestamp: DateTime.now(), + ); + } + } + + /// 计算两点之间的距离(米) + double distanceBetween( + double startLatitude, + double startLongitude, + double endLatitude, + double endLongitude, + ) { + // TODO: 使用 geolocator 计算距离 + // return Geolocator.distanceBetween(startLatitude, startLongitude, endLatitude, endLongitude); + // 简单估算 + const earthRadius = 6371000.0; + final dLat = (endLatitude - startLatitude) * 3.14159 / 180; + final dLon = (endLongitude - startLongitude) * 3.14159 / 180; + final a = dLat * dLat + dLon * dLon * 0.25; + return earthRadius * 2 * a * 1000; + } +} + +/// 位置数据模型 +class LocationData { + final double latitude; + final double longitude; + final double accuracy; + final double? altitude; + final DateTime timestamp; + + LocationData({ + required this.latitude, + required this.longitude, + required this.accuracy, + this.altitude, + required this.timestamp, + }); + + @override + String toString() => 'LocationData(lat: $latitude, lng: $longitude, accuracy: $accuracy)'; +} diff --git a/mobile-app/lib/shared/services/push_service.dart b/mobile-app/lib/shared/services/push_service.dart new file mode 100644 index 00000000..7ae38aa8 --- /dev/null +++ b/mobile-app/lib/shared/services/push_service.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +/// 消息推送服务(模拟) +/// 提供推送初始化、消息接收回调等功能 +class PushService { + static PushService? _instance; + final StreamController _messageController = StreamController.broadcast(); + + /// 推送消息流 + Stream get messageStream => _messageController.stream; + + PushService._internal(); + + factory PushService() { + _instance ??= PushService._internal(); + return _instance!; + } + + /// 初始化推送服务 + Future initialize() async { + // TODO: 集成 flutter_local_notifications 或 firebase_messaging + // 1. 初始化通知插件 + // 2. 请求通知权限 + // 3. 获取设备推送 Token + // 4. 注册推送 Token 到后端 + print('[PushService] 推送服务已初始化(模拟)'); + + // 模拟延迟推送消息 + _simulatePushMessages(); + } + + /// 获取设备推送 Token + Future getDeviceToken() async { + // TODO: 获取实际的推送 Token + return 'mock_device_token_${DateTime.now().millisecondsSinceEpoch}'; + } + + /// 注册推送 Token 到后端 + Future registerToken(String token) async { + // TODO: 调用后端接口注册 Token + print('[PushService] Token 已注册: $token'); + } + + /// 处理接收到的推送消息 + void onMessageReceived(Map data) { + final message = PushMessage( + id: data['id']?.toString() ?? '', + title: data['title'] ?? '', + body: data['body'] ?? '', + type: data['type'] ?? 'general', + data: data, + timestamp: DateTime.now(), + ); + _messageController.add(message); + } + + /// 显示本地通知 + Future showLocalNotification({ + required String title, + required String body, + String? payload, + }) async { + // TODO: 使用 flutter_local_notifications 显示本地通知 + print('[PushService] 本地通知: $title - $body'); + } + + /// 模拟推送消息 + void _simulatePushMessages() { + Future.delayed(const Duration(seconds: 10), () { + _messageController.add(PushMessage( + id: 'mock_1', + title: '巡检提醒', + body: '您有一条新的巡检任务待执行', + type: 'patrol', + data: {}, + timestamp: DateTime.now(), + )); + }); + } + + /// 释放资源 + void dispose() { + _messageController.close(); + } +} + +/// 推送消息模型 +class PushMessage { + final String id; + final String title; + final String body; + final String type; + final Map data; + final DateTime timestamp; + + PushMessage({ + required this.id, + required this.title, + required this.body, + required this.type, + required this.data, + required this.timestamp, + }); +} diff --git a/mobile-app/lib/shared/widgets/empty_state.dart b/mobile-app/lib/shared/widgets/empty_state.dart new file mode 100644 index 00000000..8b1dbc1c --- /dev/null +++ b/mobile-app/lib/shared/widgets/empty_state.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +/// 空状态组件 +class EmptyState extends StatelessWidget { + final IconData icon; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + const EmptyState({ + super.key, + this.icon = Icons.inbox, + required this.message, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 16), + ElevatedButton( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/shared/widgets/error_retry.dart b/mobile-app/lib/shared/widgets/error_retry.dart new file mode 100644 index 00000000..7b1d2610 --- /dev/null +++ b/mobile-app/lib/shared/widgets/error_retry.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// 错误重试组件 +class ErrorRetry extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const ErrorRetry({ + super.key, + required this.message, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red[300]), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey[700]), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('重试'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/shared/widgets/loading_overlay.dart b/mobile-app/lib/shared/widgets/loading_overlay.dart new file mode 100644 index 00000000..b9da3779 --- /dev/null +++ b/mobile-app/lib/shared/widgets/loading_overlay.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +/// 加载遮罩组件 +class LoadingOverlay extends StatelessWidget { + final bool isLoading; + final Widget child; + final String? message; + + const LoadingOverlay({ + super.key, + required this.isLoading, + required this.child, + this.message, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + if (isLoading) + Container( + color: Colors.black.withOpacity(0.3), + child: Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + if (message != null) ...[ + const SizedBox(height: 16), + Text(message!), + ], + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml new file mode 100644 index 00000000..3e30b72f --- /dev/null +++ b/mobile-app/pubspec.yaml @@ -0,0 +1,38 @@ +name: water_management_app +description: 供水管理系统三合一移动端APP(供水/巡检/营收) +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.6 + dio: ^5.4.0 + go_router: ^13.0.0 + provider: ^6.1.1 + shared_preferences: ^2.2.2 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + geolocator: ^10.1.0 + image_picker: ^1.0.4 + flutter_local_notifications: ^17.0.0 + json_annotation: ^4.8.1 + intl: ^0.19.0 + cached_network_image: ^3.3.0 + pull_to_refresh: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + build_runner: ^2.4.7 + json_serializable: ^6.7.1 + hive_generator: ^2.0.1 + +flutter: + uses-material-design: true + assets: + - assets/images/