first commit

This commit is contained in:
2026-09-21 17:37:53 +08:00
commit 67d4dd5b7c
141 changed files with 8558 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/manual_entry_tab.dart';
import 'widgets/ocr_tab.dart';
/// 记账页内部 Tab:0=手动记账 1=OCR识别
class AddTabNotifier extends Notifier<int> {
@override
int build() => 0;
void set(int index) => state = index;
}
final addTabProvider = NotifierProvider<AddTabNotifier, int>(AddTabNotifier.new);
/// 记账页:手动记账 / OCR识别 双 Tab(复刻 uniApp pages/add/add.vue)
class AddPage extends ConsumerWidget {
const AddPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = ref.watch(addTabProvider);
return Scaffold(
appBar: AppBar(title: const Text('添加账单')),
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: Column(
children: [
const SizedBox(height: 12),
_buildTabs(tabIndex, ref),
Expanded(
child: tabIndex == 0 ? const ManualEntryTab() : const OcrTab(),
),
],
),
),
);
}
Widget _buildTabs(int tabIndex, WidgetRef ref) {
void setTab(int index) => ref.read(addTabProvider.notifier).set(index);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
children: [
_tab('手动记账', 0, tabIndex, setTab),
_tab('OCR识别', 1, tabIndex, setTab),
],
),
);
}
Widget _tab(String text, int index, int tabIndex, void Function(int) setTab) {
final active = tabIndex == index;
return Expanded(
child: GestureDetector(
onTap: () => setTab(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: active
? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)])
: null,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
}
+247
View File
@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../providers/add_bill_provider.dart';
import '../../../providers/category_provider.dart';
import '../../../utils/amount_input_formatter.dart';
import '../../../utils/date_util.dart';
import '../../../utils/toast.dart';
import '../../../widgets/category_picker_grid.dart';
import '../../../widgets/state_views.dart';
/// 手动记账表单(复刻 uniApp add.vue 手动 Tab)
class ManualEntryTab extends ConsumerStatefulWidget {
const ManualEntryTab({super.key});
@override
ConsumerState<ManualEntryTab> createState() => _ManualEntryTabState();
}
class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
late final TextEditingController _descriptionController = TextEditingController();
late final TextEditingController _amountController = TextEditingController();
@override
void dispose() {
_descriptionController.dispose();
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final form = ref.watch(addBillFormProvider);
final notifier = ref.read(addBillFormProvider.notifier);
// OCR「选择此条」外部填充时,同步非受控输入框
ref.listen<int>(addBillFormProvider.select((s) => s.revision), (prev, next) {
if (prev == next) return;
final s = ref.read(addBillFormProvider);
_amountController.text = s.amount?.toStringAsFixed(2) ?? '';
_descriptionController.text = s.description;
});
return ListView(
padding: const EdgeInsets.all(16),
children: [
_sectionLabel('账单类型'),
_typeSelector(form, notifier),
const SizedBox(height: 16),
_sectionLabel('分类'),
_categoryGrid(form, notifier),
const SizedBox(height: 16),
_sectionLabel('金额'),
_amountField(form, notifier),
const SizedBox(height: 16),
_sectionLabel('日期'),
_dateField(context, form, notifier),
const SizedBox(height: 16),
_sectionLabel('备注'),
_descriptionField(form, notifier),
const SizedBox(height: 24),
_submitBtn(context, ref, form, notifier),
const SizedBox(height: 90), // 底部导航栏避让
],
);
}
Widget _sectionLabel(String text) => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
);
// ---- 类型选择 ----
Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, form, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, form, notifier),
],
);
}
Widget _typeCard(String icon, String label, int type, AddBillFormState form, AddBillFormNotifier notifier) {
final active = form.type == type;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setType(type),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? const Color(0xFFC8956E) : Colors.transparent,
width: 2,
),
boxShadow: active
? [BoxShadow(color: const Color(0x33C8956E), blurRadius: 8, offset: const Offset(0, 3))]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
],
),
),
),
);
}
// ---- 分类宫格 ----
Widget _categoryGrid(AddBillFormState form, AddBillFormNotifier notifier) {
return Consumer(builder: (context, ref, _) {
final categoriesAsync = ref.watch(categoriesProvider(form.type));
return categoriesAsync.when(
loading: () => const LoadingView(padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: '分类加载失败:${asApiException(e).message}',
onRetry: () => ref.invalidate(categoriesProvider),
),
data: (categories) => CategoryPickerGrid(
categories: categories,
selectedId: form.categoryId,
onSelect: notifier.setCategory,
crossAxisCount: 5,
),
);
});
}
// ---- 金额 ----
Widget _amountField(AddBillFormState form, AddBillFormNotifier notifier) {
return TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [AmountInputFormatter()],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入金额',
prefixText: '¥ ',
prefixStyle: const TextStyle(fontSize: 18, color: Color(0xFFC8956E), fontWeight: FontWeight.bold),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
),
onChanged: (v) => notifier.setAmount(double.tryParse(v)),
);
}
// ---- 日期 ----
Widget _dateField(BuildContext context, AddBillFormState form, AddBillFormNotifier notifier) {
return GestureDetector(
onTap: () async {
final initial = DateTime.tryParse(form.billDate) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) notifier.setDate(formatDate(picked));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Text(form.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
const Spacer(),
const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)),
],
),
),
);
}
// ---- 备注 ----
Widget _descriptionField(AddBillFormState form, AddBillFormNotifier notifier) {
return TextField(
maxLines: 3,
maxLength: 200,
controller: _descriptionController,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入备注(可选)',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
),
onChanged: notifier.setDescription,
);
}
// ---- 保存 ----
Widget _submitBtn(BuildContext context, WidgetRef ref, AddBillFormState form, AddBillFormNotifier notifier) {
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
),
onPressed: form.submitting
? null
: () async {
try {
await notifier.submit();
_amountController.clear();
_descriptionController.clear();
if (context.mounted) showToast(context, '✅ 记账成功');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '保存失败,请重试', error: true);
}
},
child: form.submitting
? const SizedBox(
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('保 存', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, letterSpacing: 4)),
),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/add_bill_provider.dart';
import '../../../providers/ocr_provider.dart';
import '../../../utils/date_util.dart';
import '../add_page.dart';
/// 单条 OCR 识别结果卡片:可折叠详情 + 「选择此条」填充手动表单
class OcrResultCard extends ConsumerWidget {
final int index;
final bool expanded;
final VoidCallback onToggle;
const OcrResultCard({
super.key,
required this.index,
required this.expanded,
required this.onToggle,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ocrProvider);
if (index >= state.results.length) return const SizedBox.shrink();
final result = state.results[index];
final isExpense = result.isExpense;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE0C9A6)),
),
child: Column(
children: [
// 头部:序号 + 金额 + 置信度 + 展开
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Text(
'#${index + 1}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Color(0xFF8D6E63)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'¥${result.absAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: isExpense ? const Color(0xFFEF5350) : const Color(0xFF66BB6A),
),
),
),
if (result.confidence != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
'置信度 ${(result.confidence! * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more, color: Color(0xFF8D6E63)),
),
],
),
),
),
// 详情
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
firstChild: const SizedBox(width: double.infinity),
secondChild: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1, color: Color(0xFFF0E6D8)),
const SizedBox(height: 8),
_detailRow('类型', isExpense ? '支出' : '收入'),
if (result.merchant != null && result.merchant!.isNotEmpty)
_detailRow('商户', result.merchant!),
if (result.date != null && result.date!.isNotEmpty)
_detailRow('日期', parseBillDate(result.date)),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 38,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
side: const BorderSide(color: Color(0xFFC8956E)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(19)),
),
onPressed: () {
ref.read(addBillFormProvider.notifier).fillFromOcr(result);
// 切回手动记账 Tab 并清空 OCR 状态
ref.read(addTabProvider.notifier).set(0);
ref.read(ocrProvider.notifier).clearImage();
},
child: const Text('选择此条', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
),
),
],
),
),
),
],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 42,
child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037))),
),
],
),
);
}
}
+208
View File
@@ -0,0 +1,208 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/network/api_exception.dart';
import '../../../providers/ocr_provider.dart';
import '../../../utils/toast.dart';
import 'ocr_result_card.dart';
/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab)
class OcrTab extends ConsumerWidget {
const OcrTab({super.key});
Future<void> _chooseImage(BuildContext context, WidgetRef ref, ImageSource source) async {
final picker = ImagePicker();
final xfile = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1600);
if (xfile != null) {
ref.read(ocrProvider.notifier).setImagePath(xfile.path);
}
}
Future<void> _recognize(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).recognize();
final count = ref.read(ocrProvider).results.length;
if (context.mounted) {
showToast(context, count > 0 ? '识别到 $count 条记录' : '未识别到账单信息', error: count == 0);
}
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '识别失败,请重试', error: true);
}
}
Future<void> _saveDirectly(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).saveDirectly();
if (context.mounted) showToast(context, '✅ 已保存识别的账单');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '保存失败,请重试', error: true);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ocrProvider);
final notifier = ref.read(ocrProvider.notifier);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// 图片预览
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('图片预览', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037))),
const SizedBox(height: 10),
GestureDetector(
onTap: () => _showSourceSheet(context, ref),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: state.imagePath != null
? SizedBox(
height: 220,
width: double.infinity,
child: Image.file(
File(state.imagePath!),
fit: BoxFit.contain,
),
)
: Container(
height: 140,
width: double.infinity,
color: const Color(0xFFF5F5F5),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('📷', style: TextStyle(fontSize: 36)),
SizedBox(height: 6),
Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))),
],
),
),
),
),
if (state.imagePath != null)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: notifier.clearImage,
child: const Text('清除图片', style: TextStyle(color: Color(0xFF8D6E63))),
),
),
],
),
),
const SizedBox(height: 14),
// 识别按钮
if (state.imagePath != null && state.results.isEmpty)
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: state.recognizing ? null : () => _recognize(context, ref),
child: state.recognizing
? const SizedBox(
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('识别账单', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
// 识别结果
if (state.results.isNotEmpty) ...[
SizedBox(
width: double.infinity,
height: 44,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
onPressed: state.saving ? null : () => _saveDirectly(context, ref),
icon: state.saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_outlined, size: 18),
label: const Text('直接存储', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'识别到 ${state.results.length} 条账单记录',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
),
const SizedBox(height: 10),
for (var i = 0; i < state.results.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: OcrResultCard(
index: i,
expanded: state.expandedIndexes.contains(i),
onToggle: () => notifier.toggleExpanded(i),
),
),
],
const SizedBox(height: 90), // 底部导航栏避让
],
);
}
void _showSourceSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍照'),
onTap: () {
Navigator.pop(sheetCtx);
_chooseImage(context, ref, ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () {
Navigator.pop(sheetCtx);
_chooseImage(context, ref, ImageSource.gallery);
},
),
],
),
),
);
}
}
+358
View File
@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../providers/bill_edit_provider.dart';
import '../../providers/category_provider.dart';
import '../../utils/amount_input_formatter.dart';
import '../../utils/date_util.dart';
import '../../utils/toast.dart';
import '../../widgets/category_picker_grid.dart';
import '../../widgets/state_views.dart';
/// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除)
class BillEditPage extends ConsumerStatefulWidget {
const BillEditPage({super.key, required this.billId});
final int billId;
@override
ConsumerState<BillEditPage> createState() => _BillEditPageState();
}
class _BillEditPageState extends ConsumerState<BillEditPage> {
final _amountController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_amountController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(billEditProvider(widget.billId));
final notifier = ref.read(billEditProvider(widget.billId).notifier);
// 服务端数据回填时同步非受控输入框(与记账页同一套 revision 机制)
ref.listen<int>(
billEditProvider(widget.billId).select((s) => s.revision),
(prev, next) {
if (prev == next) return;
final s = ref.read(billEditProvider(widget.billId));
_amountController.text = s.amount?.toStringAsFixed(2) ?? '';
_descriptionController.text = s.description;
},
);
return Scaffold(
appBar: AppBar(title: const Text('编辑账单')),
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: _body(state, notifier),
),
);
}
Widget _body(BillEditState state, BillEditNotifier notifier) {
if (state.loading) {
return const Center(child: LoadingView(tip: '账单加载中...'));
}
if (state.loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ErrorView(message: state.loadError!, onRetry: notifier.reload),
),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
_sectionLabel('账单类型'),
_typeSelector(state, notifier),
const SizedBox(height: 16),
_sectionLabel('分类'),
_categorySection(state, notifier),
const SizedBox(height: 16),
_sectionLabel('金额'),
_amountField(state, notifier),
const SizedBox(height: 16),
_sectionLabel('日期'),
_dateField(state, notifier),
const SizedBox(height: 16),
_sectionLabel('备注'),
_descriptionField(notifier),
const SizedBox(height: 28),
_footer(state, notifier),
const SizedBox(height: 40),
],
);
}
Widget _sectionLabel(String text) => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
);
// ---- 类型 ----
Widget _typeSelector(BillEditState state, BillEditNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, state, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, state, notifier),
],
);
}
Widget _typeCard(
String icon,
String label,
int type,
BillEditState state,
BillEditNotifier notifier,
) {
final active = state.type == type;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setType(type),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? const Color(0xFFC8956E) : Colors.transparent,
width: 2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
],
),
),
),
);
}
// ---- 分类 ----
Widget _categorySection(BillEditState state, BillEditNotifier notifier) {
return Consumer(
builder: (context, ref, _) {
final categoriesAsync = ref.watch(categoriesProvider(state.type));
return categoriesAsync.when(
loading: () => const LoadingView(padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(message: asApiException(e).message),
data: (categories) => CategoryPickerGrid(
categories: categories,
selectedId: state.categoryId,
onSelect: notifier.setCategory,
crossAxisCount: 4,
),
);
},
);
}
// ---- 金额 ----
Widget _amountField(BillEditState state, BillEditNotifier notifier) {
return TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [AmountInputFormatter()],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入金额',
prefixText: '¥ ',
prefixStyle: const TextStyle(
fontSize: 18,
color: Color(0xFFC8956E),
fontWeight: FontWeight.bold,
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onChanged: (v) => notifier.setAmount(double.tryParse(v)),
);
}
// ---- 日期 ----
Widget _dateField(BillEditState state, BillEditNotifier notifier) {
return GestureDetector(
onTap: () async {
final initial = DateTime.tryParse(state.billDate) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) notifier.setDate(formatDate(picked));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Text(state.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
const Spacer(),
const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)),
],
),
),
);
}
// ---- 备注 ----
Widget _descriptionField(BillEditNotifier notifier) {
return TextField(
controller: _descriptionController,
maxLines: 3,
maxLength: 200,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入备注(可选)',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onChanged: notifier.setDescription,
);
}
// ---- 底部:删除 + 保存 ----
Widget _footer(BillEditState state, BillEditNotifier notifier) {
final busy = state.submitting;
return Row(
children: [
Expanded(
child: SizedBox(
height: 48,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFFD32F2F),
side: const BorderSide(color: Color(0xFFE0C9A6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: busy ? null : () => _confirmDelete(notifier),
child: const Text('删除', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: busy ? null : () => _save(notifier),
child: busy
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text(
'保 存',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: 3),
),
),
),
),
],
);
}
Future<void> _save(BillEditNotifier notifier) async {
try {
await notifier.save();
if (!mounted) return;
showToast(context, '✅ 已保存');
Navigator.of(context).pop();
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '保存失败,请重试', error: true);
}
}
Future<void> _confirmDelete(BillEditNotifier notifier) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除这笔账单吗?删除后无法恢复'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))),
),
],
),
);
if (confirmed != true) return;
try {
await notifier.remove();
if (!mounted) return;
showToast(context, '已删除');
Navigator.of(context).pop();
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '删除失败,请重试', error: true);
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../models/bill.dart';
import '../../models/statistics_response.dart';
import '../../providers/bill_provider.dart';
import '../../providers/bill_refresh.dart';
import '../../providers/budget_provider.dart';
import '../../utils/amount_util.dart';
import '../../utils/bill_grouping.dart';
import '../../utils/date_util.dart';
import '../../widgets/state_views.dart';
import '../bill/bill_edit_page.dart';
import 'widgets/bill_group_card.dart';
import 'widgets/budget_card.dart';
import 'widgets/month_switcher.dart';
/// 首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final anchor = ref.watch(monthAnchorProvider);
final billsAsync = ref.watch(billsProvider(anchor));
final statsAsync = ref.watch(monthlyStatisticsProvider(anchor));
final budgetAsync = ref.watch(budgetProvider);
final isCurrentMonth = isSameMonth(anchor, DateTime.now());
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: RefreshIndicator(
color: const Color(0xFFC8956E),
onRefresh: () => _onRefresh(ref),
child: ListView(
// 内容不足一屏时也要能下拉刷新
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
children: [
_header(anchor),
const SizedBox(height: 14),
_summaryCard(anchor, statsAsync),
const SizedBox(height: 12),
// 后端预算接口只提供当月数据,历史月份不展示,避免串数据
if (isCurrentMonth)
BudgetCard(budget: budgetAsync)
else
_budgetUnavailableTip(),
const SizedBox(height: 16),
..._billSection(context, ref, billsAsync),
const SizedBox(height: 90), // 底部导航避让
],
),
),
),
),
);
}
// ---- 顶部标题 + 月份切换 ----
Widget _header(DateTime anchor) {
return Row(
children: [
const Text(
'我的账单',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const Spacer(),
const MonthSwitcher(),
],
);
}
// ---- 本月汇总 ----
Widget _summaryCard(DateTime anchor, AsyncValue<StatisticsResponse> statsAsync) {
final stats = statsAsync.value;
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
_summaryItem('${anchor.month}月收入', stats?.totalIncome, const Color(0xFF19BE6B)),
_vDivider(),
_summaryItem('${anchor.month}月支出', stats?.totalExpense, const Color(0xFFFA3534)),
_vDivider(),
_summaryItem('结余', stats?.balance, const Color(0xFF5D4037)),
],
),
);
}
Widget _summaryItem(String label, double? amount, Color color) {
return Expanded(
child: Column(
children: [
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
const SizedBox(height: 6),
Text(
// 统计未加载完时显示占位,避免整块转圈导致头部抖动
amount == null ? '--' : '¥${formatAmount(amount)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
);
}
Widget _vDivider() => Container(width: 1, height: 28, color: const Color(0xFFF0E6D8));
Widget _budgetUnavailableTip() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎯 ', style: TextStyle(fontSize: 14)),
Text(
'仅可查看本月预算',
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
),
],
),
);
}
// ---- 账单列表 ----
List<Widget> _billSection(
BuildContext context,
WidgetRef ref,
AsyncValue<List<Bill>> billsAsync,
) {
return [
billsAsync.when(
loading: () => const LoadingView(),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(billsProvider),
),
data: (bills) {
if (bills.isEmpty) {
return const EmptyView(
title: '本月暂无账单记录',
tip: '点击底部「记账」添加,或用 OCR 识别小票',
);
}
return Column(
children: groupBillsByDate(bills)
.map((group) => BillGroupCard(
group: group,
onTapBill: (bill) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => BillEditPage(billId: bill.id)),
),
))
.toList(),
);
},
),
];
}
// ---- 下拉刷新 ----
Future<void> _onRefresh(WidgetRef ref) async {
final anchor = ref.read(monthAnchorProvider);
invalidateBillDataFromWidget(ref);
// 等三个请求结束再收起下拉动画;异常吞掉,由各自的 AsyncValue 渲染错误态
await Future.wait<void>([
_silent(ref.read(billsProvider(anchor).future)),
_silent(ref.read(monthlyStatisticsProvider(anchor).future)),
_silent(ref.read(budgetProvider.future)),
]);
}
Future<void> _silent(Future<Object?> future) =>
future.then<void>((_) {}, onError: (_, _) {});
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../../models/bill.dart';
import '../../../utils/amount_util.dart';
import '../../../utils/bill_grouping.dart';
import '../../../utils/date_util.dart';
/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目
class BillGroupCard extends StatelessWidget {
const BillGroupCard({super.key, required this.group, required this.onTapBill});
final BillDayGroup group;
final ValueChanged<Bill> onTapBill;
static const _expenseColor = Color(0xFFFA3534);
static const _incomeColor = Color(0xFF19BE6B);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
child: Row(
children: [
Text(
formatGroupDate(group.date),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const Spacer(),
// 原版把当日收入与支出直接相加(无意义),这里分开展示
if (group.expenseTotal > 0)
Text(
'支 ¥${formatAmount(group.expenseTotal)}',
style: const TextStyle(fontSize: 12, color: _expenseColor),
),
if (group.expenseTotal > 0 && group.incomeTotal > 0)
const SizedBox(width: 10),
if (group.incomeTotal > 0)
Text(
'收 ¥${formatAmount(group.incomeTotal)}',
style: const TextStyle(fontSize: 12, color: _incomeColor),
),
],
),
),
const Divider(height: 1, color: Color(0xFFF5EEE4)),
for (var i = 0; i < group.bills.length; i++) ...[
_BillTile(bill: group.bills[i], onTap: () => onTapBill(group.bills[i])),
if (i != group.bills.length - 1)
const Divider(height: 1, indent: 56, color: Color(0xFFF5EEE4)),
],
],
),
);
}
}
class _BillTile extends StatelessWidget {
const _BillTile({required this.bill, required this.onTap});
final Bill bill;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// 金额正负与颜色按 type 判断(后端 amount 恒为正数,不能用金额正负判色)
final color = bill.isExpense ? BillGroupCard._expenseColor : BillGroupCard._incomeColor;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFBEEDD),
),
alignment: Alignment.center,
child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bill.categoryName,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
if (bill.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
bill.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
],
),
),
const SizedBox(width: 8),
Text(
formatSignedAmount(bill.absAmount, isExpense: bill.isExpense),
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
),
);
}
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/budget.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/donut_chart.dart';
import '../../../widgets/state_views.dart';
/// 本月预算卡:环形图 + 预算 / 剩余 / 日均可用
class BudgetCard extends StatelessWidget {
const BudgetCard({super.key, required this.budget});
final AsyncValue<BudgetInfo> budget;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: budget.when(
loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(message: '预算加载失败'),
data: (info) => info.hasBudget ? _content(info) : _noBudget(),
),
);
}
Widget _noBudget() {
return const EmptyView(
icon: '🎯',
title: '本月未设置预算',
tip: '可在「我的 → 预算设置」中设置',
padding: EdgeInsets.symmetric(vertical: 20),
);
}
Widget _content(BudgetInfo info) {
final over = info.isOverBudget;
final ringColor = over ? const Color(0xFFE53935) : const Color(0xFFC8956E);
final usedRatio = info.usedRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 12),
child: Text(
'本月预算',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
),
Row(
children: [
DonutChart(
size: 116,
centerSpaceRadius: 38,
slices: [
DonutSlice(value: usedRatio, color: ringColor),
DonutSlice(value: 1 - usedRatio, color: const Color(0xFFF0E6D8)),
],
center: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
over ? '超支' : '${(usedRatio * 100).round()}%',
style: TextStyle(
fontSize: over ? 14 : 17,
fontWeight: FontWeight.bold,
color: over ? const Color(0xFFE53935) : const Color(0xFF5D4037),
),
),
if (!over)
const Text('已用', style: TextStyle(fontSize: 10, color: Color(0xFF8D6E63))),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_amountRow('预算总额', '¥${formatAmount(info.amount)}', const Color(0xFF5D4037)),
const SizedBox(height: 8),
_amountRow('已用', '¥${formatAmount(info.usedAmount)}', ringColor),
const SizedBox(height: 8),
_amountRow(
'剩余',
'¥${formatAmount(info.remainingAmount)}',
info.remainingAmount < 0 ? const Color(0xFFE53935) : const Color(0xFF19BE6B),
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0x33C8956E), Color(0x33FFD700)],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(info.remainingDaily)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
],
);
}
Widget _amountRow(String label, String value, Color valueColor) {
return Row(
children: [
Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))),
const Spacer(),
Text(
value,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: valueColor),
),
],
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/bill_provider.dart';
import '../../../utils/date_util.dart';
/// 月份切换条:‹ 2026年9月 ›
class MonthSwitcher extends ConsumerWidget {
const MonthSwitcher({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final anchor = ref.watch(monthAnchorProvider);
final notifier = ref.read(monthAnchorProvider.notifier);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_iconButton(
icon: Icons.chevron_left,
tooltip: '上一月',
onTap: notifier.prev,
),
GestureDetector(
onTap: () => _pickMonth(context, ref),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatMonthTitle(anchor),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 18, color: Color(0xFF8D6E63)),
],
),
),
),
_iconButton(
icon: Icons.chevron_right,
tooltip: '下一月',
// 不允许超过当前月
onTap: notifier.canGoNext ? notifier.next : null,
),
],
),
);
}
Widget _iconButton({
required IconData icon,
required String tooltip,
VoidCallback? onTap,
}) {
final enabled = onTap != null;
return IconButton(
icon: Icon(icon),
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
color: enabled ? const Color(0xFF5D4037) : const Color(0xFFBCAAA4),
tooltip: tooltip,
onPressed: onTap,
);
}
Future<void> _pickMonth(BuildContext context, WidgetRef ref) async {
final anchor = ref.read(monthAnchorProvider);
final picked = await showDatePicker(
context: context,
initialDate: anchor,
initialDatePickerMode: DatePickerMode.year,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
ref.read(monthAnchorProvider.notifier).pick(picked);
}
}
}
+527
View File
@@ -0,0 +1,527 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../api/auth_api.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/local_storage.dart';
import '../../providers/auth_provider.dart';
import '../../utils/crypto.dart';
import '../../utils/toast.dart';
import '../shell/main_shell.dart';
/// 登录/注册页(复刻 uniApp pages/login/login.vue)
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage>
with SingleTickerProviderStateMixin {
bool _isRegister = false;
bool _showPassword = false;
bool _loading = false;
bool _agreeProtocol = false;
bool _rememberMe = false;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _nicknameController = TextEditingController();
final _formKey = GlobalKey<FormState>();
late final AnimationController _floatController = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat(reverse: true);
@override
void initState() {
super.initState();
final saved = LocalStorage.instance.getRememberedAccount();
if (saved != null) {
_usernameController.text = saved.username;
_passwordController.text = decryptPassword(saved.password);
_rememberMe = true;
}
}
@override
void dispose() {
_floatController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_nicknameController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_loading) return;
final username = _usernameController.text.trim();
final password = _passwordController.text;
if (username.isEmpty || password.isEmpty) {
showToast(context, '请输入用户名和密码', error: true);
return;
}
if (_isRegister) {
if (password.length < 6) {
showToast(context, '密码至少6位', error: true);
return;
}
if (!_agreeProtocol) {
showToast(context, '请先阅读并同意用户协议');
return;
}
}
setState(() => _loading = true);
try {
final api = ref.read(authApiProvider);
final response = _isRegister
? await api.register(
username,
password,
_nicknameController.text.trim().isEmpty
? username
: _nicknameController.text.trim(),
)
: await api.login(username, password);
// 记住密码
if (!_isRegister && _rememberMe) {
await LocalStorage.instance.saveRememberedPassword(
username,
encryptPassword(password),
);
} else if (!_isRegister && !_rememberMe) {
await LocalStorage.instance.clearRememberedPassword();
}
await ref.read(authProvider.notifier).loginSuccess(response);
if (!mounted) return;
showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功');
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const MainShell()),
(route) => false,
);
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '操作失败,请重试', error: true);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: Stack(
children: [
..._buildBackgroundBalls(),
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
const SizedBox(height: 32),
_buildFormCard(),
],
),
),
),
),
],
),
),
);
}
// ---- 背景装饰圆球 ----
List<Widget> _buildBackgroundBalls() {
final t = _floatController.value;
Offset offset(double phase) =>
Offset(20 * _wave(t + phase), 30 * _wave(t + phase + 0.25));
Widget ball({
required double size,
required List<Color> colors,
required Offset pos,
required double phase,
}) {
return Positioned(
left: pos.dx,
top: pos.dy,
child: Opacity(
opacity: 0.3,
child: AnimatedBuilder(
animation: _floatController,
builder: (context, _) => Transform.translate(
offset: offset(phase),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: colors),
),
),
),
),
),
);
}
return [
ball(
size: 280,
colors: const [Color(0xFFC8956E), Color(0xFFFFB6C1)],
pos: const Offset(-70, -70),
phase: 0,
),
ball(
size: 200,
colors: const [Color(0xFFF5D59E), Color(0xFFFFD700)],
pos: const Offset(-40, 180),
phase: 0.25,
),
ball(
size: 240,
colors: const [Color(0xFFFFB6C1), Color(0xFFC8956E)],
pos: const Offset(40, -180),
phase: 0.5,
),
ball(
size: 170,
colors: const [Color(0xFFFFD700), Color(0xFFF5D59E)],
pos: const Offset(240, 420),
phase: 0.75,
),
];
}
double _wave(double x) {
final v = (x % 1) * 2;
return v <= 1 ? v : 2 - v;
}
// ---- Logo + 标题 ----
Widget _buildHeader() {
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.all(10),
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_decoLine(),
const SizedBox(width: 10),
const Text(
'强宝爱记账',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
letterSpacing: 2,
shadows: [
Shadow(
color: Color(0x66FFD700),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
),
const SizedBox(width: 10),
_decoLine(),
],
),
const SizedBox(height: 6),
const Text(
'智能记账,轻松管理',
style: TextStyle(fontSize: 14, color: Color(0xFF8D6E63)),
),
],
);
}
Widget _decoLine() => Container(
width: 40,
height: 4,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFFFD700)],
),
),
);
// ---- 表单卡片(毛玻璃) ----
Widget _buildFormCard() {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: _glassFilter,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFC8956E), width: 1.5),
),
child: Form(
key: _formKey,
child: Column(
children: [
_buildSegmentTabs(),
const SizedBox(height: 20),
_inputField(
icon: '👤',
controller: _usernameController,
hint: '请输入用户名',
maxLength: 20,
),
const SizedBox(height: 14),
_inputField(
icon: '🔒',
controller: _passwordController,
hint: '请输入密码',
maxLength: 20,
obscure: !_showPassword,
suffix: IconButton(
icon: Text(
_showPassword ? '👁️' : '👁️‍🗨️',
style: const TextStyle(fontSize: 16),
),
onPressed: () =>
setState(() => _showPassword = !_showPassword),
),
),
if (_isRegister) ...[
const SizedBox(height: 14),
_inputField(
icon: '✨',
controller: _nicknameController,
hint: '请输入昵称(可选)',
maxLength: 20,
),
],
if (!_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _rememberMe,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _rememberMe = v ?? false),
),
),
const SizedBox(width: 6),
const Text(
'记住密码',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
],
),
],
if (_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _agreeProtocol,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _agreeProtocol = v ?? false),
),
),
const Expanded(
child: Text(
'我已阅读并同意用户协议',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(
_isRegister ? '注 册' : '登 录',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: 4,
),
),
),
),
],
),
),
),
),
);
}
static final ImageFilter _glassFilter = ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
);
// ---- 登录/注册切换 ----
Widget _buildSegmentTabs() {
return Container(
decoration: BoxDecoration(
color: const Color(0x33C8956E),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
_segment(
'登录',
!_isRegister,
() => setState(() => _isRegister = false),
),
_segment('注册', _isRegister, () => setState(() => _isRegister = true)),
],
),
);
}
Widget _segment(String text, bool active, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: active ? const Color(0xFFC8956E) : Colors.transparent,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: active ? Colors.white : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
Widget _inputField({
required String icon,
required TextEditingController controller,
required String hint,
int maxLength = 50,
bool obscure = false,
Widget? suffix,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE0C9A6)),
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
obscureText: obscure,
maxLength: maxLength,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
color: Color(0xFFBCAAA4),
fontSize: 14,
),
border: InputBorder.none,
counterText: '',
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
?suffix,
],
),
);
}
}
+524
View File
@@ -0,0 +1,524 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../core/network/auth_redirect.dart';
import '../../models/account.dart';
import '../../models/budget.dart';
import '../../providers/account_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/budget_provider.dart';
import '../../utils/amount_input_formatter.dart';
import '../../utils/amount_util.dart';
import '../../utils/toast.dart';
import '../../widgets/state_views.dart';
/// 「我的」页面
///
/// 账户余额可手动修改;总收入 / 总支出由账单汇总得出,只读;
/// 本月预算可设置与随时修改(按月生效)。
class MinePage extends ConsumerWidget {
const MinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authProvider).user;
final accountAsync = ref.watch(accountProvider);
final budgetAsync = ref.watch(budgetProvider);
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: RefreshIndicator(
color: const Color(0xFFC8956E),
onRefresh: () => _onRefresh(ref),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_userCard(user?.nickname ?? '用户', user?.username ?? '-'),
const SizedBox(height: 14),
_accountCard(context, ref, accountAsync),
const SizedBox(height: 14),
_budgetCard(context, ref, budgetAsync),
const SizedBox(height: 24),
_logoutButton(context),
const SizedBox(height: 90),
],
),
),
),
),
);
}
Future<void> _onRefresh(WidgetRef ref) async {
ref.invalidate(accountProvider);
ref.invalidate(budgetProvider);
await Future.wait<void>([
_silent(ref.read(accountProvider.future)),
_silent(ref.read(budgetProvider.future)),
]);
}
Future<void> _silent(Future<Object?> future) =>
future.then<void>((_) {}, onError: (_, _) {});
// ---------------- 用户信息 ----------------
Widget _userCard(String nickname, String username) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)],
),
border: Border.all(color: const Color(0xFF5D4037), width: 1.5),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.2),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
alignment: Alignment.center,
child: const Text('👤', style: TextStyle(fontSize: 30)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname.isEmpty ? '用户' : nickname,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
Text(
'账号:$username',
style: const TextStyle(fontSize: 13, color: Color(0xFF6D4C41)),
),
],
),
),
],
),
);
}
// ---------------- 账户卡 ----------------
Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue<AccountInfo> accountAsync) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: accountAsync.when(
loading: () => const LoadingView(tip: '账户加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(accountProvider),
),
data: (account) => _accountContent(context, ref, account),
),
);
}
Widget _accountContent(BuildContext context, WidgetRef ref, AccountInfo account) {
final negative = account.balance < 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'账户余额',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const Spacer(),
_editButton(
label: '修改',
onPressed: () => _editBalance(context, ref, account),
),
],
),
const SizedBox(height: 6),
Text(
'¥${formatAmount(account.balance)}',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: negative ? const Color(0xFFFA3534) : const Color(0xFF5D4037),
),
),
const SizedBox(height: 16),
const Divider(height: 1, color: Color(0xFFF5EEE4)),
const SizedBox(height: 14),
Row(
children: [
_readOnlyStat('总收入', account.totalIncome, const Color(0xFF19BE6B)),
Container(width: 1, height: 26, color: const Color(0xFFF0E6D8)),
_readOnlyStat('总支出', account.totalExpense, const Color(0xFFFA3534)),
],
),
],
);
}
/// 只读统计项:来自账单汇总,不可修改(用锁图标提示)
Widget _readOnlyStat(String label, double amount, Color color) {
return Expanded(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))),
const SizedBox(width: 4),
const Icon(Icons.lock_outline, size: 11, color: Color(0xFFBCAAA4)),
],
),
const SizedBox(height: 6),
Text(
'¥${formatAmount(amount)}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color),
),
],
),
);
}
// ---------------- 预算卡 ----------------
Widget _budgetCard(BuildContext context, WidgetRef ref, AsyncValue<BudgetInfo> budgetAsync) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: budgetAsync.when(
loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(budgetProvider),
),
data: (budget) => _budgetContent(context, ref, budget),
),
);
}
Widget _budgetContent(BuildContext context, WidgetRef ref, BudgetInfo budget) {
final hasBudget = budget.hasBudget;
final over = budget.isOverBudget;
final ratio = budget.usedRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'本月预算',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const SizedBox(width: 6),
if (!hasBudget)
const Text(
'(未设置)',
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
const Spacer(),
_editButton(
label: hasBudget ? '修改' : '设置',
onPressed: () => _editBudget(context, ref, budget),
),
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'¥${formatAmount(budget.amount)}',
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 8),
if (hasBudget)
Text(
over ? '已超支' : '每月生效,可随时修改',
style: TextStyle(
fontSize: 12,
color: over ? const Color(0xFFE53935) : const Color(0xFFBCAAA4),
),
),
],
),
if (hasBudget) ...[
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Stack(
children: [
Container(height: 8, color: const Color(0xFFF5EEE4)),
FractionallySizedBox(
widthFactor: ratio,
child: Container(
height: 8,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: over
? const [Color(0xFFE53935), Color(0xFFFF8A80)]
: const [Color(0xFFC8956E), Color(0xFFFFD700)],
),
),
),
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
'已用 ${(ratio * 100).round()}%',
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
),
const Spacer(),
Text(
'剩余 ¥${formatAmount(budget.remainingAmount)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: budget.remainingAmount < 0
? const Color(0xFFE53935)
: const Color(0xFF19BE6B),
),
),
],
),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0x33C8956E), Color(0x33FFD700)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(budget.remainingDaily)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
],
],
);
}
Widget _editButton({required String label, required VoidCallback onPressed}) {
return TextButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(label, style: const TextStyle(fontSize: 13)),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
);
}
// ---------------- 修改余额 ----------------
Future<void> _editBalance(BuildContext context, WidgetRef ref, AccountInfo account) async {
final input = await _showAmountDialog(
context: context,
title: '修改账户余额',
initialValue: account.balance,
hint: '请输入当前账户余额',
note: '修改后的余额将作为新的基准,之后记账会在其基础上增减。',
allowNegative: true, // 余额可能为负
);
if (input == null) return;
try {
await ref.read(accountProvider.notifier).setBalance(input);
if (context.mounted) showToast(context, '✅ 余额已更新');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '余额更新失败,请重试', error: true);
}
}
// ---------------- 修改预算 ----------------
Future<void> _editBudget(BuildContext context, WidgetRef ref, BudgetInfo budget) async {
final input = await _showAmountDialog(
context: context,
title: budget.hasBudget ? '修改本月预算' : '设置本月预算',
initialValue: budget.amount,
hint: '请输入预算金额',
note: '预算按月生效,可随时修改;本月账单支出会累计到「已用」。',
);
if (input == null) return;
try {
await ref.read(budgetProvider.notifier).setMonthBudget(input);
if (context.mounted) showToast(context, '✅ 预算已保存');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '预算保存失败,请重试', error: true);
}
}
/// 金额输入弹窗(修改余额 / 修改预算 共用)
///
/// [allowNegative] 仅余额编辑开启——余额可能为负,预算与记账金额不会。
Future<double?> _showAmountDialog({
required BuildContext context,
required String title,
required double initialValue,
required String hint,
required String note,
bool allowNegative = false,
}) async {
final controller = TextEditingController(text: initialValue.toStringAsFixed(2));
// 预填内容默认全选,输入即替换,省去手动清空
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
try {
return await showDialog<double>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
inputFormatters: [AmountInputFormatter(allowNegative: allowNegative)],
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
decoration: InputDecoration(
hintText: hint,
prefixText: '¥ ',
prefixStyle: const TextStyle(
fontSize: 18,
color: Color(0xFFC8956E),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(note, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, double.tryParse(controller.text)),
child: const Text('保存', style: TextStyle(color: Color(0xFFC8956E))),
),
],
),
);
} finally {
controller.dispose();
}
}
// ---------------- 退出登录 ----------------
Widget _logoutButton(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFFD32F2F),
side: const BorderSide(color: Color(0xFFE0C9A6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: () => _confirmLogout(context),
child: const Text('退出登录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
);
}
Future<void> _confirmLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('提示'),
content: const Text('确定要退出登录吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('确定退出', style: TextStyle(color: Color(0xFFD32F2F))),
),
],
),
);
if (confirmed != true) return;
await logoutAndGoLogin();
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../add/add_page.dart';
import '../home/home_page.dart';
import '../login/login_page.dart';
import '../mine/mine_page.dart';
import '../statistics/statistics_page.dart';
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@override
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell> {
int _index = 0; // 默认选中「首页」
@override
void initState() {
super.initState();
// 兜底:未登录直接回登录页
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!ref.read(authProvider).isLoggedIn) {
_goLogin();
}
});
}
void _goLogin() {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
@override
Widget build(BuildContext context) {
// IndexedStack 保证四个页面常驻:写操作后首页/统计的数据失效能立即重取,
// 用户切回时看到的就是最新数据。
const pages = [
HomePage(),
AddPage(),
StatisticsPage(),
MinePage(),
];
return Scaffold(
body: IndexedStack(index: _index, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
backgroundColor: Colors.white,
indicatorColor: const Color(0x33C8956E),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: '首页'),
NavigationDestination(icon: Icon(Icons.add_circle_outline), selectedIcon: Icon(Icons.add_circle), label: '记账'),
NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: '统计'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'),
],
),
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../models/statistics_response.dart';
import '../../providers/statistics_provider.dart';
import '../../utils/amount_util.dart';
import '../../widgets/state_views.dart';
import 'widgets/category_pie_card.dart';
import 'widgets/category_rank_list.dart';
/// 统计页:按日/周/月切换 + 汇总 + 分类占比饼图 + 分类明细
class StatisticsPage extends ConsumerWidget {
const StatisticsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tab = ref.watch(statisticsTabProvider);
final statsAsync = ref.watch(statisticsProvider(tab));
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
children: [
const Text(
'统计分析',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 14),
_tabBar(tab, ref),
const SizedBox(height: 14),
_content(ref, statsAsync),
const SizedBox(height: 90), // 底部导航避让
],
),
),
),
);
}
// ---- 时间粒度切换 ----
Widget _tabBar(StatisticsTab current, WidgetRef ref) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
children: [
_tabItem('按日', StatisticsTab.daily, current, ref),
_tabItem('按周', StatisticsTab.weekly, current, ref),
_tabItem('按月', StatisticsTab.monthly, current, ref),
],
),
);
}
Widget _tabItem(String text, StatisticsTab tab, StatisticsTab current, WidgetRef ref) {
final active = current == tab;
return Expanded(
child: GestureDetector(
onTap: () => ref.read(statisticsTabProvider.notifier).set(tab),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: active
? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)])
: null,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
// ---- 内容区 ----
Widget _content(WidgetRef ref, AsyncValue<StatisticsResponse> statsAsync) {
return statsAsync.when(
loading: () => const LoadingView(),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(statisticsProvider),
),
data: (stats) => Column(
children: [
_summaryCard(stats),
const SizedBox(height: 12),
if (stats.categoryStatistics.isEmpty)
const EmptyView(title: '暂无统计数据', tip: '所选时间范围内还没有账单')
else ...[
CategoryPieCard(stats: stats),
const SizedBox(height: 12),
CategoryRankList(stats: stats),
],
],
),
);
}
// ---- 汇总卡 ----
Widget _summaryCard(StatisticsResponse stats) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.18),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
_summaryItem('总收入', stats.totalIncome, const Color(0xFF1B5E20)),
_vDivider(),
_summaryItem('总支出', stats.totalExpense, const Color(0xFFB71C1C)),
_vDivider(),
_summaryItem('余额', stats.balance, const Color(0xFF3E2723)),
],
),
);
}
Widget _summaryItem(String label, double amount, Color color) {
return Expanded(
child: Column(
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: Color(0xFF4E342E)),
),
const SizedBox(height: 6),
Text(
'¥${formatAmount(amount)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
);
}
Widget _vDivider() =>
Container(width: 1, height: 26, color: const Color(0xFF5D4037).withValues(alpha: 0.25));
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../models/statistics_response.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/donut_chart.dart';
/// 支出分类占比环形图卡
class CategoryPieCard extends StatelessWidget {
const CategoryPieCard({super.key, required this.stats});
final StatisticsResponse stats;
@override
Widget build(BuildContext context) {
// 只画支出分类(收入分类在下方排行列表里看)
final items = stats.categoryStatistics
.where((e) => e.isExpense && e.amount > 0)
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
if (items.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'支出分类占比',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
DonutChart(
size: 150,
centerSpaceRadius: 48,
slices: items
.asMap()
.entries
.map((e) => DonutSlice(
value: e.value.amount,
color: chartColorAt(e.key),
))
.toList(),
center: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('支出', style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
Text(
'¥${formatAmount(stats.totalExpense)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
const SizedBox(width: 16),
// 图例最多显示前 6 项,避免色块与名称对不上号
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items
.take(6)
.toList()
.asMap()
.entries
.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: chartColorAt(e.key),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
e.value.categoryName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8D6E63),
),
),
),
Text(
'${percentOf(e.value.amount, stats.totalExpense).toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
))
.toList(),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../../../models/statistics_response.dart';
import '../../../utils/amount_util.dart';
/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比
class CategoryRankList extends StatelessWidget {
const CategoryRankList({super.key, required this.stats});
final StatisticsResponse stats;
@override
Widget build(BuildContext context) {
final items = [...stats.categoryStatistics]
..sort((a, b) => b.amount.compareTo(a.amount));
if (items.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'分类明细',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const SizedBox(height: 12),
...items.map(_row),
],
),
);
}
Widget _row(CategoryStat item) {
// 分母按行内类型取:支出项用总支出、收入项用总收入
// (uniApp 原版一律用总支出做分母,导致收入项占比显示成百分比 > 100%)
final total = item.isExpense ? stats.totalExpense : stats.totalIncome;
final pct = percentOf(item.amount, total);
final barColor = item.isExpense ? const Color(0xFFC8956E) : const Color(0xFF19BE6B);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.categoryIcon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Text(
item.categoryName,
style: const TextStyle(fontSize: 13.5, color: Color(0xFF5D4037)),
),
),
Text(
'¥${formatAmount(item.amount)}',
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 8),
SizedBox(
width: 46,
child: Text(
'${pct.toStringAsFixed(1)}%',
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 6, color: const Color(0xFFF5EEE4)),
FractionallySizedBox(
widthFactor: (pct / 100).clamp(0.0, 1.0),
child: Container(
height: 6,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [barColor, barColor.withValues(alpha: 0.55)],
),
),
),
),
],
),
),
],
),
);
}
}