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:
2026-06-15 08:52:55 +08:00
parent 37bcc78eee
commit d9d72405c6
41 changed files with 3014 additions and 0 deletions
@@ -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,
),
),
],
),
);
}
}