ソースを参照

feat(mobile): #24 Flutter三合一APP骨架搭建

- Flutter 3.x 项目初始化(pubspec.yaml + 目录结构)
- 核心依赖:dio/go_router/provider/shared_preferences/hive/geolocator
- 统一登录页 + Token管理(TokenService + AuthInterceptor)
- 底部Tab三合一导航(供水/巡检/营收)
- 供水管理Tab:监测数据列表(MonitorListPage)
- 巡检Tab:任务列表+状态筛选+进度展示(PatrolTaskListPage)
- 营收Tab:抄表页面+账单列表(MeterReadingPage/BillListPage)
- 消息推送服务(PushService)
- GPS定位服务(LocationService)
- 拍照/相册服务(CameraService)
- 离线缓存服务(CacheService)
- Android/iOS打包配置+权限声明
- GoRouter路由+Auth认证守卫
- Provider状态管理
bot_dev2 4 日 前
コミット
05b2ccd2a4
共有41 個のファイルを変更した3014 個の追加0 個の削除を含む
  1. 43
    0
      mobile-app/.gitignore
  2. 90
    0
      mobile-app/README.md
  3. 7
    0
      mobile-app/analysis_options.yaml
  4. 65
    0
      mobile-app/android/app/build.gradle
  5. 57
    0
      mobile-app/android/app/src/main/AndroidManifest.xml
  6. 6
    0
      mobile-app/android/app/src/main/java/com/example/water_management/MainActivity.kt
  7. 9
    0
      mobile-app/android/app/src/main/res/values/styles.xml
  8. 18
    0
      mobile-app/android/build.gradle
  9. 26
    0
      mobile-app/android/settings.gradle
  10. 0
    0
      mobile-app/assets/images/.gitkeep
  11. 13
    0
      mobile-app/ios/Runner/AppDelegate.swift
  12. 60
    0
      mobile-app/ios/Runner/Info.plist
  13. 39
    0
      mobile-app/lib/config/app_config.dart
  14. 42
    0
      mobile-app/lib/config/app_routes.dart
  15. 25
    0
      mobile-app/lib/core/constants/app_constants.dart
  16. 39
    0
      mobile-app/lib/core/network/api_response.dart
  17. 41
    0
      mobile-app/lib/core/network/auth_interceptor.dart
  18. 79
    0
      mobile-app/lib/core/network/dio_client.dart
  19. 52
    0
      mobile-app/lib/core/theme/app_theme.dart
  20. 44
    0
      mobile-app/lib/features/auth/models/user_model.dart
  21. 173
    0
      mobile-app/lib/features/auth/pages/login_page.dart
  22. 82
    0
      mobile-app/lib/features/auth/services/auth_provider.dart
  23. 90
    0
      mobile-app/lib/features/auth/services/token_service.dart
  24. 57
    0
      mobile-app/lib/features/main_shell/pages/main_shell_page.dart
  25. 59
    0
      mobile-app/lib/features/patrol/models/patrol_task_model.dart
  26. 255
    0
      mobile-app/lib/features/patrol/pages/patrol_task_list_page.dart
  27. 64
    0
      mobile-app/lib/features/revenue/models/bill_model.dart
  28. 59
    0
      mobile-app/lib/features/revenue/models/meter_reading_model.dart
  29. 223
    0
      mobile-app/lib/features/revenue/pages/bill_list_page.dart
  30. 238
    0
      mobile-app/lib/features/revenue/pages/meter_reading_page.dart
  31. 48
    0
      mobile-app/lib/features/water_supply/models/monitor_data_model.dart
  32. 194
    0
      mobile-app/lib/features/water_supply/pages/monitor_list_page.dart
  33. 54
    0
      mobile-app/lib/main.dart
  34. 122
    0
      mobile-app/lib/shared/services/cache_service.dart
  35. 141
    0
      mobile-app/lib/shared/services/camera_service.dart
  36. 125
    0
      mobile-app/lib/shared/services/location_service.dart
  37. 104
    0
      mobile-app/lib/shared/services/push_service.dart
  38. 48
    0
      mobile-app/lib/shared/widgets/empty_state.dart
  39. 40
    0
      mobile-app/lib/shared/widgets/error_retry.dart
  40. 45
    0
      mobile-app/lib/shared/widgets/loading_overlay.dart
  41. 38
    0
      mobile-app/pubspec.yaml

+ 43
- 0
mobile-app/.gitignore ファイルの表示

@@ -0,0 +1,43 @@
1
+# Miscellaneous
2
+*.class
3
+*.log
4
+*.pyc
5
+*.swp
6
+.DS_Store
7
+.atom/
8
+.buildlog/
9
+.history
10
+.svn/
11
+migrate_working_dir/
12
+
13
+# IntelliJ related
14
+*.iml
15
+*.ipr
16
+*.iws
17
+.idea/
18
+
19
+# Flutter/Dart/Pub related
20
+**/doc/api/
21
+**/ios/Flutter/.last_build_id
22
+.dart_tool/
23
+.flutter-plugins
24
+.flutter-plugins-dependencies
25
+.packages
26
+.pub-cache/
27
+.pub/
28
+/build/
29
+
30
+# Symbolication related
31
+app.*.symbols
32
+
33
+# Obfuscation related
34
+app.*.map.json
35
+
36
+# Android Studio will place build artifacts here
37
+/android/app/debug
38
+/android/app/profile
39
+/android/app/release
40
+
41
+# Generated files
42
+*.g.dart
43
+*.freezed.dart

+ 90
- 0
mobile-app/README.md ファイルの表示

@@ -0,0 +1,90 @@
1
+# 供水管理系统 - 移动端APP
2
+
3
+三合一移动应用:供水管理 / 巡检管理 / 营收管理
4
+
5
+## 技术栈
6
+
7
+- **框架**: Flutter 3.x
8
+- **状态管理**: Provider
9
+- **路由**: GoRouter
10
+- **HTTP**: Dio
11
+- **本地存储**: Hive + SharedPreferences
12
+- **定位**: Geolocator
13
+- **相机**: Image Picker
14
+- **通知**: Flutter Local Notifications
15
+
16
+## 项目结构
17
+
18
+```
19
+mobile-app/
20
+├── lib/
21
+│   ├── main.dart                    # 入口文件
22
+│   ├── core/                        # 核心模块
23
+│   │   ├── theme/                   # 主题配置
24
+│   │   ├── constants/               # 常量定义
25
+│   │   ├── network/                 # 网络层(Dio + 拦截器)
26
+│   │   └── utils/                   # 工具类
27
+│   ├── features/                    # 功能模块
28
+│   │   ├── auth/                    # 认证(登录/Token)
29
+│   │   ├── main_shell/              # 主框架(Tab导航)
30
+│   │   ├── water_supply/            # 供水管理
31
+│   │   ├── patrol/                  # 巡检管理
32
+│   │   └── revenue/                 # 营收管理
33
+│   ├── shared/                      # 共享模块
34
+│   │   ├── widgets/                 # 通用组件
35
+│   │   └── services/                # 共享服务
36
+│   └── config/                      # 配置(路由等)
37
+├── android/                         # Android 配置
38
+├── ios/                             # iOS 配置
39
+└── pubspec.yaml                     # 依赖配置
40
+```
41
+
42
+## 功能模块
43
+
44
+### 1. 统一登录 + Token 管理
45
+- 账号密码登录
46
+- Token 自动附加(AuthInterceptor)
47
+- Token 刷新机制
48
+- 登录状态持久化
49
+
50
+### 2. 三合一 Tab 导航
51
+- 供水管理 Tab
52
+- 巡检管理 Tab
53
+- 营收管理 Tab
54
+
55
+### 3. 供水管理
56
+- 监测站点列表
57
+- 实时水压/流量/水质数据
58
+- 状态告警
59
+
60
+### 4. 巡检管理
61
+- 巡检任务列表(按状态筛选)
62
+- 任务进度展示
63
+- 支持新建巡检任务
64
+
65
+### 5. 营收管理
66
+- 抄表任务列表
67
+- 账单列表(缴费状态筛选)
68
+- 抄表数据录入
69
+
70
+### 6. 核心服务
71
+- **PushService**: 消息推送(初始化 + 回调)
72
+- **LocationService**: GPS定位 + 权限管理
73
+- **CameraService**: 拍照/相册选择
74
+- **CacheService**: 离线缓存(Hive)
75
+
76
+## 运行
77
+
78
+```bash
79
+# 安装依赖
80
+flutter pub get
81
+
82
+# 运行开发模式
83
+flutter run
84
+
85
+# 构建 APK
86
+flutter build apk
87
+
88
+# 构建 iOS
89
+flutter build ios
90
+```

+ 7
- 0
mobile-app/analysis_options.yaml ファイルの表示

@@ -0,0 +1,7 @@
1
+include: package:flutter_lints/flutter.yaml
2
+
3
+linter:
4
+  rules:
5
+    prefer_const_constructors: true
6
+    prefer_const_declarations: true
7
+    avoid_print: false

+ 65
- 0
mobile-app/android/app/build.gradle ファイルの表示

@@ -0,0 +1,65 @@
1
+plugins {
2
+    id "com.android.application"
3
+    id "kotlin-android"
4
+    id "dev.flutter.flutter-gradle-plugin"
5
+}
6
+
7
+def localProperties = new Properties()
8
+def localPropertiesFile = rootProject.file('local.properties')
9
+if (localPropertiesFile.exists()) {
10
+    localPropertiesFile.withReader('UTF-8') { reader ->
11
+        localProperties.load(reader)
12
+    }
13
+}
14
+
15
+def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
16
+if (flutterVersionCode == null) {
17
+    flutterVersionCode = '1'
18
+}
19
+
20
+def flutterVersionName = localProperties.getProperty('flutter.versionName')
21
+if (flutterVersionName == null) {
22
+    flutterVersionName = '1.0'
23
+}
24
+
25
+android {
26
+    namespace "com.xayunmei.water_management"
27
+    compileSdk 34
28
+
29
+    compileOptions {
30
+        sourceCompatibility JavaVersion.VERSION_1_8
31
+        targetCompatibility JavaVersion.VERSION_1_8
32
+    }
33
+
34
+    kotlinOptions {
35
+        jvmTarget = '1.8'
36
+    }
37
+
38
+    sourceSets {
39
+        main.java.srcDirs += 'src/main/kotlin'
40
+    }
41
+
42
+    defaultConfig {
43
+        applicationId "com.xayunmei.water_management"
44
+        minSdk 23
45
+        targetSdk 34
46
+        versionCode flutterVersionCode.toInteger()
47
+        versionName flutterVersionName
48
+    }
49
+
50
+    buildTypes {
51
+        release {
52
+            signingConfig signingConfigs.debug
53
+            minifyEnabled false
54
+            shrinkResources false
55
+        }
56
+    }
57
+}
58
+
59
+flutter {
60
+    source '../..'
61
+}
62
+
63
+dependencies {
64
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10"
65
+}

+ 57
- 0
mobile-app/android/app/src/main/AndroidManifest.xml ファイルの表示

@@ -0,0 +1,57 @@
1
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+    <!-- 网络权限 -->
4
+    <uses-permission android:name="android.permission.INTERNET" />
5
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6
+
7
+    <!-- 位置权限(巡检功能) -->
8
+    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
9
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
10
+    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
11
+
12
+    <!-- 相机权限(巡检拍照) -->
13
+    <uses-permission android:name="android.permission.CAMERA" />
14
+
15
+    <!-- 存储权限(离线缓存) -->
16
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
17
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
18
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
19
+
20
+    <!-- 通知权限(推送) -->
21
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
22
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
23
+    <uses-permission android:name="android.permission.VIBRATE" />
24
+
25
+    <application
26
+        android:label="供水管理"
27
+        android:name="${applicationName}"
28
+        android:icon="@mipmap/ic_launcher"
29
+        android:enableOnBackInvokedCallback="true">
30
+        <activity
31
+            android:name=".MainActivity"
32
+            android:exported="true"
33
+            android:launchMode="singleTop"
34
+            android:theme="@style/LaunchTheme"
35
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayoutDensity"
36
+            android:hardwareAccelerated="true"
37
+            android:windowSoftInputMode="adjustResize">
38
+            <meta-data
39
+                android:name="io.flutter.embedding.android.NormalTheme"
40
+                android:resource="@style/NormalTheme" />
41
+            <intent-filter>
42
+                <action android:name="android.intent.action.MAIN"/>
43
+                <category android:name="android.intent.category.LAUNCHER"/>
44
+            </intent-filter>
45
+        </activity>
46
+        <meta-data
47
+            android:name="flutterEmbedding"
48
+            android:value="2" />
49
+    </application>
50
+
51
+    <queries>
52
+        <intent>
53
+            <action android:name="android.intent.action.PROCESS_TEXT"/>
54
+            <data android:mimeType="text/plain"/>
55
+        </intent>
56
+    </queries>
57
+</manifest>

+ 6
- 0
mobile-app/android/app/src/main/java/com/example/water_management/MainActivity.kt ファイルの表示

@@ -0,0 +1,6 @@
1
+package com.xayunmei.water_management
2
+
3
+import io.flutter.embedding.android.FlutterActivity
4
+
5
+class MainActivity: FlutterActivity() {
6
+}

+ 9
- 0
mobile-app/android/app/src/main/res/values/styles.xml ファイルの表示

@@ -0,0 +1,9 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<resources>
3
+    <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
4
+        <item name="android:windowBackground">@drawable/launch_background</item>
5
+    </style>
6
+    <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
7
+        <item name="android:windowBackground">?android:colorBackground</item>
8
+    </style>
9
+</resources>

+ 18
- 0
mobile-app/android/build.gradle ファイルの表示

@@ -0,0 +1,18 @@
1
+allprojects {
2
+    repositories {
3
+        google()
4
+        mavenCentral()
5
+    }
6
+}
7
+
8
+rootProject.buildDir = '../build'
9
+subprojects {
10
+    project.buildDir = "${rootProject.buildDir}/${project.name}"
11
+}
12
+subprojects {
13
+    project.evaluationDependsOn(':app')
14
+}
15
+
16
+tasks.register("clean", Delete) {
17
+    delete rootProject.buildDir
18
+}

