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