first commit
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: (_, _) {});
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: '我的'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user