+ 26
- 0
mobile-app/android/settings.gradle ファイルの表示

@@ -0,0 +1,26 @@
1
+pluginManagement {
2
+    def flutterSdkPath = {
3
+        def properties = new Properties()
4
+        file("local.properties").withInputStream { properties.load(it) }
5
+        def flutterSdkPath = properties.getProperty("flutter.sdk")
6
+        assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
7
+        return flutterSdkPath
8
+    }
9
+    settings.ext.flutterSdkPath = flutterSdkPath()
10
+
11
+    includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
12
+
13
+    repositories {
14
+        google()
15
+        mavenCentral()
16
+        gradlePluginPortal()
17
+    }
18
+}
19
+
20
+plugins {
21
+    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
22
+    id "com.android.application" version "8.1.0" apply false
23
+    id "org.jetbrains.kotlin.android" version "1.9.10" apply false
24
+}
25
+
26
+include ":app"

+ 0
- 0
mobile-app/assets/images/.gitkeep ファイルの表示


+ 13
- 0
mobile-app/ios/Runner/AppDelegate.swift ファイルの表示

@@ -0,0 +1,13 @@
1
+import UIKit
2
+import Flutter
3
+
4
+@UIApplicationMain
5
+@objc class AppDelegate: FlutterAppDelegate {
6
+  override func application(
7
+    _ application: UIApplication,
8
+    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
9
+  ) -> Bool {
10
+    GeneratedPluginRegistrant.register(with: self)
11
+    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
12
+  }
13
+}

+ 60
- 0
mobile-app/ios/Runner/Info.plist ファイルの表示

@@ -0,0 +1,60 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+    <key>CFBundleDevelopmentRegion</key>
6
+    <string>$(DEVELOPMENT_LANGUAGE)</string>
7
+    <key>CFBundleDisplayName</key>
8
+    <string>供水管理</string>
9
+    <key>CFBundleExecutable</key>
10
+    <string>$(EXECUTABLE_NAME)</string>
11
+    <key>CFBundleIdentifier</key>
12
+    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+    <key>CFBundleInfoDictionaryVersion</key>
14
+    <string>6.0</string>
15
+    <key>CFBundleName</key>
16
+    <string>water_management_app</string>
17
+    <key>CFBundlePackageType</key>
18
+    <string>APPL</string>
19
+    <key>CFBundleShortVersionString</key>
20
+    <string>$(FLUTTER_BUILD_NAME)</string>
21
+    <key>CFBundleSignature</key>
22
+    <string>????</string>
23
+    <key>CFBundleVersion</key>
24
+    <string>$(FLUTTER_BUILD_NUMBER)</string>
25
+    <key>LSRequiresIPhoneOS</key>
26
+    <true/>
27
+    <key>UILaunchStoryboardName</key>
28
+    <string>LaunchScreen</string>
29
+    <key>UIMainStoryboardFile</key>
30
+    <string>Main</string>
31
+    <key>UISupportedInterfaceOrientations</key>
32
+    <array>
33
+        <string>UIInterfaceOrientationPortrait</string>
34
+    </array>
35
+    <key>UIViewControllerBasedStatusBarAppearance</key>
36
+    <false/>
37
+
38
+    <!-- 位置权限描述(巡检功能) -->
39
+    <key>NSLocationWhenInUseUsageDescription</key>
40
+    <string>需要获取您的位置信息以支持巡检定位功能</string>
41
+    <key>NSLocationAlwaysUsageDescription</key>
42
+    <string>需要持续获取您的位置信息以支持巡检轨迹记录</string>
43
+
44
+    <!-- 相机权限描述(巡检拍照) -->
45
+    <key>NSCameraUsageDescription</key>
46
+    <string>需要使用相机进行巡检拍照记录</string>
47
+
48
+    <!-- 相册权限描述 -->
49
+    <key>NSPhotoLibraryUsageDescription</key>
50
+    <string>需要访问相册以选择巡检相关图片</string>
51
+    <key>NSPhotoLibraryAddUsageDescription</key>
52
+    <string>需要保存巡检照片到相册</string>
53
+
54
+    <!-- 通知权限 -->
55
+    <key>UIBackgroundModes</key>
56
+    <array>
57
+        <string>remote-notification</string>
58
+    </array>
59
+</dict>
60
+</plist>

+ 39
- 0
mobile-app/lib/config/app_config.dart ファイルの表示

@@ -0,0 +1,39 @@
1
+/// 应用全局配置
2
+class AppConfig {
3
+  static AppConfig? _instance;
4
+
5
+  AppConfig._internal();
6
+
7
+  factory AppConfig() {
8
+    _instance ??= AppConfig._internal();
9
+    return _instance!;
10
+  }
11
+
12
+  /// 环境配置
13
+  AppEnvironment _environment = AppEnvironment.development;
14
+  AppEnvironment get environment => _environment;
15
+
16
+  /// 切换环境
17
+  void setEnvironment(AppEnvironment env) {
18
+    _environment = env;
19
+  }
20
+
21
+  /// API 基础地址
22
+  String get baseUrl {
23
+    switch (_environment) {
24
+      case AppEnvironment.development:
25
+        return 'https://dev-api.xayunmei.com';
26
+      case AppEnvironment.staging:
27
+        return 'https://staging-api.xayunmei.com';
28
+      case AppEnvironment.production:
29
+        return 'https://api.xayunmei.com';
30
+    }
31
+  }
32
+}
33
+
34
+/// 环境枚举
35
+enum AppEnvironment {
36
+  development,
37
+  staging,
38
+  production,
39
+}

+ 42
- 0
mobile-app/lib/config/app_routes.dart ファイルの表示

@@ -0,0 +1,42 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:go_router/go_router.dart';
3
+import '../features/auth/pages/login_page.dart';
4
+import '../features/auth/services/token_service.dart';
5
+import '../features/main_shell/pages/main_shell_page.dart';
6
+import '../features/revenue/pages/bill_list_page.dart';
7
+
8
+/// 应用路由配置
9
+class AppRoutes {
10
+  static const String login = '/login';
11
+  static const String main = '/main';
12
+  static const String bills = '/bills';
13
+
14
+  static GoRouter createRouter() {
15
+    return GoRouter(
16
+      initialLocation: login,
17
+      redirect: (BuildContext context, GoRouterState state) async {
18
+        final tokenService = TokenService();
19
+        final isLoggedIn = await tokenService.isLoggedIn();
20
+        final isGoingToLogin = state.matchedLocation == login;
21
+
22
+        if (!isLoggedIn && !isGoingToLogin) return login;
23
+        if (isLoggedIn && isGoingToLogin) return main;
24
+        return null;
25
+      },
26
+      routes: [
27
+        GoRoute(
28
+          path: login,
29
+          builder: (context, state) => const LoginPage(),
30
+        ),
31
+        GoRoute(
32
+          path: main,
33
+          builder: (context, state) => const MainShellPage(),
34
+        ),
35
+        GoRoute(
36
+          path: bills,
37
+          builder: (context, state) => const BillListPage(),
38
+        ),
39
+      ],
40
+    );
41
+  }
42
+}

+ 25
- 0
mobile-app/lib/core/constants/app_constants.dart ファイルの表示

@@ -0,0 +1,25 @@
1
+/// 应用常量配置
2
+class AppConstants {
3
+  // API 基础地址
4
+  static const String baseUrl = 'https://api.xayunmei.com';
5
+  static const String apiVersion = '/api/v1';
6
+
7
+  // Token 相关
8
+  static const String tokenKey = 'auth_token';
9
+  static const String refreshTokenKey = 'refresh_token';
10
+  static const String tokenExpiryKey = 'token_expiry';
11
+
12
+  // 缓存相关
13
+  static const String cacheBoxName = 'app_cache';
14
+  static const String userBoxName = 'user_cache';
15
+
16
+  // 超时配置
17
+  static const int connectTimeout = 15000;
18
+  static const int receiveTimeout = 15000;
19
+
20
+  // 分页配置
21
+  static const int defaultPageSize = 20;
22
+
23
+  // 应用名称
24
+  static const String appName = '供水管理系统';
25
+}

+ 39
- 0
mobile-app/lib/core/network/api_response.dart ファイルの表示

@@ -0,0 +1,39 @@
1
+/// 统一 API 响应模型
2
+class ApiResponse<T> {
3
+  final int code;
4
+  final String message;
5
+  final T? data;
6
+
7
+  ApiResponse({
8
+    required this.code,
9
+    required this.message,
10
+    this.data,
11
+  });
12
+
13
+  bool get isSuccess => code == 200 || code == 0;
14
+
15
+  factory ApiResponse.fromJson(Map<String, dynamic> json, T Function(dynamic)? fromJsonT) {
16
+    return ApiResponse(
17
+      code: json['code'] ?? 0,
18
+      message: json['message'] ?? '',
19
+      data: json['data'] != null && fromJsonT != null ? fromJsonT(json['data']) : json['data'] as T?,
20
+    );
21
+  }
22
+}
23
+
24
+/// 分页响应模型
25
+class PaginatedResponse<T> {
26
+  final List<T> list;
27
+  final int total;
28
+  final int page;
29
+  final int pageSize;
30
+
31
+  PaginatedResponse({
32
+    required this.list,
33
+    required this.total,
34
+    required this.page,
35
+    required this.pageSize,
36
+  });
37
+
38
+  bool get hasMore => page * pageSize < total;
39
+}

+ 41
- 0
mobile-app/lib/core/network/auth_interceptor.dart ファイルの表示

@@ -0,0 +1,41 @@
1
+import 'package:dio/dio.dart';
2
+import '../../features/auth/services/token_service.dart';
3
+import '../constants/app_constants.dart';
4
+
5
+/// Token 认证拦截器
6
+/// 自动在请求头中附加 Token,处理 Token 刷新
7
+class AuthInterceptor extends Interceptor {
8
+  final TokenService _tokenService = TokenService();
9
+
10
+  @override
11
+  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
12
+    final token = await _tokenService.getToken();
13
+    if (token != null) {
14
+      options.headers['Authorization'] = 'Bearer $token';
15
+    }
16
+    handler.next(options);
17
+  }
18
+
19
+  @override
20
+  void onError(DioException err, ErrorInterceptorHandler handler) async {
21
+    if (err.response?.statusCode == 401) {
22
+      // Token 过期,尝试刷新
23
+      try {
24
+        final refreshed = await _tokenService.refreshToken();
25
+        if (refreshed) {
26
+          // 使用新 Token 重试请求
27
+          final token = await _tokenService.getToken();
28
+          err.requestOptions.headers['Authorization'] = 'Bearer $token';
29
+          
30
+          final response = await Dio().fetch(err.requestOptions);
31
+          handler.resolve(response);
32
+          return;
33
+        }
34
+      } catch (_) {
35
+        // 刷新失败,清除 Token 并跳转登录
36
+        await _tokenService.clearTokens();
37
+      }
38
+    }
39
+    handler.next(err);
40
+  }
41
+}

+ 79
- 0
mobile-app/lib/core/network/dio_client.dart ファイルの表示

@@ -0,0 +1,79 @@
1
+import 'package:dio/dio.dart';
2
+import '../constants/app_constants.dart';
3
+import 'auth_interceptor.dart';
4
+
5
+/// Dio HTTP 客户端配置
6
+class DioClient {
7
+  static DioClient? _instance;
8
+  late Dio _dio;
9
+
10
+  Dio get dio => _dio;
11
+
12
+  DioClient._internal() {
13
+    _dio = Dio(
14
+      BaseOptions(
15
+        baseUrl: AppConstants.baseUrl + AppConstants.apiVersion,
16
+        connectTimeout: const Duration(milliseconds: AppConstants.connectTimeout),
17
+        receiveTimeout: const Duration(milliseconds: AppConstants.receiveTimeout),
18
+        headers: {
19
+          'Content-Type': 'application/json',
20
+          'Accept': 'application/json',
21
+        },
22
+      ),
23
+    );
24
+
25
+    // 添加拦截器
26
+    _dio.interceptors.addAll([
27
+      AuthInterceptor(),
28
+      LogInterceptor(
29
+        requestBody: true,
30
+        responseBody: true,
31
+        logPrint: (obj) => print('[DioLog] $obj'),
32
+      ),
33
+    ]);
34
+  }
35
+
36
+  factory DioClient() {
37
+    _instance ??= DioClient._internal();
38
+    return _instance!;
39
+  }
40
+
41
+  /// GET 请求
42
+  Future<Response<T>> get<T>(
43
+    String path, {
44
+    Map<String, dynamic>? queryParameters,
45
+    Options? options,
46
+  }) async {
47
+    return _dio.get<T>(path, queryParameters: queryParameters, options: options);
48
+  }
49
+
50
+  /// POST 请求
51
+  Future<Response<T>> post<T>(
52
+    String path, {
53
+    dynamic data,
54
+    Map<String, dynamic>? queryParameters,
55
+    Options? options,
56
+  }) async {
57
+    return _dio.post<T>(path, data: data, queryParameters: queryParameters, options: options);
58
+  }
59
+
60
+  /// PUT 请求
61
+  Future<Response<T>> put<T>(
62
+    String path, {
63
+    dynamic data,
64
+    Map<String, dynamic>? queryParameters,
65
+    Options? options,
66
+  }) async {
67
+    return _dio.put<T>(path, data: data, queryParameters: queryParameters, options: options);
68
+  }
69
+
70
+  /// DELETE 请求
71
+  Future<Response<T>> delete<T>(
72
+    String path, {
73
+    dynamic data,
74
+    Map<String, dynamic>? queryParameters,
75
+    Options? options,
76
+  }) async {
77
+    return _dio.delete<T>(path, data: data, queryParameters: queryParameters, options: options);
78
+  }
79
+}

+ 52
- 0
mobile-app/lib/core/theme/app_theme.dart ファイルの表示

