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