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,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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user