@@ -0,0 +1,52 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 应用主题配置
4
+class AppTheme {
5
+  static const Color primaryColor = Color(0xFF1976D2);
6
+  static const Color secondaryColor = Color(0xFF42A5F5);
7
+  static const Color accentColor = Color(0xFF00BCD4);
8
+  static const Color backgroundColor = Color(0xFFF5F5F5);
9
+  static const Color errorColor = Color(0xFFD32F2F);
10
+  static const Color successColor = Color(0xFF388E3C);
11
+
12
+  static ThemeData get lightTheme {
13
+    return ThemeData(
14
+      useMaterial3: true,
15
+      primaryColor: primaryColor,
16
+      colorScheme: ColorScheme.fromSeed(
17
+        seedColor: primaryColor,
18
+        secondary: secondaryColor,
19
+        background: backgroundColor,
20
+        error: errorColor,
21
+      ),
22
+      appBarTheme: const AppBarTheme(
23
+        backgroundColor: primaryColor,
24
+        foregroundColor: Colors.white,
25
+        elevation: 0,
26
+      ),
27
+      elevatedButtonTheme: ElevatedButtonThemeData(
28
+        style: ElevatedButton.styleFrom(
29
+          backgroundColor: primaryColor,
30
+          foregroundColor: Colors.white,
31
+          minimumSize: const Size(double.infinity, 48),
32
+          shape: RoundedRectangleBorder(
33
+            borderRadius: BorderRadius.circular(8),
34
+          ),
35
+        ),
36
+      ),
37
+      inputDecorationTheme: InputDecorationTheme(
38
+        border: OutlineInputBorder(
39
+          borderRadius: BorderRadius.circular(8),
40
+        ),
41
+        filled: true,
42
+        fillColor: Colors.white,
43
+      ),
44
+      cardTheme: CardTheme(
45
+        elevation: 2,
46
+        shape: RoundedRectangleBorder(
47
+          borderRadius: BorderRadius.circular(12),
48
+        ),
49
+      ),
50
+    );
51
+  }
52
+}

+ 44
- 0
mobile-app/lib/features/auth/models/user_model.dart ファイルの表示

@@ -0,0 +1,44 @@
1
+/// 用户模型
2
+class UserModel {
3
+  final String id;
4
+  final String username;
5
+  final String name;
6
+  final String? avatar;
7
+  final String role;
8
+  final String? department;
9
+  final String? phone;
10
+
11
+  UserModel({
12
+    required this.id,
13
+    required this.username,
14
+    required this.name,
15
+    this.avatar,
16
+    required this.role,
17
+    this.department,
18
+    this.phone,
19
+  });
20
+
21
+  factory UserModel.fromJson(Map<String, dynamic> json) {
22
+    return UserModel(
23
+      id: json['id']?.toString() ?? '',
24
+      username: json['username'] ?? '',
25
+      name: json['name'] ?? '',
26
+      avatar: json['avatar'],
27
+      role: json['role'] ?? '',
28
+      department: json['department'],
29
+      phone: json['phone'],
30
+    );
31
+  }
32
+
33
+  Map<String, dynamic> toJson() {
34
+    return {
35
+      'id': id,
36
+      'username': username,
37
+      'name': name,
38
+      'avatar': avatar,
39
+      'role': role,
40
+      'department': department,
41
+      'phone': phone,
42
+    };
43
+  }
44
+}

+ 173
- 0
mobile-app/lib/features/auth/pages/login_page.dart ファイルの表示

@@ -0,0 +1,173 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:provider/provider.dart';
3
+import '../services/auth_provider.dart';
4
+
5
+/// 登录页面
6
+class LoginPage extends StatefulWidget {
7
+  const LoginPage({super.key});
8
+
9
+  @override
10
+  State<LoginPage> createState() => _LoginPageState();
11
+}
12
+
13
+class _LoginPageState extends State<LoginPage> {
14
+  final _formKey = GlobalKey<FormState>();
15
+  final _usernameController = TextEditingController();
16
+  final _passwordController = TextEditingController();
17
+  bool _obscurePassword = true;
18
+
19
+  @override
20
+  void dispose() {
21
+    _usernameController.dispose();
22
+    _passwordController.dispose();
23
+    super.dispose();
24
+  }
25
+
26
+  Future<void> _handleLogin() async {
27
+    if (!_formKey.currentState!.validate()) return;
28
+
29
+    final authProvider = Provider.of<AuthProvider>(context, listen: false);
30
+    final success = await authProvider.login(
31
+      username: _usernameController.text.trim(),
32
+      password: _passwordController.text,
33
+    );
34
+
35
+    if (success && mounted) {
36
+      // 登录成功后路由由 AuthGuard 自动处理
37
+      Navigator.of(context).pushReplacementNamed('/main');
38
+    } else if (mounted) {
39
+      ScaffoldMessenger.of(context).showSnackBar(
40
+        SnackBar(
41
+          content: Text(authProvider.errorMessage ?? '登录失败'),
42
+          backgroundColor: Colors.red,
43
+        ),
44
+      );
45
+    }
46
+  }
47
+
48
+  @override
49
+  Widget build(BuildContext context) {
50
+    return Scaffold(
51
+      body: SafeArea(
52
+        child: Center(
53
+          child: SingleChildScrollView(
54
+            padding: const EdgeInsets.all(32),
55
+            child: Form(
56
+              key: _formKey,
57
+              child: Column(
58
+                mainAxisAlignment: MainAxisAlignment.center,
59
+                crossAxisAlignment: CrossAxisAlignment.stretch,
60
+                children: [
61
+                  // Logo 和应用名
62
+                  const Icon(
63
+                    Icons.water_drop,
64
+                    size: 80,
65
+                    color: Color(0xFF1976D2),
66
+                  ),
67
+                  const SizedBox(height: 16),
68
+                  const Text(
69
+                    '供水管理系统',
70
+                    textAlign: TextAlign.center,
71
+                    style: TextStyle(
72
+                      fontSize: 24,
73
+                      fontWeight: FontWeight.bold,
74
+                      color: Color(0xFF1976D2),
75
+                    ),
76
+                  ),
77
+                  const SizedBox(height: 8),
78
+                  Text(
79
+                    '供水 · 巡检 · 营收',
80
+                    textAlign: TextAlign.center,
81
+                    style: TextStyle(
82
+                      fontSize: 14,
83
+                      color: Colors.grey[600],
84
+                    ),
85
+                  ),
86
+                  const SizedBox(height: 48),
87
+
88
+                  // 用户名输入
89
+                  TextFormField(
90
+                    controller: _usernameController,
91
+                    decoration: const InputDecoration(
92
+                      labelText: '用户名',
93
+                      prefixIcon: Icon(Icons.person),
94
+                      hintText: '请输入用户名',
95
+                    ),
96
+                    validator: (value) {
97
+                      if (value == null || value.trim().isEmpty) {
98
+                        return '请输入用户名';
99
+                      }
100
+                      return null;
101
+                    },
102
+                  ),
103
+                  const SizedBox(height: 16),
104
+
105
+                  // 密码输入
106
+                  TextFormField(
107
+                    controller: _passwordController,
108
+                    decoration: InputDecoration(
109
+                      labelText: '密码',
110
+                      prefixIcon: const Icon(Icons.lock),
111
+                      hintText: '请输入密码',
112
+                      suffixIcon: IconButton(
113
+                        icon: Icon(
114
+                          _obscurePassword ? Icons.visibility_off : Icons.visibility,
115
+                        ),
116
+                        onPressed: () {
117
+                          setState(() {
118
+                            _obscurePassword = !_obscurePassword;
119
+                          });
120
+                        },
121
+                      ),
122
+                    ),
123
+                    obscureText: _obscurePassword,
124
+                    validator: (value) {
125
+                      if (value == null || value.isEmpty) {
126
+                        return '请输入密码';
127
+                      }
128
+                      if (value.length < 6) {
129
+                        return '密码长度不能少于6位';
130
+                      }
131
+                      return null;
132
+                    },
133
+                  ),
134
+                  const SizedBox(height: 32),
135
+
136
+                  // 登录按钮
137
+                  Consumer<AuthProvider>(
138
+                    builder: (context, auth, child) {
139
+                      return ElevatedButton(
140
+                        onPressed: auth.isLoading ? null : _handleLogin,
141
+                        child: auth.isLoading
142
+                            ? const SizedBox(
143
+                                height: 20,
144
+                                width: 20,
145
+                                child: CircularProgressIndicator(
146
+                                  strokeWidth: 2,
147
+                                  color: Colors.white,
148
+                                ),
149
+                              )
150
+                            : const Text(
151
+                                '登 录',
152
+                                style: TextStyle(fontSize: 16),
153
+                              ),
154
+                      );
155
+                    },
156
+                  ),
157
+                  const SizedBox(height: 16),
158
+
159
+                  // 版本信息
160
+                  Text(
161
+                    'v1.0.0',
162
+                    textAlign: TextAlign.center,
163
+                    style: TextStyle(color: Colors.grey[400], fontSize: 12),
164
+                  ),
165
+                ],
166
+              ),
167
+            ),
168
+          ),
169
+        ),
170
+      ),
171
+    );
172
+  }
173
+}

+ 82
- 0
mobile-app/lib/features/auth/services/auth_provider.dart ファイルの表示

@@ -0,0 +1,82 @@
1
+import 'package:flutter/material.dart';
2
+import 'token_service.dart';
3
+import '../models/user_model.dart';
4
+
5
+/// 认证状态管理
6
+class AuthProvider extends ChangeNotifier {
7
+  final TokenService _tokenService = TokenService();
8
+
9
+  bool _isLoading = false;
10
+  bool _isAuthenticated = false;
11
+  UserModel? _currentUser;
12
+  String? _errorMessage;
13
+
14
+  bool get isLoading => _isLoading;
15
+  bool get isAuthenticated => _isAuthenticated;
16
+  UserModel? get currentUser => _currentUser;
17
+  String? get errorMessage => _errorMessage;
18
+
19
+  /// 初始化认证状态
20
+  Future<void> init() async {
21
+    _isAuthenticated = await _tokenService.isLoggedIn();
22
+    if (_isAuthenticated) {
23
+      // TODO: 从接口获取用户信息
24
+      _currentUser = UserModel(
25
+        id: '1',
26
+        username: 'admin',
27
+        name: '管理员',
28
+        role: 'admin',
29
+      );
30
+    }
31
+    notifyListeners();
32
+  }
33
+
34
+  /// 登录
35
+  Future<bool> login({
36
+    required String username,
37
+    required String password,
38
+  }) async {
39
+    _isLoading = true;
40
+    _errorMessage = null;
41
+    notifyListeners();
42
+
43
+    try {
44
+      // TODO: 调用实际登录接口
45
+      // final response = await DioClient().post('/auth/login', data: {
46
+      //   'username': username,
47
+      //   'password': password,
48
+      // });
49
+
50
+      // 模拟登录成功
51
+      await _tokenService.saveTokens(
52
+        token: 'mock_access_token_${DateTime.now().millisecondsSinceEpoch}',
53
+        refreshToken: 'mock_refresh_token',
54
+        expiresIn: 7200,
55
+      );
56
+
57
+      _currentUser = UserModel(
58
+        id: '1',
59
+        username: username,
60
+        name: username == 'admin' ? '管理员' : username,
61
+        role: 'admin',
62
+      );
63
+      _isAuthenticated = true;
64
+      _isLoading = false;
65
+      notifyListeners();
66
+      return true;
67
+    } catch (e) {
68
+      _errorMessage = '登录失败,请检查用户名和密码';
69
+      _isLoading = false;
70
+      notifyListeners();
71
+      return false;
72
+    }
73
+  }
74
+
75
+  /// 登出
76
+  Future<void> logout() async {
77
+    await _tokenService.clearTokens();
78
+    _isAuthenticated = false;
79
+    _currentUser = null;
80
+    notifyListeners();
81
+  }
82
+}

+ 90
- 0
mobile-app/lib/features/auth/services/token_service.dart ファイルの表示

@@ -0,0 +1,90 @@
1
+import 'package:shared_preferences/shared_preferences.dart';
2
+import '../../../core/constants/app_constants.dart';
3
+
4
+/// Token 管理服务
5
+/// 负责 Token 的存储、读取、刷新和清除
6
+class TokenService {
7
+  static TokenService? _instance;
8
+  SharedPreferences? _prefs;
9
+
10
+  TokenService();
11
+
12
+  factory TokenService.getInstance() {
13
+    _instance ??= TokenService();
14
+    return _instance!;
15
+  }
16
+
17
+  Future<SharedPreferences> get _preferences async {
18
+    _prefs ??= await SharedPreferences.getInstance();
19
+    return _prefs!;
20
+  }
21
+
22
+  /// 获取 Access Token
23
+  Future<String?> getToken() async {
24
+    final prefs = await _preferences;
25
+    return prefs.getString(AppConstants.tokenKey);
26
+  }
27
+
28
+  /// 获取 Refresh Token
29
+  Future<String?> getRefreshToken() async {
30
+    final prefs = await _preferences;
31
+    return prefs.getString(AppConstants.refreshTokenKey);
32
+  }
33
+
34
+  /// 保存 Token
35
+  Future<void> saveTokens({
36
+    required String token,
37
+    String? refreshToken,
38
+    int? expiresIn,
39
+  }) async {
40
+    final prefs = await _preferences;
41
+    await prefs.setString(AppConstants.tokenKey, token);
42
+    if (refreshToken != null) {
43
+      await prefs.setString(AppConstants.refreshTokenKey, refreshToken);
44
+    }
45
+    if (expiresIn != null) {
46
+      final expiry = DateTime.now().millisecondsSinceEpoch + expiresIn * 1000;
47
+      await prefs.setInt(AppConstants.tokenExpiryKey, expiry);
48
+    }
49
+  }
50
+
51
+  /// 刷新 Token
52
+  Future<bool> refreshToken() async {
53
+    // TODO: 实现实际的 Token 刷新逻辑
54
+    // 使用 refreshToken 调用后端接口获取新的 accessToken
55
+    final refreshTok = await getRefreshToken();
56
+    if (refreshTok == null) return false;
57
+
58
+    try {
59
+      // 模拟刷新
60
+      // final response = await DioClient().post('/auth/refresh', data: {'refresh_token': refreshTok});
61
+      // await saveTokens(token: response.data['data']['token'], refreshToken: response.data['data']['refresh_token']);
62
+      return true;
63
+    } catch (e) {
64
+      return false;
65
+    }
66
+  }
67
+
68
+  /// 检查 Token 是否过期
69
+  Future<bool> isTokenExpired() async {
70
+    final prefs = await _preferences;
71
+    final expiry = prefs.getInt(AppConstants.tokenExpiryKey);
72
+    if (expiry == null) return true;
73
+    return DateTime.now().millisecondsSinceEpoch > expiry;
74
+  }
75
+
76
+  /// 清除所有 Token
77
+  Future<void> clearTokens() async {
78
+    final prefs = await _preferences;
79
+    await prefs.remove(AppConstants.tokenKey);
80
+    await prefs.remove(AppConstants.refreshTokenKey);
81
+    await prefs.remove(AppConstants.tokenExpiryKey);
82
+  }
83
+
84
+  /// 是否已登录
85
+  Future<bool> isLoggedIn() async {
86
+    final token = await getToken();
87
+    if (token == null) return false;
88
+    return !(await isTokenExpired());
89
+  }
90
+}

