first commit

This commit is contained in:
2026-09-21 17:37:53 +08:00
commit 67d4dd5b7c
141 changed files with 8558 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/account_api.dart';
import '../core/network/api_exception.dart';
import '../models/account.dart';
/// 账户信息(余额 / 总收入 / 总支出)
class AccountNotifier extends AsyncNotifier<AccountInfo> {
@override
Future<AccountInfo> build() => ref.watch(accountApiProvider).getAccount();
/// 把账户余额改成 [targetBalance]。
///
/// 后端只能写期初余额,所以这里先反算:
/// `initialBalance = targetBalance - totalIncome + totalExpense`
Future<void> setBalance(double targetBalance) async {
final current = state.value;
if (current == null) throw ApiException(-1, '账户信息尚未加载完成');
final initial = initialBalanceFor(
targetBalance: targetBalance,
totalIncome: current.totalIncome,
totalExpense: current.totalExpense,
);
final updated = await ref.read(accountApiProvider).updateBalance(initial);
state = AsyncData(updated);
}
}
final accountProvider =
AsyncNotifierProvider<AccountNotifier, AccountInfo>(AccountNotifier.new);
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/bill_api.dart';
import '../core/network/api_exception.dart';
import '../models/bill_request.dart';
import '../models/ocr_result.dart';
import '../utils/date_util.dart';
import 'bill_refresh.dart';
/// 手动记账表单状态
class AddBillFormState {
final int type; // 1=支出 2=收入
final int? categoryId;
final double? amount;
final String billDate; // yyyy-MM-dd
final String description;
final bool submitting;
final int revision; // 每次 fillFromOcr 外部填充时 +1,供 UI 同步非受控输入框
const AddBillFormState({
this.type = 1,
this.categoryId,
this.amount,
required this.billDate,
this.description = '',
this.submitting = false,
this.revision = 0,
});
AddBillFormState copyWith({
int? type,
int? categoryId,
Object? amount = _sentinel,
String? billDate,
String? description,
bool? submitting,
int? revision,
}) {
return AddBillFormState(
type: type ?? this.type,
categoryId: categoryId ?? this.categoryId,
amount: amount == _sentinel ? this.amount : amount as double?,
billDate: billDate ?? this.billDate,
description: description ?? this.description,
submitting: submitting ?? this.submitting,
revision: revision ?? this.revision,
);
}
static const _sentinel = Object();
}
class AddBillFormNotifier extends Notifier<AddBillFormState> {
@override
AddBillFormState build() => AddBillFormState(billDate: formatDate(DateTime.now()));
void setType(int type) {
if (type == state.type) return;
// 类型切换后分类需重选(对齐 uniApp watch 行为)
state = state.copyWith(type: type, categoryId: null);
}
void setCategory(int id) => state = state.copyWith(categoryId: id);
void setAmount(double? value) => state = state.copyWith(amount: value);
void setDate(String date) => state = state.copyWith(billDate: date);
void setDescription(String desc) => state = state.copyWith(description: desc);
/// 保存账单,成功返回 true
Future<bool> submit() async {
final s = state;
if (s.categoryId == null) throw ApiException(-1, '请选择分类');
if (s.amount == null || s.amount! <= 0) throw ApiException(-1, '请输入有效金额');
state = state.copyWith(submitting: true);
try {
await ref.read(billApiProvider).createBill(BillRequest(
categoryId: s.categoryId!,
amount: s.amount!,
description: s.description,
billDate: s.billDate,
type: s.type,
));
// 让首页列表与统计页重新取数
invalidateBillData(ref);
_resetAfterSubmit();
return true;
} finally {
state = state.copyWith(submitting: false);
}
}
void _resetAfterSubmit() {
state = AddBillFormState(
type: state.type,
billDate: formatDate(DateTime.now()),
);
}
/// OCR「选择此条」:按金额正负推断类型并填充表单
void fillFromOcr(OcrResult ocr) {
state = AddBillFormState(
type: ocr.isExpense ? 1 : 2,
amount: ocr.absAmount,
description: ocr.merchant ?? '',
billDate: parseBillDate(ocr.date),
revision: state.revision + 1,
);
}
}
final addBillFormProvider =
NotifierProvider<AddBillFormNotifier, AddBillFormState>(AddBillFormNotifier.new);
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/storage/local_storage.dart';
import '../models/auth_response.dart';
import '../utils/jwt_util.dart';
class AuthState {
final String? token;
final UserInfo? user;
const AuthState({this.token, this.user});
bool get isLoggedIn => token != null && token!.isNotEmpty;
const AuthState.initial() : this._();
const AuthState._({this.token, this.user});
}
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() => const AuthState.initial();
/// App 启动时从本地存储恢复登录态。
/// 若 token 已在本地可判定过期,直接清理,避免带着废 token 进入主界面。
Future<void> load() async {
final token = LocalStorage.instance.getToken();
if (token == null || token.isEmpty) return;
if (isJwtExpired(token)) {
await LocalStorage.instance.clearAuth();
return;
}
final userInfo = LocalStorage.instance.getUserInfo();
state = AuthState(token: token, user: userInfo == null ? null : UserInfo.fromJson(userInfo));
}
/// 登录/注册成功后调用:持久化并更新状态
Future<void> loginSuccess(AuthResponse response) async {
final user = UserInfo(
username: response.username ?? '',
nickname: response.nickname ?? response.username ?? '',
);
await LocalStorage.instance.setAuth(token: response.token, userInfo: user.toJson());
state = AuthState(token: response.token, user: user);
}
Future<void> logout() async {
await LocalStorage.instance.clearAuth();
state = const AuthState.initial();
}
}
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/bill_api.dart';
import '../core/network/api_exception.dart';
import '../models/bill_request.dart';
import '../utils/date_util.dart';
import 'bill_refresh.dart';
/// 账单编辑页的表单状态
class BillEditState {
final bool loading; // 详情加载中
final String? loadError; // 详情加载失败提示
final int type; // 1=支出 2=收入
final int? categoryId;
final double? amount;
final String billDate; // yyyy-MM-dd
final String description;
final bool submitting;
final int revision; // 服务端数据回填时 +1,供页面同步非受控输入框
const BillEditState({
this.loading = false,
this.loadError,
this.type = 1,
this.categoryId,
this.amount,
required this.billDate,
this.description = '',
this.submitting = false,
this.revision = 0,
});
BillEditState copyWith({
bool? loading,
Object? loadError = _sentinel,
int? type,
int? categoryId,
Object? amount = _sentinel,
String? billDate,
String? description,
bool? submitting,
int? revision,
}) {
return BillEditState(
loading: loading ?? this.loading,
loadError: loadError == _sentinel ? this.loadError : loadError as String?,
type: type ?? this.type,
categoryId: categoryId ?? this.categoryId,
amount: amount == _sentinel ? this.amount : amount as double?,
billDate: billDate ?? this.billDate,
description: description ?? this.description,
submitting: submitting ?? this.submitting,
revision: revision ?? this.revision,
);
}
static const _sentinel = Object();
}
class BillEditNotifier extends Notifier<BillEditState> {
BillEditNotifier(this.billId);
final int billId;
@override
BillEditState build() {
_load();
return BillEditState(billDate: formatDate(DateTime.now()), loading: true);
}
Future<void> _load() async {
try {
final bill = await ref.read(billApiProvider).getBill(billId);
if (!ref.mounted) return;
state = BillEditState(
type: bill.type,
categoryId: bill.categoryId,
amount: bill.absAmount,
billDate: bill.billDate,
description: bill.description,
revision: state.revision + 1,
);
} on ApiException catch (e) {
if (!ref.mounted) return;
state = state.copyWith(loading: false, loadError: e.message);
} catch (_) {
if (!ref.mounted) return;
state = state.copyWith(loading: false, loadError: '账单加载失败');
}
}
/// 重新加载(错误重试用)
void reload() {
state = state.copyWith(loading: true, loadError: null);
_load();
}
void setType(int type) {
if (type == state.type) return;
// 切换收支后分类需重选(与记账页行为一致)
state = state.copyWith(type: type, categoryId: null);
}
void setCategory(int id) => state = state.copyWith(categoryId: id);
void setAmount(double? value) => state = state.copyWith(amount: value);
void setDate(String date) => state = state.copyWith(billDate: date);
void setDescription(String desc) => state = state.copyWith(description: desc);
/// 保存:必须回传全部字段,否则后端会把 description 覆盖成 null
Future<void> save() async {
final s = state;
if (s.categoryId == null) throw ApiException(-1, '请选择分类');
if (s.amount == null || s.amount! <= 0) throw ApiException(-1, '请输入有效金额');
state = state.copyWith(submitting: true);
try {
await ref.read(billApiProvider).updateBill(
billId,
BillRequest(
categoryId: s.categoryId!,
amount: s.amount!,
description: s.description,
billDate: s.billDate,
type: s.type,
),
);
if (!ref.mounted) return;
invalidateBillData(ref);
} finally {
if (ref.mounted) state = state.copyWith(submitting: false);
}
}
/// 删除该账单
Future<void> remove() async {
state = state.copyWith(submitting: true);
try {
await ref.read(billApiProvider).deleteBill(billId);
if (!ref.mounted) return;
invalidateBillData(ref);
} finally {
if (ref.mounted) state = state.copyWith(submitting: false);
}
}
}
final billEditProvider = NotifierProvider.autoDispose
.family<BillEditNotifier, BillEditState, int>(BillEditNotifier.new);
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/bill_api.dart';
import '../api/statistics_api.dart';
import '../models/bill.dart';
import '../models/statistics_response.dart';
import '../utils/date_util.dart';
/// 首页选中的月份锚点(该月 1 日 00:00)。
/// 切换月份只改这一个状态,依赖它的列表与汇总会自动换 key 重新取数。
class MonthAnchorNotifier extends Notifier<DateTime> {
@override
DateTime build() => monthStart(DateTime.now());
bool get canGoNext => state.isBefore(monthStart(DateTime.now()));
void prev() => state = shiftMonth(state, -1);
/// 下一月,不允许超过当前月
void next() {
final target = shiftMonth(state, 1);
if (target.isAfter(monthStart(DateTime.now()))) return;
state = target;
}
/// 选中某一天所在的月份
void pick(DateTime anyDayInMonth) => state = monthStart(anyDayInMonth);
}
final monthAnchorProvider =
NotifierProvider<MonthAnchorNotifier, DateTime>(MonthAnchorNotifier.new);
/// 某月的账单列表(key = 月份锚点)
final billsProvider = FutureProvider.autoDispose.family<List<Bill>, DateTime>((ref, month) {
return ref.watch(billApiProvider).getBills(
startDate: formatDate(monthStart(month)),
endDate: formatDate(monthEnd(month)),
);
});
/// 某月的统计(首页汇总卡用),与列表共用同一个月份 key
final monthlyStatisticsProvider =
FutureProvider.autoDispose.family<StatisticsResponse, DateTime>((ref, month) {
return ref.watch(statisticsApiProvider).getMonthly(year: month.year, month: month.month);
});
/// 账单详情
final billDetailProvider =
FutureProvider.autoDispose.family<Bill, int>((ref, id) => ref.watch(billApiProvider).getBill(id));
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'account_provider.dart';
import 'bill_provider.dart';
import 'budget_provider.dart';
import 'statistics_provider.dart';
/// 账单数据的统一失效入口。
///
/// 任何写操作(手动记账、OCR 批量存储、编辑、删除)成功后调用这里,
/// 首页与统计页会自动重新取数——替代 uniApp 的 `uni.$emit('refreshBills')` 事件总线。
///
/// 传入的是 **family 对象**,Riverpod 会失效该 family 的所有 key,
/// 所以即使用户之前浏览过其它月份,那些缓存也一并作废,不会出现脏数据。
///
/// 账户信息也要一起失效:余额和总收支都是由账单汇总出来的。
///
/// 说明:Ref 与 WidgetRef 没有公共父类(且 invalidate 的参数类型 ProviderOrFamily
/// 未公开导出),所以这里写成两个入口,各自直接调用而不是抽公共函数。
void invalidateBillData(Ref ref) {
ref.invalidate(billsProvider);
ref.invalidate(monthlyStatisticsProvider);
ref.invalidate(statisticsProvider);
ref.invalidate(budgetProvider);
ref.invalidate(billDetailProvider);
ref.invalidate(accountProvider);
}
/// 供 Widget 层调用(下拉刷新、页面重试)
void invalidateBillDataFromWidget(WidgetRef ref) {
ref.invalidate(billsProvider);
ref.invalidate(monthlyStatisticsProvider);
ref.invalidate(statisticsProvider);
ref.invalidate(budgetProvider);
ref.invalidate(billDetailProvider);
ref.invalidate(accountProvider);
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/budget_api.dart';
import '../models/budget.dart';
/// 当月预算(后端只提供当月数据)。
///
/// 做成 AsyncNotifier 而不是 FutureProvider,是为了能直接调用
/// [BudgetNotifier.setMonthBudget] 修改预算并拿到后端返回的最新值。
class BudgetNotifier extends AsyncNotifier<BudgetInfo> {
@override
Future<BudgetInfo> build() => ref.watch(budgetApiProvider).getBudget();
/// 设置/修改当月预算(立即生效,可随时修改)
Future<void> setMonthBudget(double amount) async {
final updated = await ref.read(budgetApiProvider).setCurrentBudget(amount);
state = AsyncData(updated);
}
}
final budgetProvider = AsyncNotifierProvider<BudgetNotifier, BudgetInfo>(BudgetNotifier.new);
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/category_api.dart';
import '../models/category.dart';
/// 按类型加载分类:1=支出 2=收入
final categoriesProvider =
FutureProvider.autoDispose.family<List<Category>, int>((ref, type) {
return ref.watch(categoryApiProvider).getCategories(type);
});
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/bill_api.dart';
import '../api/category_api.dart';
import '../api/ocr_api.dart';
import '../core/network/api_exception.dart';
import '../models/bill_request.dart';
import '../models/ocr_result.dart';
import '../utils/date_util.dart';
import 'bill_refresh.dart';
/// OCR 识别流程状态
class OcrState {
final String? imagePath;
final List<OcrResult> results;
final Set<int> expandedIndexes;
final bool recognizing;
final bool saving;
final String? error;
const OcrState({
this.imagePath,
this.results = const [],
this.expandedIndexes = const {},
this.recognizing = false,
this.saving = false,
this.error,
});
OcrState copyWith({
Object? imagePath = _sentinel,
List<OcrResult>? results,
Set<int>? expandedIndexes,
bool? recognizing,
bool? saving,
Object? error = _sentinel,
}) {
return OcrState(
imagePath: imagePath == _sentinel ? this.imagePath : imagePath as String?,
results: results ?? this.results,
expandedIndexes: expandedIndexes ?? this.expandedIndexes,
recognizing: recognizing ?? this.recognizing,
saving: saving ?? this.saving,
error: error == _sentinel ? this.error : error as String?,
);
}
static const _sentinel = Object();
}
class OcrNotifier extends Notifier<OcrState> {
@override
OcrState build() => const OcrState();
void setImagePath(String path) {
state = OcrState(imagePath: path);
}
void clearImage() {
state = const OcrState();
}
void toggleExpanded(int index) {
final indexes = {...state.expandedIndexes};
if (!indexes.remove(index)) indexes.add(index);
state = state.copyWith(expandedIndexes: indexes);
}
/// 上传识别
Future<void> recognize() async {
final path = state.imagePath;
if (path == null) throw ApiException(-1, '请先选择图片');
state = state.copyWith(recognizing: true, error: null);
try {
final results = await ref.read(ocrApiProvider).recognize(path);
state = state.copyWith(
results: results,
// 只有一条时自动展开
expandedIndexes: results.length == 1 ? {0} : <int>{},
);
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
rethrow;
} finally {
state = state.copyWith(recognizing: false);
}
}
/// 直接批量存储:按金额正负推断类型,分类取该类型下第一项
Future<void> saveDirectly() async {
if (state.results.isEmpty) throw ApiException(-1, '没有可存储的识别结果');
state = state.copyWith(saving: true, error: null);
try {
final categoryApi = ref.read(categoryApiProvider);
final bills = <BillRequest>[];
final cache = <int, int?>{}; // type -> 默认分类id
for (final r in state.results) {
final type = r.isExpense ? 1 : 2;
if (!cache.containsKey(type)) {
final categories = await categoryApi.getCategories(type);
cache[type] = categories.isNotEmpty ? categories.first.id : null;
}
final categoryId = cache[type];
if (categoryId == null) {
throw ApiException(-1, '缺少${type == 1 ? '支出' : '收入'}分类,请先创建分类');
}
bills.add(BillRequest(
categoryId: categoryId,
amount: r.absAmount,
description: r.merchant ?? '',
billDate: parseBillDate(r.date),
type: type,
));
}
await ref.read(billApiProvider).createBillsBatch(bills);
// 让首页列表与统计页重新取数
invalidateBillData(ref);
state = const OcrState();
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
rethrow;
} finally {
state = state.copyWith(saving: false);
}
}
}
final ocrProvider = NotifierProvider<OcrNotifier, OcrState>(OcrNotifier.new);
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/statistics_api.dart';
import '../models/statistics_response.dart';
import '../utils/date_util.dart';
/// 统计页的时间粒度
enum StatisticsTab { daily, weekly, monthly }
class StatisticsTabNotifier extends Notifier<StatisticsTab> {
@override
StatisticsTab build() => StatisticsTab.monthly; // 对齐 uniApp 默认按月
void set(StatisticsTab tab) => state = tab;
}
final statisticsTabProvider =
NotifierProvider<StatisticsTabNotifier, StatisticsTab>(StatisticsTabNotifier.new);
/// 按时间粒度取统计。日期范围在 provider 内按"当前时刻"计算,
/// 与 uniApp 行为一致:日=今天,周=本周一~今天,月=本自然月。
final statisticsProvider =
FutureProvider.autoDispose.family<StatisticsResponse, StatisticsTab>((ref, tab) {
final api = ref.watch(statisticsApiProvider);
final now = DateTime.now();
switch (tab) {
case StatisticsTab.daily:
final today = formatDate(now);
return api.getDaily(startDate: today, endDate: today);
case StatisticsTab.weekly:
return api.getWeekly(weekStart: formatDate(weekStart(now)));
case StatisticsTab.monthly:
return api.getMonthly(year: now.year, month: now.month);
}
});