first commit
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user