+ 57
- 0
mobile-app/lib/features/main_shell/pages/main_shell_page.dart ファイルの表示

@@ -0,0 +1,57 @@
1
+import 'package:flutter/material.dart';
2
+import '../../water_supply/pages/monitor_list_page.dart';
3
+import '../../patrol/pages/patrol_task_list_page.dart';
4
+import '../../revenue/pages/meter_reading_page.dart';
5
+
6
+/// 主页面 - 底部三Tab导航(供水/巡检/营收)
7
+class MainShellPage extends StatefulWidget {
8
+  const MainShellPage({super.key});
9
+
10
+  @override
11
+  State<MainShellPage> createState() => _MainShellPageState();
12
+}
13
+
14
+class _MainShellPageState extends State<MainShellPage> {
15
+  int _currentIndex = 0;
16
+
17
+  final List<Widget> _pages = const [
18
+    MonitorListPage(),
19
+    PatrolTaskListPage(),
20
+    MeterReadingPage(),
21
+  ];
22
+
23
+  @override
24
+  Widget build(BuildContext context) {
25
+    return Scaffold(
26
+      body: IndexedStack(
27
+        index: _currentIndex,
28
+        children: _pages,
29
+      ),
30
+      bottomNavigationBar: BottomNavigationBar(
31
+        currentIndex: _currentIndex,
32
+        onTap: (index) {
33
+          setState(() {
34
+            _currentIndex = index;
35
+          });
36
+        },
37
+        type: BottomNavigationBarType.fixed,
38
+        selectedItemColor: const Color(0xFF1976D2),
39
+        unselectedItemColor: Colors.grey,
40
+        items: const [
41
+          BottomNavigationBarItem(
42
+            icon: Icon(Icons.water_drop),
43
+            label: '供水管理',
44
+          ),
45
+          BottomNavigationBarItem(
46
+            icon: Icon(Icons.assignment),
47
+            label: '巡检',
48
+          ),
49
+          BottomNavigationBarItem(
50
+            icon: Icon(Icons.receipt_long),
51
+            label: '营收',
52
+          ),
53
+        ],
54
+      ),
55
+    );
56
+  }
57
+}

+ 59
- 0
mobile-app/lib/features/patrol/models/patrol_task_model.dart ファイルの表示

@@ -0,0 +1,59 @@
1
+/// 巡检任务模型
2
+class PatrolTaskModel {
3
+  final String id;
4
+  final String taskName;
5
+  final String taskCode;
6
+  final String routeName;
7
+  final String assignee;
8
+  final String status; // pending, in_progress, completed, overdue
9
+  final DateTime planDate;
10
+  final int checkpointTotal;
11
+  final int checkpointCompleted;
12
+  final String? remark;
13
+
14
+  PatrolTaskModel({
15
+    required this.id,
16
+    required this.taskName,
17
+    required this.taskCode,
18
+    required this.routeName,
19
+    required this.assignee,
20
+    required this.status,
21
+    required this.planDate,
22
+    required this.checkpointTotal,
23
+    required this.checkpointCompleted,
24
+    this.remark,
25
+  });
26
+
27
+  double get progress =>
28
+      checkpointTotal > 0 ? checkpointCompleted / checkpointTotal : 0;
29
+
30
+  factory PatrolTaskModel.fromJson(Map<String, dynamic> json) {
31
+    return PatrolTaskModel(
32
+      id: json['id']?.toString() ?? '',
33
+      taskName: json['taskName'] ?? '',
34
+      taskCode: json['taskCode'] ?? '',
35
+      routeName: json['routeName'] ?? '',
36
+      assignee: json['assignee'] ?? '',
37
+      status: json['status'] ?? 'pending',
38
+      planDate: DateTime.tryParse(json['planDate'] ?? '') ?? DateTime.now(),
39
+      checkpointTotal: json['checkpointTotal'] ?? 0,
40
+      checkpointCompleted: json['checkpointCompleted'] ?? 0,
41
+      remark: json['remark'],
42
+    );
43
+  }
44
+
45
+  Map<String, dynamic> toJson() {
46
+    return {
47
+      'id': id,
48
+      'taskName': taskName,
49
+      'taskCode': taskCode,
50
+      'routeName': routeName,
51
+      'assignee': assignee,
52
+      'status': status,
53
+      'planDate': planDate.toIso8601String(),
54
+      'checkpointTotal': checkpointTotal,
55
+      'checkpointCompleted': checkpointCompleted,
56
+      'remark': remark,
57
+    };
58
+  }
59
+}

+ 255
- 0
mobile-app/lib/features/patrol/pages/patrol_task_list_page.dart ファイルの表示

@@ -0,0 +1,255 @@
1
+import 'package:flutter/material.dart';
2
+import '../models/patrol_task_model.dart';
3
+
4
+/// 巡检任务列表页面
5
+class PatrolTaskListPage extends StatefulWidget {
6
+  const PatrolTaskListPage({super.key});
7
+
8
+  @override
9
+  State<PatrolTaskListPage> createState() => _PatrolTaskListPageState();
10
+}
11
+
12
+class _PatrolTaskListPageState extends State<PatrolTaskListPage>
13
+    with AutomaticKeepAliveClientMixin {
14
+  List<PatrolTaskModel> _tasks = [];
15
+  bool _isLoading = true;
16
+  String _filterStatus = 'all';
17
+
18
+  @override
19
+  bool get wantKeepAlive => true;
20
+
21
+  @override
22
+  void initState() {
23
+    super.initState();
24
+    _loadData();
25
+  }
26
+
27
+  void _loadData() {
28
+    Future.delayed(const Duration(milliseconds: 500), () {
29
+      setState(() {
30
+        _tasks = _generateMockData();
31
+        _isLoading = false;
32
+      });
33
+    });
34
+  }
35
+
36
+  List<PatrolTaskModel> _generateMockData() {
37
+    return [
38
+      PatrolTaskModel(
39
+        id: '1', taskName: '城东管网日常巡检', taskCode: 'PT-2024-001',
40
+        routeName: '城东A线', assignee: '张工',
41
+        status: 'in_progress', planDate: DateTime.now(),
42
+        checkpointTotal: 12, checkpointCompleted: 7,
43
+      ),
44
+      PatrolTaskModel(
45
+        id: '2', taskName: '城西水厂设备巡检', taskCode: 'PT-2024-002',
46
+        routeName: '城西厂区', assignee: '李工',
47
+        status: 'pending', planDate: DateTime.now().add(const Duration(days: 1)),
48
+        checkpointTotal: 8, checkpointCompleted: 0,
49
+      ),
50
+      PatrolTaskModel(
51
+        id: '3', taskName: '南区管网夜间巡检', taskCode: 'PT-2024-003',
52
+        routeName: '南区B线', assignee: '王工',
53
+        status: 'completed', planDate: DateTime.now().subtract(const Duration(days: 1)),
54
+        checkpointTotal: 10, checkpointCompleted: 10,
55
+      ),
56
+      PatrolTaskModel(
57
+        id: '4', taskName: '北区调蓄池安全巡检', taskCode: 'PT-2024-004',
58
+        routeName: '北区站点', assignee: '赵工',
59
+        status: 'overdue', planDate: DateTime.now().subtract(const Duration(days: 2)),
60
+        checkpointTotal: 6, checkpointCompleted: 2,
61
+      ),
62
+      PatrolTaskModel(
63
+        id: '5', taskName: '开发区阀门巡检', taskCode: 'PT-2024-005',
64
+        routeName: '开发区C线', assignee: '刘工',
65
+        status: 'pending', planDate: DateTime.now().add(const Duration(days: 2)),
66
+        checkpointTotal: 15, checkpointCompleted: 0,
67
+      ),
68
+    ];
69
+  }
70
+
71
+  List<PatrolTaskModel> get _filteredTasks {
72
+    if (_filterStatus == 'all') return _tasks;
73
+    return _tasks.where((t) => t.status == _filterStatus).toList();
74
+  }
75
+
76
+  Color _getStatusColor(String status) {
77
+    switch (status) {
78
+      case 'pending': return Colors.blue;
79
+      case 'in_progress': return Colors.orange;
80
+      case 'completed': return Colors.green;
81
+      case 'overdue': return Colors.red;
82
+      default: return Colors.grey;
83
+    }
84
+  }
85
+
86
+  String _getStatusText(String status) {
87
+    switch (status) {
88
+      case 'pending': return '待执行';
89
+      case 'in_progress': return '进行中';
90
+      case 'completed': return '已完成';
91
+      case 'overdue': return '已超期';
92
+      default: return status;
93
+    }
94
+  }
95
+
96
+  @override
97
+  Widget build(BuildContext context) {
98
+    super.build(context);
99
+    return Scaffold(
100
+      appBar: AppBar(
101
+        title: const Text('巡检任务'),
102
+        bottom: PreferredSize(
103
+          preferredSize: const Size.fromHeight(48),
104
+          child: Padding(
105
+            padding: const EdgeInsets.symmetric(horizontal: 12),
106
+            child: Row(
107
+              children: [
108
+                _buildFilterChip('全部', 'all'),
109
+                _buildFilterChip('待执行', 'pending'),
110
+                _buildFilterChip('进行中', 'in_progress'),
111
+                _buildFilterChip('已完成', 'completed'),
112
+                _buildFilterChip('超期', 'overdue'),
113
+              ],
114
+            ),
115
+          ),
116
+        ),
117
+      ),
118
+      body: _isLoading
119
+          ? const Center(child: CircularProgressIndicator())
120
+          : _filteredTasks.isEmpty
121
+              ? const Center(child: Text('暂无巡检任务'))
122
+              : ListView.builder(
123
+                  padding: const EdgeInsets.all(12),
124
+                  itemCount: _filteredTasks.length,
125
+                  itemBuilder: (context, index) {
126
+                    return _buildTaskCard(_filteredTasks[index]);
127
+                  },
128
+                ),
129
+      floatingActionButton: FloatingActionButton(
130
+        onPressed: () {
131
+          // TODO: 新建巡检任务
132
+        },
133
+        child: const Icon(Icons.add),
134
+      ),
135
+    );
136
+  }
137
+
138
+  Widget _buildFilterChip(String label, String value) {
139
+    final isSelected = _filterStatus == value;
140
+    return Padding(
141
+      padding: const EdgeInsets.only(right: 8),
142
+      child: FilterChip(
143
+        label: Text(label, style: const TextStyle(fontSize: 12)),
144
+        selected: isSelected,
145
+        onSelected: (selected) {
146
+          setState(() {
147
+            _filterStatus = value;
148
+          });
149
+        },
150
+        selectedColor: const Color(0xFF1976D2).withOpacity(0.2),
151
+        checkmarkColor: const Color(0xFF1976D2),
152
+      ),
153
+    );
154
+  }
155
+
156
+  Widget _buildTaskCard(PatrolTaskModel task) {
157
+    return Card(
158
+      margin: const EdgeInsets.only(bottom: 12),
159
+      child: Padding(
160
+        padding: const EdgeInsets.all(16),
161
+        child: Column(
162
+          crossAxisAlignment: CrossAxisAlignment.start,
163
+          children: [
164
+            Row(
165
+              children: [
166
+                const Icon(Icons.assignment, color: Color(0xFF1976D2), size: 20),
167
+                const SizedBox(width: 8),
168
+                Expanded(
169
+                  child: Column(
170
+                    crossAxisAlignment: CrossAxisAlignment.start,
171
+                    children: [
172
+                      Text(
173
+                        task.taskName,
174
+                        style: const TextStyle(
175
+                          fontSize: 15,
176
+                          fontWeight: FontWeight.bold,
177
+                        ),
178
+                      ),
179
+                      Text(
180
+                        task.taskCode,
181
+                        style: TextStyle(fontSize: 12, color: Colors.grey[500]),
182
+                      ),
183
+                    ],
184
+                  ),
185
+                ),
186
+                Container(
187
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
188
+                  decoration: BoxDecoration(
189
+                    color: _getStatusColor(task.status).withOpacity(0.1),
190
+                    borderRadius: BorderRadius.circular(12),
191
+                  ),
192
+                  child: Text(
193
+                    _getStatusText(task.status),
194
+                    style: TextStyle(
195
+                      fontSize: 12,
196
+                      color: _getStatusColor(task.status),
197
+                    ),
198
+                  ),
199
+                ),
200
+              ],
201
+            ),
202
+            const SizedBox(height: 12),
203
+            Row(
204
+              children: [
205
+                _buildInfoItem(Icons.route, '路线: ${task.routeName}'),
206
+                const SizedBox(width: 16),
207
+                _buildInfoItem(Icons.person, '巡检人: ${task.assignee}'),
208
+              ],
209
+            ),
210
+            const SizedBox(height: 8),
211
+            // 进度条
212
+            Row(
213
+              children: [
214
+                Text(
215
+                  '巡检进度: ${task.checkpointCompleted}/${task.checkpointTotal}',
216
+                  style: const TextStyle(fontSize: 12),
217
+                ),
218
+                const Spacer(),
219
+                Text(
220
+                  '${(task.progress * 100).toStringAsFixed(0)}%',
221
+                  style: TextStyle(
222
+                    fontSize: 12,
223
+                    color: _getStatusColor(task.status),
224
+                    fontWeight: FontWeight.bold,
225
+                  ),
226
+                ),
227
+              ],
228
+            ),
229
+            const SizedBox(height: 4),
230
+            ClipRRect(
231
+              borderRadius: BorderRadius.circular(4),
232
+              child: LinearProgressIndicator(
233
+                value: task.progress,
234
+                backgroundColor: Colors.grey[200],
235
+                valueColor: AlwaysStoppedAnimation(_getStatusColor(task.status)),
236
+                minHeight: 6,
237
+              ),
238
+            ),
239
+          ],
240
+        ),
241
+      ),
242
+    );
243
+  }
244
+
245
+  Widget _buildInfoItem(IconData icon, String text) {
246
+    return Row(
247
+      mainAxisSize: MainAxisSize.min,
248
+      children: [
249
+        Icon(icon, size: 14, color: Colors.grey[600]),
250
+        const SizedBox(width: 4),
251
+        Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[700])),
252
+      ],
253
+    );
254
+  }
255
+}

+ 64
- 0
mobile-app/lib/features/revenue/models/bill_model.dart ファイルの表示

@@ -0,0 +1,64 @@
1
+/// 账单模型
2
+class BillModel {
3
+  final String id;
4
+  final String billNo;
5
+  final String userName;
6
+  final String userAddress;
7
+  final String meterNo;
8
+  final double previousReading;
9
+  final double currentReading;
10
+  final double usage;
11
+  final double amount;
12
+  final String status; // unpaid, paid, overdue
13
+  final String period;
14
+  final DateTime dueDate;
15
+
16
+  BillModel({
17
+    required this.id,
18
+    required this.billNo,
19
+    required this.userName,
20
+    required this.userAddress,
21
+    required this.meterNo,
22
+    required this.previousReading,
23
+    required this.currentReading,
24
+    required this.usage,
25
+    required this.amount,
26
+    required this.status,
27
+    required this.period,
28
+    required this.dueDate,
29
+  });
30
+
31
+  factory BillModel.fromJson(Map<String, dynamic> json) {
32
+    return BillModel(
33
+      id: json['id']?.toString() ?? '',
34
+      billNo: json['billNo'] ?? '',
35
+      userName: json['userName'] ?? '',
36
+      userAddress: json['userAddress'] ?? '',
37
+      meterNo: json['meterNo'] ?? '',
38
+      previousReading: (json['previousReading'] ?? 0).toDouble(),
39
+      currentReading: (json['currentReading'] ?? 0).toDouble(),
40
+      usage: (json['usage'] ?? 0).toDouble(),
41
+      amount: (json['amount'] ?? 0).toDouble(),
42
+      status: json['status'] ?? 'unpaid',
43
+      period: json['period'] ?? '',
44
+      dueDate: DateTime.tryParse(json['dueDate'] ?? '') ?? DateTime.now(),
45
+    );
46
+  }
47
+
48
+  Map<String, dynamic> toJson() {
49
+    return {
50
+      'id': id,
51
+      'billNo': billNo,
52
+      'userName': userName,
53
+      'userAddress': userAddress,
54
+      'meterNo': meterNo,
55
+      'previousReading': previousReading,
56
+      'currentReading': currentReading,
57
+      'usage': usage,
58
+      'amount': amount,
59
+      'status': status,
60
+      'period': period,
61
+      'dueDate': dueDate.toIso8601String(),
62
+    };
63
+  }
64
+}

+ 59
- 0
mobile-app/lib/features/revenue/models/meter_reading_model.dart ファイルの表示

@@ -0,0 +1,59 @@
1
+/// 抄表记录模型
2
+class MeterReadingModel {
3
+  final String id;
4
+  final String meterNo;
5
+  final String userName;
6
+  final String address;
7
+  final double previousReading;
8
+  final double? currentReading;
9
+  final String status; // pending, completed, abnormal
10
+  final DateTime readingDate;
11
+  final String? reader;
12
+  final String? remark;
13
+
14
+  MeterReadingModel({
15
+    required this.id,
16
+    required this.meterNo,
17
+    required this.userName,
18
+    required this.address,
19
+    required this.previousReading,
20
+    this.currentReading,
21
+    required this.status,
22
+    required this.readingDate,
23
+    this.reader,
24
+    this.remark,
25
+  });
26
+
27
+  double? get usage =>
28
+      currentReading != null ? currentReading! - previousReading : null;
29
+
30
+  factory MeterReadingModel.fromJson(Map<String, dynamic> json) {
31
+    return MeterReadingModel(
32
+      id: json['id']?.toString() ?? '',
33
+      meterNo: json['meterNo'] ?? '',
34
+      userName: json['userName'] ?? '',
35
+      address: json['address'] ?? '',
36
+      previousReading: (json['previousReading'] ?? 0).toDouble(),
37
+      currentReading: json['currentReading']?.toDouble(),
38
+      status: json['status'] ?? 'pending',
39
+      readingDate: DateTime.tryParse(json['readingDate'] ?? '') ?? DateTime.now(),
40
+      reader: json['reader'],
41
+      remark: json['remark'],
42
+    );
43
+  }
44
+
45
+  Map<String, dynamic> toJson() {
46
+    return {
47
+      'id': id,
48
+      'meterNo': meterNo,
49
+      'userName': userName,
50
+      'address': address,
51
+      'previousReading': previousReading,
52
+      'currentReading': currentReading,
53
+      'status': status,
54
+      'readingDate': readingDate.toIso8601String(),
55
+      'reader': reader,
56
+      'remark': remark,
57
+    };
58
+  }
59
+}

+ 223
- 0
mobile-app/lib/features/revenue/pages/bill_list_page.dart ファイルの表示

@@ -0,0 +1,223 @@
1
+import 'package:flutter/material.dart';
2
+import '../models/bill_model.dart';
3
+
4
+/// 账单列表页面
5
+class BillListPage extends StatefulWidget {
6
+  const BillListPage({super.key});
7
+
8
+  @override
9
+  State<BillListPage> createState() => _BillListPageState();
10
+}
11
+
12
+class _BillListPageState extends State<BillListPage> {
13
+  List<BillModel> _bills = [];
14
+  bool _isLoading = true;
15
+  String _filterStatus = 'all';
16
+
17
+  @override
18
+  void initState() {
19
+    super.initState();
20
+    _loadData();
21
+  }
22
+
23
+  void _loadData() {
24
+    Future.delayed(const Duration(milliseconds: 500), () {
25
+      setState(() {
26
+        _bills = _generateMockData();
27
+        _isLoading = false;
28
+      });
29
+    });
30
+  }
31
+
32
+  List<BillModel> _generateMockData() {
33
+    return [
34
+      BillModel(
35
+        id: '1', billNo: 'BILL-2024-001', userName: '张三',
36
+        userAddress: '城东街道12号', meterNo: 'WM-2024-001',
37
+        previousReading: 1250.5, currentReading: 1285.0,
38
+        usage: 34.5, amount: 138.0, status: 'unpaid',
39
+        period: '2024-01', dueDate: DateTime.now().add(const Duration(days: 15)),
40
+      ),
41
+      BillModel(
42
+        id: '2', billNo: 'BILL-2024-002', userName: '李四',
43
+        userAddress: '城西大道88号', meterNo: 'WM-2024-002',
44
+        previousReading: 3420.0, currentReading: 3456.0,
45
+        usage: 36.0, amount: 144.0, status: 'paid',
46
+        period: '2024-01', dueDate: DateTime.now().subtract(const Duration(days: 5)),
47
+      ),
48
+      BillModel(
49
+        id: '3', billNo: 'BILL-2024-003', userName: '王五',
50
+        userAddress: '南区花园5栋', meterNo: 'WM-2024-003',
51
+        previousReading: 890.0, currentReading: 932.0,
52
+        usage: 42.0, amount: 168.0, status: 'overdue',
53
+        period: '2023-12', dueDate: DateTime.now().subtract(const Duration(days: 10)),
54
+      ),
55
+      BillModel(
56
+        id: '4', billNo: 'BILL-2024-004', userName: '赵六',
57
+        userAddress: '北区商铺16号', meterNo: 'WM-2024-004',
58
+        previousReading: 5600.0, currentReading: 5780.0,
59
+        usage: 180.0, amount: 720.0, status: 'unpaid',
60
+        period: '2024-01', dueDate: DateTime.now().add(const Duration(days: 20)),
61
+      ),
62
+    ];
63
+  }
64
+
65
+  List<BillModel> get _filteredBills {
66
+    if (_filterStatus == 'all') return _bills;
67
+    return _bills.where((b) => b.status == _filterStatus).toList();
68
+  }
69
+
70
+  Color _getStatusColor(String status) {
71
+    switch (status) {
72
+      case 'unpaid': return Colors.orange;
73
+      case 'paid': return Colors.green;
74
+      case 'overdue': return Colors.red;
75
+      default: return Colors.grey;
76
+    }
77
+  }
78
+
79
+  String _getStatusText(String status) {
80
+    switch (status) {
81
+      case 'unpaid': return '未缴费';
82
+      case 'paid': return '已缴费';
83
+      case 'overdue': return '已逾期';
84
+      default: return status;
85
+    }
86
+  }
87
+
88
+  @override
89
+  Widget build(BuildContext context) {
90
+    return Scaffold(
91
+      appBar: AppBar(
92
+        title: const Text('账单列表'),
93
+      ),
94
+      body: _isLoading
95
+          ? const Center(child: CircularProgressIndicator())
96
+          : Column(
97
+              children: [
98
+                // 筛选
99
+                Padding(
100
+                  padding: const EdgeInsets.all(12),
101
+                  child: Row(
102
+                    children: [
103
+                      _buildFilterChip('全部', 'all'),
104
+                      _buildFilterChip('未缴费', 'unpaid'),
105
+                      _buildFilterChip('已缴费', 'paid'),
106
+                      _buildFilterChip('已逾期', 'overdue'),
107
+                    ],
108
+                  ),
109
+                ),
110
+                // 列表
111
+                Expanded(
112
+                  child: _filteredBills.isEmpty
113
+                      ? const Center(child: Text('暂无账单'))
114
+                      : ListView.builder(
115
+                          padding: const EdgeInsets.symmetric(horizontal: 12),
116
+                          itemCount: _filteredBills.length,
117
+                          itemBuilder: (context, index) {
118
+                            return _buildBillCard(_filteredBills[index]);
119
+                          },
120
+                        ),
121
+                ),
122
+              ],
123
+            ),
124
+    );
125
+  }
126
+
127
+  Widget _buildFilterChip(String label, String value) {
128
+    final isSelected = _filterStatus == value;
129
+    return Padding(
130
+      padding: const EdgeInsets.only(right: 8),
131
+      child: FilterChip(
132
+        label: Text(label, style: const TextStyle(fontSize: 12)),
133
+        selected: isSelected,
134
+        onSelected: (selected) {
135
+          setState(() {
136
+            _filterStatus = value;
137
+          });
138
+        },
139
+        selectedColor: const Color(0xFF1976D2).withOpacity(0.2),
140
+        checkmarkColor: const Color(0xFF1976D2),
141
+      ),
142
+    );
143
+  }
144
+
145
+  Widget _buildBillCard(BillModel bill) {
146
+    return Card(
147
+      margin: const EdgeInsets.only(bottom: 12),
148
+      child: Padding(
149
+        padding: const EdgeInsets.all(16),
150
+        child: Column(
151
+          crossAxisAlignment: CrossAxisAlignment.start,
152
+          children: [
153
+            Row(
154
+              children: [
155
+                const Icon(Icons.receipt, color: Color(0xFF1976D2), size: 20),
156
+                const SizedBox(width: 8),
157
+                Expanded(
158
+                  child: Column(
159
+                    crossAxisAlignment: CrossAxisAlignment.start,
160
+                    children: [
161
+                      Text(bill.userName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
162
+                      Text(bill.billNo, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
163
+                    ],
164
+                  ),
165
+                ),
166
+                Container(
167
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
168
+                  decoration: BoxDecoration(
169
+                    color: _getStatusColor(bill.status).withOpacity(0.1),
170
+                    borderRadius: BorderRadius.circular(12),
171
+                  ),
172
+                  child: Text(
173
+                    _getStatusText(bill.status),
174
+                    style: TextStyle(fontSize: 12, color: _getStatusColor(bill.status)),
175
+                  ),
176
+                ),
177
+              ],
178
+            ),
179
+            const Divider(height: 20),
180
+            Row(
181
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
182
+              children: [
183
+                _buildDetailItem('用水量', '${bill.usage.toStringAsFixed(1)} m³'),
184
+                _buildDetailItem('水费', '¥${bill.amount.toStringAsFixed(2)}'),
185
+                _buildDetailItem('账期', bill.period),
186
+              ],
187
+            ),
188
+            const SizedBox(height: 8),
189
+            Text(
190
+              '地址: ${bill.userAddress}',
191
+              style: TextStyle(fontSize: 12, color: Colors.grey[600]),
192
+            ),
193
+            if (bill.status != 'paid') ...[
194
+              const SizedBox(height: 12),
195
+              Align(
196
+                alignment: Alignment.centerRight,
197
+                child: ElevatedButton(
198
+                  onPressed: () {
199
+                    // TODO: 缴费操作
200
+                  },
201
+                  style: ElevatedButton.styleFrom(
202
+                    minimumSize: const Size(100, 36),
203
+                  ),
204
+                  child: const Text('去缴费'),
205
+                ),
206
+              ),
207
+            ],
208
+          ],
209
+        ),
210
+      ),
211
+    );
212
+  }
213
+
214
+  Widget _buildDetailItem(String label, String value) {
215
+    return Column(
216
+      children: [
217
+        Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
218
+        const SizedBox(height: 4),
219
+        Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
220
+      ],
221
+    );
222
+  }
223
+}

+ 238
- 0
mobile-app/lib/features/revenue/pages/meter_reading_page.dart ファイルの表示

@@ -0,0 +1,238 @@
1
+import 'package:flutter/material.dart';
2
+import '../models/meter_reading_model.dart';
3
+import 'bill_list_page.dart';
4
+
5
+/// 抄表页面(营收 Tab 入口,包含抄表列表和账单入口)
6
+class MeterReadingPage extends StatefulWidget {
7
+  const MeterReadingPage({super.key});
8
+
9
+  @override
10
+  State<MeterReadingPage> createState() => _MeterReadingPageState();
11
+}
12
+
13
+class _MeterReadingPageState extends State<MeterReadingPage>
14
+    with AutomaticKeepAliveClientMixin {
15
+  List<MeterReadingModel> _readings = [];
16
+  bool _isLoading = true;
17
+
18
+  @override
19
+  bool get wantKeepAlive => true;
20
+
21
+  @override
22
+  void initState() {
23
+    super.initState();
24
+    _loadData();
25
+  }
26
+
27
+  void _loadData() {
28
+    Future.delayed(const Duration(milliseconds: 500), () {
29
+      setState(() {
30
+        _readings = _generateMockData();
31
+        _isLoading = false;
32
+      });
33
+    });
34
+  }
35
+
36
+  List<MeterReadingModel> _generateMockData() {
37
+    return [
38
+      MeterReadingModel(
39
+        id: '1', meterNo: 'WM-2024-001', userName: '张三',
40
+        address: '城东街道12号', previousReading: 1250.5,
41
+        status: 'pending', readingDate: DateTime.now(),
42
+      ),
43
+      MeterReadingModel(
44
+        id: '2', meterNo: 'WM-2024-002', userName: '李四',
45
+        address: '城西大道88号', previousReading: 3420.0,
46
+        currentReading: 3456.0, status: 'completed',
47
+        readingDate: DateTime.now().subtract(const Duration(days: 1)),
48
+        reader: '王工',
49
+      ),
50
+      MeterReadingModel(
51
+        id: '3', meterNo: 'WM-2024-003', userName: '王五',
52
+        address: '南区花园5栋', previousReading: 890.0,
53
+        status: 'pending', readingDate: DateTime.now(),
54
+      ),
55
+      MeterReadingModel(
56
+        id: '4', meterNo: 'WM-2024-004', userName: '赵六',
57
+        address: '北区商铺16号', previousReading: 5600.0,
58
+        currentReading: 5780.0, status: 'completed',
59
+        readingDate: DateTime.now().subtract(const Duration(days: 2)),
60
+        reader: '李工',
61
+      ),
62
+      MeterReadingModel(
63
+        id: '5', meterNo: 'WM-2024-005', userName: '孙七',
64
+        address: '中心路28号', previousReading: 2100.0,
65
+        status: 'abnormal', readingDate: DateTime.now(),
66
+        remark: '水表损坏',
67
+      ),
68
+    ];
69
+  }
70
+
71
+  Color _getStatusColor(String status) {
72
+    switch (status) {
73
+      case 'pending': return Colors.orange;
74
+      case 'completed': return Colors.green;
75
+      case 'abnormal': return Colors.red;
76
+      default: return Colors.grey;
77
+    }
78
+  }
79
+
80
+  String _getStatusText(String status) {
81
+    switch (status) {
82
+      case 'pending': return '待抄表';
83
+      case 'completed': return '已完成';
84
+      case 'abnormal': return '异常';
85
+      default: return status;
86
+    }
87
+  }
88
+
89
+  void _showReadingDialog(MeterReadingModel reading) {
90
+    final controller = TextEditingController();
91
+    showDialog(
92
+      context: context,
93
+      builder: (ctx) => AlertDialog(
94
+        title: Text('抄表 - ${reading.userName}'),
95
+        content: Column(
96
+          mainAxisSize: MainAxisSize.min,
97
+          crossAxisAlignment: CrossAxisAlignment.start,
98
+          children: [
99
+            Text('表号: ${reading.meterNo}'),
100
+            Text('上次读数: ${reading.previousReading}'),
101
+            const SizedBox(height: 12),
102
+            TextField(
103
+              controller: controller,
104
+              keyboardType: const TextInputType.numberWithOptions(decimal: true),
105
+              decoration: const InputDecoration(
106
+                labelText: '当前读数',
107
+                hintText: '请输入当前水表读数',
108
+              ),
109
+            ),
110
+          ],
111
+        ),
112
+        actions: [
113
+          TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
114
+          ElevatedButton(
115
+            onPressed: () {
116
+              // TODO: 提交抄表数据
117
+              Navigator.pop(ctx);
118
+              ScaffoldMessenger.of(context).showSnackBar(
119
+                const SnackBar(content: Text('抄表数据已提交')),
120
+              );
121
+            },
122
+            child: const Text('提交'),
123
+          ),
124
+        ],
125
+      ),
126
+    );
127
+  }
128
+
129
+  @override
130
+  Widget build(BuildContext context) {
131
+    super.build(context);
132
+    return Scaffold(
133
+      appBar: AppBar(
134
+        title: const Text('营收管理'),
135
+        actions: [
136
+          IconButton(
137
+            icon: const Icon(Icons.receipt_long),
138
+            tooltip: '账单列表',
139
+            onPressed: () {
140
+              Navigator.of(context).push(
141
+                MaterialPageRoute(builder: (_) => const BillListPage()),
142
+              );
143
+            },
144
+          ),
145
+        ],
146
+      ),
147
+      body: _isLoading
148
+          ? const Center(child: CircularProgressIndicator())
149
+          : Column(
150
+              crossAxisAlignment: CrossAxisAlignment.start,
151
+              children: [
152
+                // 统计卡片
153
+                Padding(
154
+                  padding: const EdgeInsets.all(12),
155
+                  child: Row(
156
+                    children: [
157
+                      _buildStatCard('待抄表', '${_readings.where((r) => r.status == 'pending').length}', Colors.orange),
158
+                      const SizedBox(width: 8),
159
+                      _buildStatCard('已完成', '${_readings.where((r) => r.status == 'completed').length}', Colors.green),
160
+                      const SizedBox(width: 8),
161
+                      _buildStatCard('异常', '${_readings.where((r) => r.status == 'abnormal').length}', Colors.red),
162
+                    ],
163
+                  ),
164
+                ),
165
+                const Padding(
166
+                  padding: EdgeInsets.symmetric(horizontal: 12),
167
+                  child: Text('抄表任务', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
168
+                ),
169
+                // 抄表列表
170
+                Expanded(
171
+                  child: ListView.builder(
172
+                    padding: const EdgeInsets.all(12),
173
+                    itemCount: _readings.length,
174
+                    itemBuilder: (context, index) {
175
+                      final reading = _readings[index];
176
+                      return Card(
177
+                        margin: const EdgeInsets.only(bottom: 8),
178
+                        child: ListTile(
179
+                          leading: CircleAvatar(
180
+                            backgroundColor: _getStatusColor(reading.status).withOpacity(0.1),
181
+                            child: Icon(
182
+                              Icons.speed,
183
+                              color: _getStatusColor(reading.status),
184
+                            ),
185
+                          ),
186
+                          title: Text(reading.userName),
187
+                          subtitle: Text('${reading.meterNo} · ${reading.address}'),
188
+                          trailing: Column(
189
+                            mainAxisAlignment: MainAxisAlignment.center,
190
+                            crossAxisAlignment: CrossAxisAlignment.end,
191
+                            children: [
192
+                              Text(
193
+                                _getStatusText(reading.status),
194
+                                style: TextStyle(
195
+                                  fontSize: 12,
196
+                                  color: _getStatusColor(reading.status),
197
+                                  fontWeight: FontWeight.bold,
198
+                                ),
199
+                              ),
200
+                              if (reading.status == 'pending')
201
+                                TextButton(
202
+                                  onPressed: () => _showReadingDialog(reading),
203
+                                  style: TextButton.styleFrom(
204
+                                    padding: EdgeInsets.zero,
205
+                                    minimumSize: const Size(0, 0),
206
+                                    tapTargetSize: MaterialTapTargetSize.shrinkWrap,
207
+                                  ),
208
+                                  child: const Text('抄表', style: TextStyle(fontSize: 12)),
209
+                                ),
210
+                            ],
211
+                          ),
212
+                        ),
213
+                      );
214
+                    },
215
+                  ),
216
+                ),
217
+              ],
218
+            ),
219
+    );
220
+  }
221
+
222
+  Widget _buildStatCard(String label, String count, Color color) {
223
+    return Expanded(
224
+      child: Card(
225
+        child: Padding(
226
+          padding: const EdgeInsets.all(12),
227
+          child: Column(
228
+            children: [
229
+              Text(count, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)),
230
+              const SizedBox(height: 4),
231
+              Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
232
+            ],
233
+          ),
234
+        ),
235
+      ),
236
+    );
237
+  }
238
+}

+ 48
- 0
mobile-app/lib/features/water_supply/models/monitor_data_model.dart ファイルの表示

@@ -0,0 +1,48 @@
1
+/// 监测数据模型
2
+class MonitorDataModel {
3
+  final String id;
4
+  final String stationName;
5
+  final String stationCode;
6
+  final double pressure;
7
+  final double flow;
8
+  final double quality;
9
+  final String status;
10
+  final DateTime updateTime;
11
+
12
+  MonitorDataModel({
13
+    required this.id,
14
+    required this.stationName,
15
+    required this.stationCode,
16
+    required this.pressure,
17
+    required this.flow,
18
+    required this.quality,
19
+    required this.status,
20
+    required this.updateTime,
21
+  });
22
+
23
+  factory MonitorDataModel.fromJson(Map<String, dynamic> json) {
24
+    return MonitorDataModel(
25
+      id: json['id']?.toString() ?? '',
26
+      stationName: json['stationName'] ?? '',
27
+      stationCode: json['stationCode'] ?? '',
28
+      pressure: (json['pressure'] ?? 0).toDouble(),
29
+      flow: (json['flow'] ?? 0).toDouble(),
30
+      quality: (json['quality'] ?? 0).toDouble(),
31
+      status: json['status'] ?? 'normal',
32
+      updateTime: DateTime.tryParse(json['updateTime'] ?? '') ?? DateTime.now(),
33
+    );
34
+  }
35
+
36
+  Map<String, dynamic> toJson() {
37
+    return {
38
+      'id': id,
39
+      'stationName': stationName,
40
+      'stationCode': stationCode,
41
+      'pressure': pressure,
42
+      'flow': flow,
43
+      'quality': quality,
44
+      'status': status,
45
+      'updateTime': updateTime.toIso8601String(),
46
+    };
47
+  }
48
+}

+ 194
- 0
mobile-app/lib/features/water_supply/pages/monitor_list_page.dart ファイルの表示

@@ -0,0 +1,194 @@
1
+import 'package:flutter/material.dart';
2
+import '../models/monitor_data_model.dart';
3
+
4
+/// 供水监测数据列表页面
5
+class MonitorListPage extends StatefulWidget {
6
+  const MonitorListPage({super.key});
7
+
8
+  @override
9
+  State<MonitorListPage> createState() => _MonitorListPageState();
10
+}
11
+
12
+class _MonitorListPageState extends State<MonitorListPage>
13
+    with AutomaticKeepAliveClientMixin {
14
+  List<MonitorDataModel> _monitorData = [];
15
+  bool _isLoading = true;
16
+
17
+  @override
18
+  bool get wantKeepAlive => true;
19
+
20
+  @override
21
+  void initState() {
22
+    super.initState();
23
+    _loadData();
24
+  }
25
+
26
+  void _loadData() {
27
+    // 模拟数据加载
28
+    Future.delayed(const Duration(milliseconds: 500), () {
29
+      setState(() {
30
+        _monitorData = _generateMockData();
31
+        _isLoading = false;
32
+      });
33
+    });
34
+  }
35
+
36
+  List<MonitorDataModel> _generateMockData() {
37
+    final stations = [
38
+      ('城东加压站', 'ST-001'),
39
+      ('城西水厂', 'ST-002'),
40
+      ('南区配水站', 'ST-003'),
41
+      ('北区调蓄池', 'ST-004'),
42
+      ('中心泵站', 'ST-005'),
43
+      ('开发区监测点', 'ST-006'),
44
+      ('高新区水厂', 'ST-007'),
45
+      ('工业园加压站', 'ST-008'),
46
+    ];
47
+
48
+    return stations.map((station) {
49
+      return MonitorDataModel(
50
+        id: station.$2,
51
+        stationName: station.$1,
52
+        stationCode: station.$2,
53
+        pressure: 0.2 + (station.$2.hashCode % 30) / 100.0,
54
+        flow: 100 + (station.$2.hashCode % 500).toDouble(),
55
+        quality: 95 + (station.$2.hashCode % 5).toDouble(),
56
+        status: station.$2.hashCode % 7 == 0 ? 'warning' : 'normal',
57
+        updateTime: DateTime.now().subtract(
58
+          Duration(minutes: station.$2.hashCode % 60),
59
+        ),
60
+      );
61
+    }).toList();
62
+  }
63
+
64
+  Color _getStatusColor(String status) {
65
+    switch (status) {
66
+      case 'normal':
67
+        return Colors.green;
68
+      case 'warning':
69
+        return Colors.orange;
70
+      case 'error':
71
+        return Colors.red;
72
+      default:
73
+        return Colors.grey;
74
+    }
75
+  }
76
+
77
+  @override
78
+  Widget build(BuildContext context) {
79
+    super.build(context);
80
+    return Scaffold(
81
+      appBar: AppBar(
82
+        title: const Text('供水监测'),
83
+        actions: [
84
+          IconButton(
85
+            icon: const Icon(Icons.refresh),
86
+            onPressed: () {
87
+              setState(() {
88
+                _isLoading = true;
89
+              });
90
+              _loadData();
91
+            },
92
+          ),
93
+        ],
94
+      ),
95
+      body: _isLoading
96
+          ? const Center(child: CircularProgressIndicator())
97
+          : RefreshIndicator(
98
+              onRefresh: () async {
99
+                setState(() {
100
+                  _isLoading = true;
101
+                });
102
+                _loadData();
103
+              },
104
+              child: ListView.builder(
105
+                padding: const EdgeInsets.all(12),
106
+                itemCount: _monitorData.length,
107
+                itemBuilder: (context, index) {
108
+                  final item = _monitorData[index];
109
+                  return _buildMonitorCard(item);
110
+                },
111
+              ),
112
+            ),
113
+    );
114
+  }
115
+
116
+  Widget _buildMonitorCard(MonitorDataModel item) {
117
+    return Card(
118
+      margin: const EdgeInsets.only(bottom: 12),
119
+      child: Padding(
120
+        padding: const EdgeInsets.all(16),
121
+        child: Column(
122
+          crossAxisAlignment: CrossAxisAlignment.start,
123
+          children: [
124
+            Row(
125
+              children: [
126
+                const Icon(Icons.water_drop, color: Color(0xFF1976D2), size: 20),
127
+                const SizedBox(width: 8),
128
+                Expanded(
129
+                  child: Text(
130
+                    item.stationName,
131
+                    style: const TextStyle(
132
+                      fontSize: 16,
133
+                      fontWeight: FontWeight.bold,
134
+                    ),
135
+                  ),
136
+                ),
137
+                Container(
138
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
139
+                  decoration: BoxDecoration(
140
+                    color: _getStatusColor(item.status).withOpacity(0.1),
141
+                    borderRadius: BorderRadius.circular(12),
142
+                    border: Border.all(
143
+                      color: _getStatusColor(item.status),
144
+                    ),
145
+                  ),
146
+                  child: Text(
147
+                    item.status == 'normal' ? '正常' : '告警',
148
+                    style: TextStyle(
149
+                      fontSize: 12,
150
+                      color: _getStatusColor(item.status),
151
+                    ),
152
+                  ),
153
+                ),
154
+              ],
155
+            ),
156
+            const SizedBox(height: 12),
157
+            Row(
158
+              children: [
159
+                _buildDataItem('水压', '${item.pressure.toStringAsFixed(2)} MPa'),
160
+                _buildDataItem('流量', '${item.flow.toStringAsFixed(1)} m³/h'),
161
+                _buildDataItem('水质', '${item.quality.toStringAsFixed(1)}%'),
162
+              ],
163
+            ),
164
+            const SizedBox(height: 8),
165
+            Text(
166
+              '更新时间: ${item.updateTime.month}/${item.updateTime.day} '
167
+              '${item.updateTime.hour.toString().padLeft(2, '0')}:'
168
+              '${item.updateTime.minute.toString().padLeft(2, '0')}',
169
+              style: TextStyle(fontSize: 12, color: Colors.grey[500]),
170
+            ),
171
+          ],
172
+        ),
173
+      ),
174
+    );
175
+  }
176
+
177
+  Widget _buildDataItem(String label, String value) {
178
+    return Expanded(
179
+      child: Column(
180
+        children: [
181
+          Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
182
+          const SizedBox(height: 4),
183
+          Text(
184
+            value,
185
+            style: const TextStyle(
186
+              fontSize: 14,
187
+              fontWeight: FontWeight.w600,
188
+            ),
189
+          ),
190
+        ],
191
+      ),
192
+    );
193
+  }
194
+}

+ 54
- 0
mobile-app/lib/main.dart ファイルの表示

@@ -0,0 +1,54 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter/services.dart';
3
+import 'package:provider/provider.dart';
4
+import 'core/theme/app_theme.dart';
5
+import 'config/app_routes.dart';
6
+import 'features/auth/services/auth_provider.dart';
7
+import 'shared/services/push_service.dart';
8
+import 'shared/services/cache_service.dart';
9
+
10
+void main() async {
11
+  WidgetsFlutterBinding.ensureInitialized();
12
+
13
+  // 强制竖屏
14
+  await SystemChrome.setPreferredOrientations([
15
+    DeviceOrientation.portraitUp,
16
+    DeviceOrientation.portraitDown,
17
+  ]);
18
+
19
+  // 设置状态栏样式
20
+  SystemChrome.setSystemUIOverlayStyle(
21
+    const SystemUiOverlayStyle(
22
+      statusBarColor: Colors.transparent,
23
+      statusBarIconBrightness: Brightness.light,
24
+    ),
25
+  );
26
+
27
+  // 初始化服务
28
+  await PushService().initialize();
29
+  await CacheService().initialize();
30
+
31
+  runApp(const WaterManagementApp());
32
+}
33
+
34
+/// 供水管理系统三合一APP
35
+class WaterManagementApp extends StatelessWidget {
36
+  const WaterManagementApp({super.key});
37
+
38
+  @override
39
+  Widget build(BuildContext context) {
40
+    return MultiProvider(
41
+      providers: [
42
+        ChangeNotifierProvider(
43
+          create: (_) => AuthProvider()..init(),
44
+        ),
45
+      ],
46
+      child: MaterialApp.router(
47
+        title: '供水管理系统',
48
+        debugShowCheckedModeBanner: false,
49
+        theme: AppTheme.lightTheme,
50
+        routerConfig: AppRoutes.createRouter(),
51
+      ),
52
+    );
53
+  }
54
+}

+ 122
- 0
mobile-app/lib/shared/services/cache_service.dart ファイルの表示

@@ -0,0 +1,122 @@
1
+import 'dart:convert';
2
+
3
+/// 离线缓存服务
4
+/// 基于 Hive 提供本地数据存储能力
5
+class CacheService {
6
+  static CacheService? _instance;
7
+  final Map<String, dynamic> _memoryCache = {};
8
+  bool _isInitialized = false;
9
+
10
+  CacheService._internal();
11
+
12
+  factory CacheService() {
13
+    _instance ??= CacheService._internal();
14
+    return _instance!;
15
+  }
16
+
17
+  /// 初始化缓存(打开 Hive Box)
18
+  Future<void> initialize() async {
19
+    if (_isInitialized) return;
20
+
21
+    try {
22
+      // TODO: 初始化 Hive
23
+      // await Hive.initFlutter();
24
+      // await Hive.openBox(AppConstants.cacheBoxName);
25
+      // await Hive.openBox(AppConstants.userBoxName);
26
+      _isInitialized = true;
27
+      print('[CacheService] 缓存服务已初始化(模拟)');
28
+    } catch (e) {
29
+      print('[CacheService] 初始化失败: $e');
30
+    }
31
+  }
32
+
33
+  /// 写入缓存
34
+  Future<void> put(String key, dynamic value, {String box = 'default'}) async {
35
+    try {
36
+      // TODO: 使用 Hive 持久化
37
+      // final cacheBox = Hive.box(AppConstants.cacheBoxName);
38
+      // await cacheBox.put(key, value);
39
+      _memoryCache['$box:$key'] = value;
40
+    } catch (e) {
41
+      print('[CacheService] 写入缓存失败: $e');
42
+    }
43
+  }
44
+
45
+  /// 读取缓存
46
+  Future<T?> get<T>(String key, {String box = 'default'}) async {
47
+    try {
48
+      // TODO: 使用 Hive 读取
49
+      // final cacheBox = Hive.box(AppConstants.cacheBoxName);
50
+      // return cacheBox.get(key) as T?;
51
+      return _memoryCache['$box:$key'] as T?;
52
+    } catch (e) {
53
+      print('[CacheService] 读取缓存失败: $e');
54
+      return null;
55
+    }
56
+  }
57
+
58
+  /// 删除缓存
59
+  Future<void> delete(String key, {String box = 'default'}) async {
60
+    try {
61
+      _memoryCache.remove('$box:$key');
62
+    } catch (e) {
63
+      print('[CacheService] 删除缓存失败: $e');
64
+    }
65
+  }
66
+
67
+  /// 清空指定 Box 的所有缓存
68
+  Future<void> clearBox({String box = 'default'}) async {
69
+    try {
70
+      _memoryCache.removeWhere((key, _) => key.startsWith('$box:'));
71
+    } catch (e) {
72
+      print('[CacheService] 清空缓存失败: $e');
73
+    }
74
+  }
75
+
76
+  /// 清空所有缓存
77
+  Future<void> clearAll() async {
78
+    try {
79
+      _memoryCache.clear();
80
+    } catch (e) {
81
+      print('[CacheService] 清空所有缓存失败: $e');
82
+    }
83
+  }
84
+
85
+  /// 缓存 JSON 对象
86
+  Future<void> cacheJson(String key, Map<String, dynamic> data, {String box = 'default'}) async {
87
+    await put(key, json.encode(data), box: box);
88
+  }
89
+
90
+  /// 读取缓存的 JSON 对象
91
+  Future<Map<String, dynamic>?> getCachedJson(String key, {String box = 'default'}) async {
92
+    final data = await get<String>(key, box: box);
93
+    if (data == null) return null;
94
+    try {
95
+      return json.decode(data) as Map<String, dynamic>;
96
+    } catch (e) {
97
+      return null;
98
+    }
99
+  }
100
+
101
+  /// 缓存列表数据
102
+  Future<void> cacheList(String key, List<Map<String, dynamic>> items, {String box = 'default'}) async {
103
+    await put(key, json.encode(items), box: box);
104
+  }
105
+
106
+  /// 读取缓存的列表数据
107
+  Future<List<Map<String, dynamic>>?> getCachedList(String key, {String box = 'default'}) async {
108
+    final data = await get<String>(key, box: box);
109
+    if (data == null) return null;
110
+    try {
111
+      final decoded = json.decode(data) as List;
112
+      return decoded.cast<Map<String, dynamic>>();
113
+    } catch (e) {
114
+      return null;
115
+    }
116
+  }
117
+
118
+  /// 检查是否有缓存
119
+  Future<bool> has(String key, {String box = 'default'}) async {
120
+    return _memoryCache.containsKey('$box:$key');
121
+  }
122
+}

+ 141
- 0
mobile-app/lib/shared/services/camera_service.dart ファイルの表示

@@ -0,0 +1,141 @@
1
+import 'dart:io';
2
+
3
+/// 拍照/相册服务
4
+/// 提供拍照和从相册选择图片的功能
5
+class CameraService {
6
+  static CameraService? _instance;
7
+
8
+  CameraService._internal();
9
+
10
+  factory CameraService() {
11
+    _instance ??= CameraService._internal();
12
+    return _instance!;
13
+  }
14
+
15
+  /// 拍照
16
+  Future<CapturedImage?> takePicture() async {
17
+    try {
18
+      // TODO: 使用 image_picker 拍照
19
+      // final picker = ImagePicker();
20
+      // final XFile? image = await picker.pickImage(
21
+      //   source: ImageSource.camera,
22
+      //   maxWidth: 1920,
23
+      //   maxHeight: 1080,
24
+      //   imageQuality: 85,
25
+      // );
26
+      // if (image != null) {
27
+      //   return CapturedImage(
28
+      //     path: image.path,
29
+      //     name: image.name,
30
+      //     size: await image.length(),
31
+      //   );
32
+      // }
33
+
34
+      // 模拟拍照结果
35
+      print('[CameraService] 拍照(模拟)');
36
+      return CapturedImage(
37
+        path: '/mock/camera/photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
38
+        name: 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
39
+        size: 1024 * 1024, // 1MB
40
+        source: ImageSource.camera,
41
+      );
42
+    } catch (e) {
43
+      print('[CameraService] 拍照失败: $e');
44
+      return null;
45
+    }
46
+  }
47
+
48
+  /// 从相册选择图片
49
+  Future<CapturedImage?> pickFromGallery() async {
50
+    try {
51
+      // TODO: 使用 image_picker 从相册选择
52
+      // final picker = ImagePicker();
53
+      // final XFile? image = await picker.pickImage(
54
+      //   source: ImageSource.gallery,
55
+      //   maxWidth: 1920,
56
+      //   maxHeight: 1080,
57
+      //   imageQuality: 85,
58
+      // );
59
+      // if (image != null) {
60
+      //   return CapturedImage(
61
+      //     path: image.path,
62
+      //     name: image.name,
63
+      //     size: await image.length(),
64
+      //   );
65
+      // }
66
+
67
+      // 模拟选择结果
68
+      print('[CameraService] 从相册选择(模拟)');
69
+      return CapturedImage(
70
+        path: '/mock/gallery/image_${DateTime.now().millisecondsSinceEpoch}.jpg',
71
+        name: 'image_${DateTime.now().millisecondsSinceEpoch}.jpg',
72
+        size: 2 * 1024 * 1024, // 2MB
73
+        source: ImageSource.gallery,
74
+      );
75
+    } catch (e) {
76
+      print('[CameraService] 选择图片失败: $e');
77
+      return null;
78
+    }
79
+  }
80
+
81
+  /// 选择多张图片
82
+  Future<List<CapturedImage>> pickMultipleImages() async {
83
+    try {
84
+      // TODO: 使用 image_picker 多选
85
+      // final picker = ImagePicker();
86
+      // final List<XFile> images = await picker.pickMultiImage();
87
+      // return images.map((img) => CapturedImage(...)).toList();
88
+
89
+      print('[CameraService] 多选图片(模拟)');
90
+      return [];
91
+    } catch (e) {
92
+      print('[CameraService] 多选图片失败: $e');
93
+      return [];
94
+    }
95
+  }
96
+
97
+  /// 上传图片文件
98
+  Future<String?> uploadImage(File file, {String? uploadPath}) async {
99
+    try {
100
+      // TODO: 使用 Dio 上传文件
101
+      // final formData = FormData.fromMap({
102
+      //   'file': await MultipartFile.fromFile(file.path),
103
+      // });
104
+      // final response = await DioClient().post('/upload', data: formData);
105
+      // return response.data['data']['url'];
106
+
107
+      print('[CameraService] 上传图片(模拟): ${file.path}');
108
+      return 'https://api.example.com/uploads/mock_image.jpg';
109
+    } catch (e) {
110
+      print('[CameraService] 上传图片失败: $e');
111
+      return null;
112
+    }
113
+  }
114
+}
115
+
116
+/// 拍摄/选择的图片模型
117
+class CapturedImage {
118
+  final String path;
119
+  final String name;
120
+  final int size;
121
+  final ImageSource source;
122
+
123
+  CapturedImage({
124
+    required this.path,
125
+    required this.name,
126
+    required this.size,
127
+    required this.source,
128
+  });
129
+
130
+  String get sizeFormatted {
131
+    if (size < 1024) return '$size B';
132
+    if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB';
133
+    return '${(size / 1024 / 1024).toStringAsFixed(1)} MB';
134
+  }
135
+}
136
+
137
+/// 图片来源
138
+enum ImageSource {
139
+  camera,
140
+  gallery,
141
+}

+ 125
- 0
mobile-app/lib/shared/services/location_service.dart ファイルの表示

@@ -0,0 +1,125 @@
1
+import 'dart:async';
2
+
3
+/// GPS 定位服务
4
+/// 提供获取当前位置、权限请求等功能
5
+class LocationService {
6
+  static LocationService? _instance;
7
+
8
+  LocationService._internal();
9
+
10
+  factory LocationService() {
11
+    _instance ??= LocationService._internal();
12
+    return _instance!;
13
+  }
14
+
15
+  /// 请求位置权限
16
+  Future<bool> requestPermission() async {
17
+    // TODO: 使用 geolocator 请求权限
18
+    // final permission = await Geolocator.requestPermission();
19
+    // return permission == LocationPermission.always || permission == LocationPermission.whileInUse;
20
+    print('[LocationService] 请求位置权限(模拟)');
21
+    return true;
22
+  }
23
+
24
+  /// 检查位置权限状态
25
+  Future<bool> hasPermission() async {
26
+    // TODO: 使用 geolocator 检查权限
27
+    return true;
28
+  }
29
+
30
+  /// 检查 GPS 是否开启
31
+  Future<bool> isGpsEnabled() async {
32
+    // TODO: 使用 geolocator 检查 GPS 状态
33
+    // return await Geolocator.isLocationServiceEnabled();
34
+    return true;
35
+  }
36
+
37
+  /// 获取当前位置
38
+  Future<LocationData?> getCurrentLocation() async {
39
+    try {
40
+      final hasPermission = await requestPermission();
41
+      if (!hasPermission) return null;
42
+
43
+      // TODO: 使用 geolocator 获取位置
44
+      // final position = await Geolocator.getCurrentPosition(
45
+      //   desiredAccuracy: LocationAccuracy.high,
46
+      // );
47
+      // return LocationData(
48
+      //   latitude: position.latitude,
49
+      //   longitude: position.longitude,
50
+      //   accuracy: position.accuracy,
51
+      // );
52
+
53
+      // 模拟位置数据(杭州市中心)
54
+      return LocationData(
55
+        latitude: 30.2741,
56
+        longitude: 120.1551,
57
+        accuracy: 10.0,
58
+        altitude: 15.0,
59
+        timestamp: DateTime.now(),
60
+      );
61
+    } catch (e) {
62
+      print('[LocationService] 获取位置失败: $e');
63
+      return null;
64
+    }
65
+  }
66
+
67
+  /// 持续监听位置变化
68
+  Stream<LocationData> watchLocation() async* {
69
+    // TODO: 使用 geolocator 的位置流
70
+    // final positionStream = Geolocator.getPositionStream(
71
+    //   locationSettings: LocationSettings(accuracy: LocationAccuracy.high),
72
+    // );
73
+    // await for (final position in positionStream) {
74
+    //   yield LocationData.fromPosition(position);
75
+    // }
76
+
77
+    // 模拟位置流
78
+    while (true) {
79
+      await Future.delayed(const Duration(seconds: 5));
80
+      yield LocationData(
81
+        latitude: 30.2741 + (DateTime.now().millisecondsSinceEpoch % 100) / 10000,
82
+        longitude: 120.1551 + (DateTime.now().millisecondsSinceEpoch % 100) / 10000,
83
+        accuracy: 10.0,
84
+        timestamp: DateTime.now(),
85
+      );
86
+    }
87
+  }
88
+
89
+  /// 计算两点之间的距离(米)
90
+  double distanceBetween(
91
+    double startLatitude,
92
+    double startLongitude,
93
+    double endLatitude,
94
+    double endLongitude,
95
+  ) {
96
+    // TODO: 使用 geolocator 计算距离
97
+    // return Geolocator.distanceBetween(startLatitude, startLongitude, endLatitude, endLongitude);
98
+    // 简单估算
99
+    const earthRadius = 6371000.0;
100
+    final dLat = (endLatitude - startLatitude) * 3.14159 / 180;
101
+    final dLon = (endLongitude - startLongitude) * 3.14159 / 180;
102
+    final a = dLat * dLat + dLon * dLon * 0.25;
103
+    return earthRadius * 2 * a * 1000;
104
+  }
105
+}
106
+
107
+/// 位置数据模型
108
+class LocationData {
109
+  final double latitude;
110
+  final double longitude;
111
+  final double accuracy;
112
+  final double? altitude;
113
+  final DateTime timestamp;
114
+
115
+  LocationData({
116
+    required this.latitude,
117
+    required this.longitude,
118
+    required this.accuracy,
119
+    this.altitude,
120
+    required this.timestamp,
121
+  });
122
+
123
+  @override
124
+  String toString() => 'LocationData(lat: $latitude, lng: $longitude, accuracy: $accuracy)';
125
+}

+ 104
- 0
mobile-app/lib/shared/services/push_service.dart ファイルの表示

@@ -0,0 +1,104 @@
1
+import 'dart:async';
2
+
3
+/// 消息推送服务(模拟)
4
+/// 提供推送初始化、消息接收回调等功能
5
+class PushService {
6
+  static PushService? _instance;
7
+  final StreamController<PushMessage> _messageController = StreamController.broadcast();
8
+
9
+  /// 推送消息流
10
+  Stream<PushMessage> get messageStream => _messageController.stream;
11
+
12
+  PushService._internal();
13
+
14
+  factory PushService() {
15
+    _instance ??= PushService._internal();
16
+    return _instance!;
17
+  }
18
+
19
+  /// 初始化推送服务
20
+  Future<void> initialize() async {
21
+    // TODO: 集成 flutter_local_notifications 或 firebase_messaging
22
+    // 1. 初始化通知插件
23
+    // 2. 请求通知权限
24
+    // 3. 获取设备推送 Token
25
+    // 4. 注册推送 Token 到后端
26
+    print('[PushService] 推送服务已初始化(模拟)');
27
+
28
+    // 模拟延迟推送消息
29
+    _simulatePushMessages();
30
+  }
31
+
32
+  /// 获取设备推送 Token
33
+  Future<String?> getDeviceToken() async {
34
+    // TODO: 获取实际的推送 Token
35
+    return 'mock_device_token_${DateTime.now().millisecondsSinceEpoch}';
36
+  }
37
+
38
+  /// 注册推送 Token 到后端
39
+  Future<void> registerToken(String token) async {
40
+    // TODO: 调用后端接口注册 Token
41
+    print('[PushService] Token 已注册: $token');
42
+  }
43
+
44
+  /// 处理接收到的推送消息
45
+  void onMessageReceived(Map<String, dynamic> data) {
46
+    final message = PushMessage(
47
+      id: data['id']?.toString() ?? '',
48
+      title: data['title'] ?? '',
49
+      body: data['body'] ?? '',
50
+      type: data['type'] ?? 'general',
51
+      data: data,
52
+      timestamp: DateTime.now(),
53
+    );
54
+    _messageController.add(message);
55
+  }
56
+
57
+  /// 显示本地通知
58
+  Future<void> showLocalNotification({
59
+    required String title,
60
+    required String body,
61
+    String? payload,
62
+  }) async {
63
+    // TODO: 使用 flutter_local_notifications 显示本地通知
64
+    print('[PushService] 本地通知: $title - $body');
65
+  }
66
+
67
+  /// 模拟推送消息
68
+  void _simulatePushMessages() {
69
+    Future.delayed(const Duration(seconds: 10), () {
70
+      _messageController.add(PushMessage(
71
+        id: 'mock_1',
72
+        title: '巡检提醒',
73
+        body: '您有一条新的巡检任务待执行',
74
+        type: 'patrol',
75
+        data: {},
76
+        timestamp: DateTime.now(),
77
+      ));
78
+    });
79
+  }
80
+
81
+  /// 释放资源
82
+  void dispose() {
83
+    _messageController.close();
84
+  }
85
+}
86
+
87
+/// 推送消息模型
88
+class PushMessage {
89
+  final String id;
90
+  final String title;
91
+  final String body;
92
+  final String type;
93
+  final Map<String, dynamic> data;
94
+  final DateTime timestamp;
95
+
96
+  PushMessage({
97
+    required this.id,
98
+    required this.title,
99
+    required this.body,
100
+    required this.type,
101
+    required this.data,
102
+    required this.timestamp,
103
+  });
104
+}

+ 48
- 0
mobile-app/lib/shared/widgets/empty_state.dart ファイルの表示

@@ -0,0 +1,48 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 空状态组件
4
+class EmptyState extends StatelessWidget {
5
+  final IconData icon;
6
+  final String message;
7
+  final String? actionLabel;
8
+  final VoidCallback? onAction;
9
+
10
+  const EmptyState({
11
+    super.key,
12
+    this.icon = Icons.inbox,
13
+    required this.message,
14
+    this.actionLabel,
15
+    this.onAction,
16
+  });
17
+
18
+  @override
19
+  Widget build(BuildContext context) {
20
+    return Center(
21
+      child: Padding(
22
+        padding: const EdgeInsets.all(32),
23
+        child: Column(
24
+          mainAxisSize: MainAxisSize.min,
25
+          children: [
26
+            Icon(icon, size: 64, color: Colors.grey[400]),
27
+            const SizedBox(height: 16),
28
+            Text(
29
+              message,
30
+              textAlign: TextAlign.center,
31
+              style: TextStyle(
32
+                fontSize: 16,
33
+                color: Colors.grey[600],
34
+              ),
35
+            ),
36
+            if (actionLabel != null && onAction != null) ...[
37
+              const SizedBox(height: 16),
38
+              ElevatedButton(
39
+                onPressed: onAction,
40
+                child: Text(actionLabel!),
41
+              ),
42
+            ],
43
+          ],
44
+        ),
45
+      ),
46
+    );
47
+  }
48
+}

+ 40
- 0
mobile-app/lib/shared/widgets/error_retry.dart ファイルの表示

@@ -0,0 +1,40 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 错误重试组件
4
+class ErrorRetry extends StatelessWidget {
5
+  final String message;
6
+  final VoidCallback onRetry;
7
+
8
+  const ErrorRetry({
9
+    super.key,
10
+    required this.message,
11
+    required this.onRetry,
12
+  });
13
+
14
+  @override
15
+  Widget build(BuildContext context) {
16
+    return Center(
17
+      child: Padding(
18
+        padding: const EdgeInsets.all(32),
19
+        child: Column(
20
+          mainAxisSize: MainAxisSize.min,
21
+          children: [
22
+            Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
23
+            const SizedBox(height: 16),
24
+            Text(
25
+              message,
26
+              textAlign: TextAlign.center,
27
+              style: TextStyle(fontSize: 16, color: Colors.grey[700]),
28
+            ),
29
+            const SizedBox(height: 16),
30
+            ElevatedButton.icon(
31
+              onPressed: onRetry,
32
+              icon: const Icon(Icons.refresh),
33
+              label: const Text('重试'),
34
+            ),
35
+          ],
36
+        ),
37
+      ),
38
+    );
39
+  }
40
+}

+ 45
- 0
mobile-app/lib/shared/widgets/loading_overlay.dart ファイルの表示

@@ -0,0 +1,45 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 加载遮罩组件
4
+class LoadingOverlay extends StatelessWidget {
5
+  final bool isLoading;
6
+  final Widget child;
7
+  final String? message;
8
+
9
+  const LoadingOverlay({
10
+    super.key,
11
+    required this.isLoading,
12
+    required this.child,
13
+    this.message,
14
+  });
15
+
16
+  @override
17
+  Widget build(BuildContext context) {
18
+    return Stack(
19
+      children: [
20
+        child,
21
+        if (isLoading)
22
+          Container(
23
+            color: Colors.black.withOpacity(0.3),
24
+            child: Center(
25
+              child: Card(
26
+                child: Padding(
27
+                  padding: const EdgeInsets.all(24),
28
+                  child: Column(
29
+                    mainAxisSize: MainAxisSize.min,
30
+                    children: [
31
+                      const CircularProgressIndicator(),
32
+                      if (message != null) ...[
33
+                        const SizedBox(height: 16),
34
+                        Text(message!),
35
+                      ],
36
+                    ],
37
+                  ),
38
+                ),
39
+              ),
40
+            ),
41
+          ),
42
+      ],
43
+    );
44
+  }
45
+}

+ 38
- 0
mobile-app/pubspec.yaml ファイルの表示

@@ -0,0 +1,38 @@
1
+name: water_management_app
2
+description: 供水管理系统三合一移动端APP(供水/巡检/营收)
3
+publish_to: 'none'
4
+version: 1.0.0+1
5
+
6
+environment:
7
+  sdk: '>=3.0.0 <4.0.0'
8
+
9
+dependencies:
10
+  flutter:
11
+    sdk: flutter
12
+  cupertino_icons: ^1.0.6
13
+  dio: ^5.4.0
14
+  go_router: ^13.0.0
15
+  provider: ^6.1.1
16
+  shared_preferences: ^2.2.2
17
+  hive: ^2.2.3
18
+  hive_flutter: ^1.1.0
19
+  geolocator: ^10.1.0
20
+  image_picker: ^1.0.4
21
+  flutter_local_notifications: ^17.0.0
22
+  json_annotation: ^4.8.1
23
+  intl: ^0.19.0
24
+  cached_network_image: ^3.3.0
25
+  pull_to_refresh: ^2.0.0
26
+
27
+dev_dependencies:
28
+  flutter_test:
29
+    sdk: flutter
30
+  flutter_lints: ^3.0.1
31
+  build_runner: ^2.4.7
32
+  json_serializable: ^6.7.1
33
+  hive_generator: ^2.0.1
34
+
35
+flutter:
36
+  uses-material-design: true
37
+  assets:
38
+    - assets/images/