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
+54
View File
@@ -0,0 +1,54 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/account.dart';
final accountApiProvider = Provider<AccountApi>((ref) => AccountApi(ref.watch(dioProvider)));
/// 账户接口(后端每个用户只有一个默认账户)
class AccountApi {
final Dio _dio;
AccountApi(this._dio);
/// 账户信息:余额、总收入、总支出(收支为全部账单汇总,只读)
Future<AccountInfo> getAccount() async {
try {
final res = await _dio.get('/accounts');
return AccountInfo.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 更新余额。
///
/// 注意:后端这个接口写入的是**期初余额**,传入前需用
/// `initialBalanceFor()` 把目标余额换算成期初余额。
Future<AccountInfo> updateBalance(double initialBalance) async {
try {
final res = await _dio.put('/accounts/balance', data: {
'initialBalance': initialBalance,
});
return AccountInfo.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
Map<String, dynamic> _parseObject(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map && data['data'] is Map) {
return Map<String, dynamic>.from(data['data'] as Map);
}
throw ApiException(-1, '账户响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '账户操作失败');
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/auth_response.dart';
final authApiProvider = Provider<AuthApi>((ref) => AuthApi(ref.watch(dioProvider)));
class AuthApi {
final Dio _dio;
AuthApi(this._dio);
/// 登录 → {token, username, nickname}
Future<AuthResponse> login(String username, String password) async {
try {
final res = await _dio.post('/auth/login', data: {
'username': username,
'password': password,
});
return AuthResponse.fromJson(_map(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 注册 → {token, username, nickname}
Future<AuthResponse> register(String username, String password, String nickname) async {
try {
final res = await _dio.post('/auth/register', data: {
'username': username,
'password': password,
'nickname': nickname,
});
return AuthResponse.fromJson(_map(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
Map<String, dynamic> _map(Object? data) {
if (data is Map<String, dynamic>) return data;
throw ApiException(-1, '响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '请求失败');
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/bill.dart';
import '../models/bill_request.dart';
final billApiProvider = Provider<BillApi>((ref) => BillApi(ref.watch(dioProvider)));
class BillApi {
final Dio _dio;
BillApi(this._dio);
/// 账单列表,可选日期区间(yyyy-MM-dd)。
/// 后端返回裸数组,无分页。
Future<List<Bill>> getBills({String? startDate, String? endDate}) async {
try {
final params = <String, dynamic>{};
if (startDate != null) params['startDate'] = startDate;
if (endDate != null) params['endDate'] = endDate;
final res = await _dio.get('/bills', queryParameters: params);
return _parseList(res.data).map(Bill.fromJson).toList();
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 账单详情
Future<Bill> getBill(int id) async {
try {
final res = await _dio.get('/bills/$id');
return Bill.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 创建账单
Future<void> createBill(BillRequest bill) async {
try {
await _dio.post('/bills', data: bill.toJson());
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 批量创建账单(OCR 直接存储)
Future<void> createBillsBatch(List<BillRequest> bills) async {
try {
await _dio.post('/bills/batch', data: {
'bills': bills.map((b) => b.toJson()).toList(),
});
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 更新账单
///
/// 注意:后端对 description 为 null 会覆盖成 null,所以必须回传全部字段
/// (BillRequest.toJson 不含 imageUrl,后端按「null 保留原值」处理,正好保留原图)。
Future<Bill> updateBill(int id, BillRequest bill) async {
try {
final res = await _dio.put('/bills/$id', data: bill.toJson());
return Bill.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 删除账单(后端为物理删除,不可恢复)
Future<void> deleteBill(int id) async {
try {
await _dio.delete('/bills/$id');
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 兼容裸数组与 {code,data:[...]} 两种形态
List<Map<String, dynamic>> _parseList(Object? data) {
if (data is List) {
return data.whereType<Map<String, dynamic>>().toList();
}
if (data is Map && data['data'] is List) {
return (data['data'] as List).whereType<Map<String, dynamic>>().toList();
}
throw ApiException(-1, '响应格式异常');
}
Map<String, dynamic> _parseObject(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map && data['data'] is Map) return Map<String, dynamic>.from(data['data'] as Map);
throw ApiException(-1, '响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '请求失败');
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/budget.dart';
final budgetApiProvider = Provider<BudgetApi>((ref) => BudgetApi(ref.watch(dioProvider)));
/// 预算接口(后端只提供当月预算)
class BudgetApi {
final Dio _dio;
BudgetApi(this._dio);
/// 获取当月预算。
///
/// 未设置预算时后端可能返回 204 / 空 body,此时返回 [BudgetInfo.empty]
/// 而不是抛错——否则首页会因为"没设预算"而进入错误态。
Future<BudgetInfo> getBudget() async {
try {
final res = await _dio.get('/budgets');
return _parse(res.data);
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '预算加载失败');
}
}
/// 设置/修改当月预算(每月生效,可随时修改)。
/// 后端自动填充年月,amount 必须 >= 0。
Future<BudgetInfo> setCurrentBudget(double amount) async {
try {
final res = await _dio.put('/budgets/current', data: {'amount': amount});
return _parse(res.data);
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '预算保存失败');
}
}
BudgetInfo _parse(Object? data) {
if (data == null) return BudgetInfo.empty;
if (data is Map<String, dynamic>) return BudgetInfo.fromJson(data);
if (data is Map && data['data'] is Map) {
return BudgetInfo.fromJson(Map<String, dynamic>.from(data['data'] as Map));
}
return BudgetInfo.empty;
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/category.dart';
final categoryApiProvider = Provider<CategoryApi>((ref) => CategoryApi(ref.watch(dioProvider)));
class CategoryApi {
final Dio _dio;
CategoryApi(this._dio);
/// 获取分类列表,type: 1=支出 2=收入(后端可能返回裸数组或 {code,data})
Future<List<Category>> getCategories(int type) async {
try {
final res = await _dio.get('/categories', queryParameters: {'type': type});
return _parseList(res.data)
.whereType<Map<String, dynamic>>()
.map(Category.fromJson)
.toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '请求失败');
}
}
List<dynamic> _parseList(Object? data) {
if (data is List) return data;
if (data is Map && data['data'] is List) return data['data'] as List;
throw ApiException(-1, '分类响应格式异常');
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/ocr_result.dart';
final ocrApiProvider = Provider<OcrApi>((ref) => OcrApi(ref.watch(dioProvider)));
class OcrApi {
final Dio _dio;
OcrApi(this._dio);
/// 上传图片识别账单,字段名固定为 file(与 uniApp upload 对齐)。
/// 后端可能返回裸数组或 {code, data:[...]},两种都兼容。
Future<List<OcrResult>> recognize(String filePath) async {
try {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: 'receipt.jpg'),
});
final res = await _dio.post('/ocr/recognize', data: formData);
final data = res.data;
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['data'] is List) {
list = data['data'] as List;
} else {
throw ApiException(-1, '未识别到账单信息');
}
return list
.whereType<Map<String, dynamic>>()
.map(OcrResult.fromJson)
.toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '识别失败,请重试');
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/statistics_response.dart';
final statisticsApiProvider =
Provider<StatisticsApi>((ref) => StatisticsApi(ref.watch(dioProvider)));
/// 统计接口。
///
/// 注意后端三个接口实际都委托给同一段按日聚合逻辑,区别只在参数:
/// daily 传日期区间,weekly 传周一,monthly 传年月。
class StatisticsApi {
final Dio _dio;
StatisticsApi(this._dio);
/// 按日期区间统计(yyyy-MM-dd)
Future<StatisticsResponse> getDaily({
required String startDate,
required String endDate,
}) async {
return _request('/statistics/daily', {
'startDate': startDate,
'endDate': endDate,
});
}
/// 按周统计,weekStart 为本周一(后端自行算到周内其余日期)
Future<StatisticsResponse> getWeekly({required String weekStart}) async {
return _request('/statistics/weekly', {'weekStart': weekStart});
}
/// 按月统计
Future<StatisticsResponse> getMonthly({required int year, required int month}) async {
return _request('/statistics/monthly', {'year': year, 'month': month});
}
Future<StatisticsResponse> _request(String path, Map<String, dynamic> params) async {
try {
final res = await _dio.get(path, queryParameters: params);
final data = res.data;
if (data is Map<String, dynamic>) return StatisticsResponse.fromJson(data);
if (data is Map && data['data'] is Map) {
return StatisticsResponse.fromJson(Map<String, dynamic>.from(data['data'] as Map));
}
// 空响应(无数据)按空统计处理,不视为错误
return StatisticsResponse.empty;
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '统计加载失败');
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/navigation/navigator_key.dart';
import 'pages/login/login_page.dart';
import 'pages/shell/main_shell.dart';
import 'providers/auth_provider.dart';
class QiangbaoApp extends ConsumerWidget {
const QiangbaoApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
return MaterialApp(
title: '强宝爱记账',
navigatorKey: navigatorKey,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFC8956E),
primary: const Color(0xFFC8956E),
),
scaffoldBackgroundColor: const Color(0xFFF8F8F8),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFF8F8F8),
foregroundColor: Color(0xFF5D4037),
elevation: 0,
),
),
home: auth.isLoggedIn ? const MainShell() : const LoginPage(),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
/// 全局 navigatorKey
///
/// 供没有 BuildContext 的地方使用(网络层 401/403 跳转、全局 toast)。
/// 独立成文件是为了避免 app.dart ↔ 网络层 之间的循环依赖。
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
/// API 环境配置
///
/// ## 开发环境(按平台自动选择)
/// - **Android 真机/模拟器**:`http://localhost:12345/api`
/// 需要先做端口转发,把手机的 localhost 映射到电脑的 localhost:
/// ```
/// adb reverse tcp:12345 tcp:12345 # 每次插拔设备后需重新执行
/// adb reverse --list # 确认映射存在
/// ```
/// 好处:不受 Windows 防火墙影响,也不要求手机与电脑在同一 Wi-Fi。
/// 若不想用 adb reverse(仅模拟器可行),可用电脑局域网 IP 覆盖:
/// `flutter run --dart-define=API_BASE_URL=http://192.168.x.x:12345/api`
/// (模拟器也可直接用固定地址 `10.0.2.2:12345`)
/// - **Windows / Chrome / iOS 模拟器**:`http://localhost:12345/api`
///
/// ## 生产环境
/// HTTPS,无需关心明文流量问题。
const String kProdBaseUrl = 'https://accounting.aqroid.cn/api';
const String kLocalBaseUrl = 'http://localhost:12345/api';
/// 手动覆盖地址(优先级最高):
/// `flutter run --dart-define=API_BASE_URL=http://<host>:12345/api`
const String _override = String.fromEnvironment('API_BASE_URL');
String get baseUrl {
if (_override.isNotEmpty) return _override;
if (!kDebugMode) return kProdBaseUrl;
return kLocalBaseUrl;
}
+16
View File
@@ -0,0 +1,16 @@
/// 统一 API 异常(对齐 uniApp request.js 的错误语义)
class ApiException implements Exception {
final int code;
final String message;
ApiException(this.code, this.message);
@override
String toString() => 'ApiException($code, $message)';
}
/// 从任意抛出对象中提取 ApiException(供 UI/Provider 捕获使用)
ApiException asApiException(Object error) {
if (error is ApiException) return error;
return ApiException(-1, error.toString());
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:dio/dio.dart';
import '../storage/local_storage.dart';
/// 请求拦截器:注入 Bearer token(对齐 uniApp request.js 的 header 逻辑)
class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = LocalStorage.instance.getToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation/navigator_key.dart';
import '../../pages/login/login_page.dart';
import '../../providers/auth_provider.dart';
/// 认证失效处理桥接:网络层(无 Riverpod 上下文)通过此模块
/// 调用 authProvider.logout 并跳转登录页。
ProviderContainer? _container;
void configureAuthRedirect(ProviderContainer container) {
_container = container;
}
/// 主动退出登录(用户在「我的」页面点击)
Future<void> logoutAndGoLogin() async {
await _container?.read(authProvider.notifier).logout();
_goLoginPage();
}
/// 登录已失效(token 过期/无效):清本地登录态并跳登录页,给出提示
Future<void> forceLogout({String reason = '登录已过期,请重新登录'}) async {
await _container?.read(authProvider.notifier).logout();
_goLoginPage();
_showTip(reason);
}
void _goLoginPage() {
final nav = navigatorKey.currentState;
if (nav == null) return;
nav.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
void _showTip(String reason) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = navigatorKey.currentContext;
if (ctx == null) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(reason),
backgroundColor: const Color(0xFFB71C1C),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
});
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_env.dart';
import 'auth_interceptor.dart';
import 'error_interceptor.dart';
/// 全局 Dio 实例
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
dio.interceptors.addAll([
AuthInterceptor(),
ErrorInterceptor(),
]);
return dio;
});
+108
View File
@@ -0,0 +1,108 @@
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'auth_redirect.dart';
/// 统一响应处理:业务 code 解包、登录失效跳转、错误映射为 ApiException。
///
/// dio 默认 validateStatus(<400) 下:HTTP 2xx → onResponse(解包业务 code),
/// HTTP 4xx/5xx → onError(提取 message / 处理认证失效)。
///
/// ## 关于「登录失效」的判定(重要)
/// 后端 `SecurityConfig` 未配置 authenticationEntryPoint,Spring Security 6
/// 默认用 Http403ForbiddenEntryPoint,因此 **token 缺失/过期时返回的是 403 而非 401**。
/// 所以这里两种都要处理:
/// - 401:标准未授权(后端以后改对了也兼容)
/// - 403:仅当请求的是需要鉴权的接口(非 permitAll 路径)时才算登录失效
///
/// permitAll 路径(见后端 SecurityConfig):`/auth/**`、`/gold-price/**`。
/// 这些接口的 403 属于服务端错误(例如登录时数据库连不上),绝不能当成过期——
/// 否则会把用户无故踢下线。
class ErrorInterceptor extends Interceptor {
bool _redirecting = false;
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
// body.code == 200 或不存在 code 字段视为成功(与 uniApp request.js 一致)
if (data is Map && data['code'] != null && data['code'] != 200) {
final code = data['code'] is int ? data['code'] as int : -1;
final message = data['message']?.toString() ?? '请求失败';
if (code == 401) {
_handleAuthFailure();
handler.reject(_dioException(response.requestOptions, ApiException(401, message)));
return;
}
handler.reject(_dioException(response.requestOptions, ApiException(code, message)));
return;
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 已包装为 ApiException 的直接透传
if (err.error is ApiException) {
handler.next(err);
return;
}
final status = err.response?.statusCode;
final path = err.response?.requestOptions.path ?? err.requestOptions.path;
// 401:标准未授权 → 视为登录失效
if (status == 401) {
_handleAuthFailure();
handler.next(err.copyWith(error: ApiException(401, '登录已过期,请重新登录')));
return;
}
// 403:本后端未认证时的兜底状态码 → 受保护接口的 403 视为登录失效
if (status == 403 && !_isPermitAllPath(path)) {
_handleAuthFailure();
handler.next(err.copyWith(error: ApiException(403, '登录已过期,请重新登录')));
return;
}
// HTTP 4xx:尽量提取后端返回的 message(含 permitAll 接口的真实服务端错误)
final data = err.response?.data;
if (data is Map && data['message'] != null) {
handler.next(err.copyWith(error: ApiException(-1, data['message'].toString())));
return;
}
// 403 且无 message:多为 Spring Security 兜底(空响应体),给出可读提示
if (status == 403) {
handler.next(err.copyWith(error: ApiException(403, '请求被拒绝(403),请查看后端日志')));
return;
}
// 网络错误等统一映射
final message = switch (err.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'连接超时,请检查网络',
DioExceptionType.connectionError => '网络错误,请检查网络连接',
_ => '请求失败(${err.response?.statusCode ?? '无响应'})',
};
handler.next(err.copyWith(error: ApiException(-1, message)));
}
/// 后端 SecurityConfig 中 permitAll 的路径前缀,其 403 不代表登录失效
bool _isPermitAllPath(String path) =>
path.startsWith('/auth') || path.startsWith('/gold-price');
void _handleAuthFailure() {
if (_redirecting) return;
_redirecting = true;
Future(() async {
try {
await forceLogout();
} finally {
Future.delayed(const Duration(seconds: 1), () => _redirecting = false);
}
});
}
DioException _dioException(RequestOptions options, ApiException error) =>
DioException(requestOptions: options, error: error);
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// 本地存储封装:token / userInfo / 记住密码
/// (对齐 uniApp 的 uni.setStorageSync / getStorageSync 用法)
class LocalStorage {
LocalStorage._();
static final LocalStorage instance = LocalStorage._();
SharedPreferences? _prefs;
/// App 启动时调用一次,确保后续同步读取可用
Future<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
Future<SharedPreferences> _getPrefs() async {
return _prefs ??= await SharedPreferences.getInstance();
}
// ---- 认证 ----
static const _keyToken = 'token';
static const _keyUserInfo = 'userInfo';
Future<void> setAuth({required String token, required Map<String, dynamic> userInfo}) async {
final prefs = await _getPrefs();
await prefs.setString(_keyToken, token);
await prefs.setString(_keyUserInfo, jsonEncode(userInfo));
}
Future<void> clearAuth() async {
final prefs = await _getPrefs();
await prefs.remove(_keyToken);
await prefs.remove(_keyUserInfo);
}
String? getToken() => _prefs?.getString(_keyToken);
Map<String, dynamic>? getUserInfo() {
final raw = _prefs?.getString(_keyUserInfo);
if (raw == null) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
// ---- 记住密码 ----
static const _keySavedUsername = 'saved_username';
static const _keySavedPassword = 'saved_password';
Future<void> saveRememberedPassword(String username, String encryptedPassword) async {
final prefs = await _getPrefs();
await prefs.setString(_keySavedUsername, username);
await prefs.setString(_keySavedPassword, encryptedPassword);
}
Future<void> clearRememberedPassword() async {
final prefs = await _getPrefs();
await prefs.remove(_keySavedUsername);
await prefs.remove(_keySavedPassword);
}
({String username, String password})? getRememberedAccount() {
final username = _prefs?.getString(_keySavedUsername);
final password = _prefs?.getString(_keySavedPassword);
if (username == null || password == null) return null;
return (username: username, password: password);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
import 'core/network/auth_redirect.dart';
import 'core/storage/local_storage.dart';
import 'providers/auth_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LocalStorage.instance.init();
final container = ProviderContainer();
// 401 过期处理桥接(网络层 → authProvider + navigatorKey)
configureAuthRedirect(container);
// 启动时先读取持久化的登录态,避免首屏闪跳
await container.read(authProvider.notifier).load();
runApp(
UncontrolledProviderScope(
container: container,
child: const QiangbaoApp(),
),
);
}
+63
View File
@@ -0,0 +1,63 @@
import 'json_util.dart';
/// 账户信息(对应后端 AccountResponse)
///
/// 语义说明(重要):
/// - [balance] 是后端计算出来的:`initialBalance + totalIncome - totalExpense`
/// - [initialBalance] 才是可写入数据库的字段(后端 PUT /accounts/balance 改的是它)
/// - [totalIncome] / [totalExpense] 是**全部账单**的汇总,只读
class AccountInfo {
final int? id;
final String name;
final double initialBalance;
final double balance;
final double totalIncome;
final double totalExpense;
const AccountInfo({
this.id,
this.name = '默认账户',
required this.initialBalance,
required this.balance,
required this.totalIncome,
required this.totalExpense,
});
factory AccountInfo.fromJson(Map<String, dynamic> json) {
final initial = asDouble(json['initialBalance']);
final income = asDouble(json['totalIncome']);
final expense = asDouble(json['totalExpense']);
return AccountInfo(
id: json['id'] == null ? null : asInt(json['id']),
name: asNonEmpty(json['name'], '默认账户'),
initialBalance: initial,
// 后端没给 balance 时本地按同一公式兜底
balance: json['balance'] == null
? initial + income - expense
: asDouble(json['balance']),
totalIncome: income,
totalExpense: expense,
);
}
static const empty = AccountInfo(
initialBalance: 0,
balance: 0,
totalIncome: 0,
totalExpense: 0,
);
}
/// 把"用户想要的目标余额"反算成后端可写入的期初余额。
///
/// 因为 balance = initialBalance + totalIncome - totalExpense,
/// 所以要让余额显示为 target,期初余额必须设为:
/// `target - totalIncome + totalExpense`
double initialBalanceFor({
required double targetBalance,
required double totalIncome,
required double totalExpense,
}) {
if (!targetBalance.isFinite) return 0;
return targetBalance - totalIncome + totalExpense;
}
+29
View File
@@ -0,0 +1,29 @@
class AuthResponse {
final String token;
final String? username;
final String? nickname;
const AuthResponse({required this.token, this.username, this.nickname});
factory AuthResponse.fromJson(Map<String, dynamic> json) => AuthResponse(
token: json['token']?.toString() ?? '',
username: json['username']?.toString(),
nickname: json['nickname']?.toString(),
);
}
class UserInfo {
final String? id;
final String username;
final String nickname;
const UserInfo({this.id, required this.username, required this.nickname});
Map<String, dynamic> toJson() => {'id': id, 'username': username, 'nickname': nickname};
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
id: json['id']?.toString(),
username: json['username']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
);
}
+52
View File
@@ -0,0 +1,52 @@
import '../utils/date_util.dart';
import 'json_util.dart';
/// 账单(对应后端 BillResponse)
///
/// 注意:amount 后端恒返回正数,收支方向看 [type]。
class Bill {
final int id;
final int categoryId;
final String categoryName;
final String categoryIcon;
final double amount;
final String description;
final String billDate; // yyyy-MM-dd
final String? imageUrl;
final int type; // 1=支出 2=收入
final String? createTime;
final String? updateTime;
const Bill({
required this.id,
required this.categoryId,
required this.categoryName,
required this.categoryIcon,
required this.amount,
required this.description,
required this.billDate,
this.imageUrl,
required this.type,
this.createTime,
this.updateTime,
});
bool get isExpense => type == 1;
double get absAmount => amount.abs();
factory Bill.fromJson(Map<String, dynamic> json) => Bill(
id: asInt(json['id']),
categoryId: asInt(json['categoryId']),
categoryName: asNonEmpty(json['categoryName'], '未分类'),
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
amount: asDouble(json['amount']),
description: asString(json['description']),
billDate: parseBillDate(asString(json['billDate'])),
imageUrl: json['imageUrl'] == null ? null : asString(json['imageUrl']),
// 后端可能返回 null,缺省按支出处理
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
createTime: json['createTime'] == null ? null : asString(json['createTime']),
updateTime: json['updateTime'] == null ? null : asString(json['updateTime']),
);
}
+24
View File
@@ -0,0 +1,24 @@
/// 账单创建/更新请求体
class BillRequest {
final int categoryId;
final double amount;
final String description;
final String billDate; // yyyy-MM-dd
final int type; // 1=支出,2=收入
const BillRequest({
required this.categoryId,
required this.amount,
this.description = '',
required this.billDate,
required this.type,
});
Map<String, dynamic> toJson() => {
'categoryId': categoryId,
'amount': amount,
'description': description,
'billDate': billDate,
'type': type,
};
}
+54
View File
@@ -0,0 +1,54 @@
import 'json_util.dart';
/// 预算信息(对应后端 BudgetResponse,仅当月有效)
class BudgetInfo {
final int? id;
final int? year;
final int? month;
final double amount; // 预算总额
final double usedAmount; // 已用
final double remainingAmount; // 剩余
final double remainingDaily; // 日均可用
const BudgetInfo({
this.id,
this.year,
this.month,
required this.amount,
required this.usedAmount,
required this.remainingAmount,
required this.remainingDaily,
});
bool get hasBudget => amount > 0;
bool get isOverBudget => amount > 0 && usedAmount > amount;
/// 已用占比(0~1),未设预算时为 0
double get usedRatio {
if (amount <= 0 || !usedAmount.isFinite) return 0;
return (usedAmount / amount).clamp(0.0, 1.0);
}
factory BudgetInfo.fromJson(Map<String, dynamic> json) {
final amount = asDouble(json['amount']);
final used = asDouble(json['usedAmount']);
return BudgetInfo(
id: json['id'] == null ? null : asInt(json['id']),
year: json['year'] == null ? null : asInt(json['year']),
month: json['month'] == null ? null : asInt(json['month']),
amount: amount,
usedAmount: used,
remainingAmount:
json['remainingAmount'] == null ? amount - used : asDouble(json['remainingAmount']),
remainingDaily: asDouble(json['remainingDaily']),
);
}
static const empty = BudgetInfo(
amount: 0,
usedAmount: 0,
remainingAmount: 0,
remainingDaily: 0,
);
}
+24
View File
@@ -0,0 +1,24 @@
/// 分类(type: 1=支出,2=收入)
class Category {
final int id;
final String name;
final String icon; // emoji
final int type;
final int? sortOrder;
const Category({
required this.id,
required this.name,
required this.icon,
required this.type,
this.sortOrder,
});
factory Category.fromJson(Map<String, dynamic> json) => Category(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? '',
icon: json['icon']?.toString() ?? '📦',
type: (json['type'] as num?)?.toInt() ?? 1,
sortOrder: (json['sortOrder'] as num?)?.toInt(),
);
}
+36
View File
@@ -0,0 +1,36 @@
/// 后端字段的防御式取值工具
///
/// 后端没有统一的响应包装,字段类型也可能与预期不符(例如金额返回字符串、
/// 数组返回 null)。所有 fromJson 都走这里取值,保证解析绝不抛异常。
library;
int asInt(Object? value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
double asDouble(Object? value, {double fallback = 0}) {
if (value is double) return value;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? fallback;
return fallback;
}
String asString(Object? value, {String fallback = ''}) {
if (value == null) return fallback;
return value.toString();
}
/// 取字符串,空串时回退(用于分类名/图标这类必须有值的展示字段)
String asNonEmpty(Object? value, String fallback) {
final text = asString(value);
return text.isEmpty ? fallback : text;
}
/// 取对象数组,非 Map 的元素直接丢弃
List<Map<String, dynamic>> asMapList(Object? value) {
if (value is! List) return const [];
return value.whereType<Map<String, dynamic>>().toList();
}
+25
View File
@@ -0,0 +1,25 @@
/// OCR 识别出的单条账单
class OcrResult {
final double amount; // 负数=支出,正数=收入(与 uniApp 语义一致)
final String? merchant;
final String? date;
final double? confidence;
const OcrResult({
required this.amount,
this.merchant,
this.date,
this.confidence,
});
bool get isExpense => amount < 0;
double get absAmount => amount.abs();
factory OcrResult.fromJson(Map<String, dynamic> json) => OcrResult(
amount: (json['amount'] as num?)?.toDouble() ?? 0,
merchant: json['merchant']?.toString(),
date: json['date']?.toString(),
confidence: (json['confidence'] as num?)?.toDouble(),
);
}
+90
View File
@@ -0,0 +1,90 @@
import 'json_util.dart';
/// 统计响应(对应后端 StatisticsResponse)
///
/// 口径说明:后端的 totalIncome / totalExpense / categoryStatistics 是按
/// **分类的 type** 判定收支的,而 /bills 列表是按 **账单的 type** 判定。
/// 两者可能不一致——前端不做交叉校验,汇总/饼图一律以此接口为准。
class StatisticsResponse {
final double totalIncome;
final double totalExpense;
final double balance;
final List<CategoryStat> categoryStatistics;
final List<DailyStat> dailyStatistics;
const StatisticsResponse({
required this.totalIncome,
required this.totalExpense,
required this.balance,
required this.categoryStatistics,
required this.dailyStatistics,
});
bool get isEmpty => categoryStatistics.isEmpty && totalIncome == 0 && totalExpense == 0;
factory StatisticsResponse.fromJson(Map<String, dynamic> json) {
final income = asDouble(json['totalIncome']);
final expense = asDouble(json['totalExpense']);
final balanceRaw = json['balance'];
return StatisticsResponse(
totalIncome: income,
totalExpense: expense,
// 后端没给 balance 时本地兜底计算
balance: balanceRaw == null ? income - expense : asDouble(balanceRaw),
categoryStatistics:
asMapList(json['categoryStatistics']).map(CategoryStat.fromJson).toList(),
dailyStatistics:
asMapList(json['dailyStatistics']).map(DailyStat.fromJson).toList(),
);
}
static const empty = StatisticsResponse(
totalIncome: 0,
totalExpense: 0,
balance: 0,
categoryStatistics: [],
dailyStatistics: [],
);
}
/// 分类维度的统计项
class CategoryStat {
final int categoryId;
final String categoryName;
final String categoryIcon;
final double amount;
final int type; // 1=支出 2=收入
const CategoryStat({
required this.categoryId,
required this.categoryName,
required this.categoryIcon,
required this.amount,
required this.type,
});
bool get isExpense => type != 2;
factory CategoryStat.fromJson(Map<String, dynamic> json) => CategoryStat(
categoryId: asInt(json['categoryId']),
categoryName: asNonEmpty(json['categoryName'], '未分类'),
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
amount: asDouble(json['amount']),
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
);
}
/// 按日趋势项(本期统计页不画折线图,保留以便后续扩展)
class DailyStat {
final String date; // yyyy-MM-dd
final double income;
final double expense;
const DailyStat({required this.date, required this.income, required this.expense});
factory DailyStat.fromJson(Map<String, dynamic> json) => DailyStat(
date: asString(json['date']),
income: asDouble(json['income']),
expense: asDouble(json['expense']),
);
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/manual_entry_tab.dart';
import 'widgets/ocr_tab.dart';
/// 记账页内部 Tab:0=手动记账 1=OCR识别
class AddTabNotifier extends Notifier<int> {
@override
int build() => 0;
void set(int index) => state = index;
}
final addTabProvider = NotifierProvider<AddTabNotifier, int>(AddTabNotifier.new);
/// 记账页:手动记账 / OCR识别 双 Tab(复刻 uniApp pages/add/add.vue)
class AddPage extends ConsumerWidget {
const AddPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = ref.watch(addTabProvider);
return Scaffold(
appBar: AppBar(title: const Text('添加账单')),
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: Column(
children: [
const SizedBox(height: 12),
_buildTabs(tabIndex, ref),
Expanded(
child: tabIndex == 0 ? const ManualEntryTab() : const OcrTab(),
),
],
),
),
);
}
Widget _buildTabs(int tabIndex, WidgetRef ref) {
void setTab(int index) => ref.read(addTabProvider.notifier).set(index);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
children: [
_tab('手动记账', 0, tabIndex, setTab),
_tab('OCR识别', 1, tabIndex, setTab),
],
),
);
}
Widget _tab(String text, int index, int tabIndex, void Function(int) setTab) {
final active = tabIndex == index;
return Expanded(
child: GestureDetector(
onTap: () => setTab(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: active
? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)])
: null,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
}
+247
View File
@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../providers/add_bill_provider.dart';
import '../../../providers/category_provider.dart';
import '../../../utils/amount_input_formatter.dart';
import '../../../utils/date_util.dart';
import '../../../utils/toast.dart';
import '../../../widgets/category_picker_grid.dart';
import '../../../widgets/state_views.dart';
/// 手动记账表单(复刻 uniApp add.vue 手动 Tab)
class ManualEntryTab extends ConsumerStatefulWidget {
const ManualEntryTab({super.key});
@override
ConsumerState<ManualEntryTab> createState() => _ManualEntryTabState();
}
class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
late final TextEditingController _descriptionController = TextEditingController();
late final TextEditingController _amountController = TextEditingController();
@override
void dispose() {
_descriptionController.dispose();
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final form = ref.watch(addBillFormProvider);
final notifier = ref.read(addBillFormProvider.notifier);
// OCR「选择此条」外部填充时,同步非受控输入框
ref.listen<int>(addBillFormProvider.select((s) => s.revision), (prev, next) {
if (prev == next) return;
final s = ref.read(addBillFormProvider);
_amountController.text = s.amount?.toStringAsFixed(2) ?? '';
_descriptionController.text = s.description;
});
return ListView(
padding: const EdgeInsets.all(16),
children: [
_sectionLabel('账单类型'),
_typeSelector(form, notifier),
const SizedBox(height: 16),
_sectionLabel('分类'),
_categoryGrid(form, notifier),
const SizedBox(height: 16),
_sectionLabel('金额'),
_amountField(form, notifier),
const SizedBox(height: 16),
_sectionLabel('日期'),
_dateField(context, form, notifier),
const SizedBox(height: 16),
_sectionLabel('备注'),
_descriptionField(form, notifier),
const SizedBox(height: 24),
_submitBtn(context, ref, form, notifier),
const SizedBox(height: 90), // 底部导航栏避让
],
);
}
Widget _sectionLabel(String text) => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
);
// ---- 类型选择 ----
Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, form, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, form, notifier),
],
);
}
Widget _typeCard(String icon, String label, int type, AddBillFormState form, AddBillFormNotifier notifier) {
final active = form.type == type;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setType(type),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? const Color(0xFFC8956E) : Colors.transparent,
width: 2,
),
boxShadow: active
? [BoxShadow(color: const Color(0x33C8956E), blurRadius: 8, offset: const Offset(0, 3))]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
],
),
),
),
);
}
// ---- 分类宫格 ----
Widget _categoryGrid(AddBillFormState form, AddBillFormNotifier notifier) {
return Consumer(builder: (context, ref, _) {
final categoriesAsync = ref.watch(categoriesProvider(form.type));
return categoriesAsync.when(
loading: () => const LoadingView(padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: '分类加载失败:${asApiException(e).message}',
onRetry: () => ref.invalidate(categoriesProvider),
),
data: (categories) => CategoryPickerGrid(
categories: categories,
selectedId: form.categoryId,
onSelect: notifier.setCategory,
crossAxisCount: 5,
),
);
});
}
// ---- 金额 ----
Widget _amountField(AddBillFormState form, AddBillFormNotifier notifier) {
return TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [AmountInputFormatter()],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入金额',
prefixText: '¥ ',
prefixStyle: const TextStyle(fontSize: 18, color: Color(0xFFC8956E), fontWeight: FontWeight.bold),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
),
onChanged: (v) => notifier.setAmount(double.tryParse(v)),
);
}
// ---- 日期 ----
Widget _dateField(BuildContext context, AddBillFormState form, AddBillFormNotifier notifier) {
return GestureDetector(
onTap: () async {
final initial = DateTime.tryParse(form.billDate) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) notifier.setDate(formatDate(picked));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Text(form.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
const Spacer(),
const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)),
],
),
),
);
}
// ---- 备注 ----
Widget _descriptionField(AddBillFormState form, AddBillFormNotifier notifier) {
return TextField(
maxLines: 3,
maxLength: 200,
controller: _descriptionController,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入备注(可选)',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
),
onChanged: notifier.setDescription,
);
}
// ---- 保存 ----
Widget _submitBtn(BuildContext context, WidgetRef ref, AddBillFormState form, AddBillFormNotifier notifier) {
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
),
onPressed: form.submitting
? null
: () async {
try {
await notifier.submit();
_amountController.clear();
_descriptionController.clear();
if (context.mounted) showToast(context, '✅ 记账成功');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '保存失败,请重试', error: true);
}
},
child: form.submitting
? const SizedBox(
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('保 存', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, letterSpacing: 4)),
),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/add_bill_provider.dart';
import '../../../providers/ocr_provider.dart';
import '../../../utils/date_util.dart';
import '../add_page.dart';
/// 单条 OCR 识别结果卡片:可折叠详情 + 「选择此条」填充手动表单
class OcrResultCard extends ConsumerWidget {
final int index;
final bool expanded;
final VoidCallback onToggle;
const OcrResultCard({
super.key,
required this.index,
required this.expanded,
required this.onToggle,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ocrProvider);
if (index >= state.results.length) return const SizedBox.shrink();
final result = state.results[index];
final isExpense = result.isExpense;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE0C9A6)),
),
child: Column(
children: [
// 头部:序号 + 金额 + 置信度 + 展开
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Text(
'#${index + 1}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Color(0xFF8D6E63)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'¥${result.absAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: isExpense ? const Color(0xFFEF5350) : const Color(0xFF66BB6A),
),
),
),
if (result.confidence != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
'置信度 ${(result.confidence! * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more, color: Color(0xFF8D6E63)),
),
],
),
),
),
// 详情
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
firstChild: const SizedBox(width: double.infinity),
secondChild: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1, color: Color(0xFFF0E6D8)),
const SizedBox(height: 8),
_detailRow('类型', isExpense ? '支出' : '收入'),
if (result.merchant != null && result.merchant!.isNotEmpty)
_detailRow('商户', result.merchant!),
if (result.date != null && result.date!.isNotEmpty)
_detailRow('日期', parseBillDate(result.date)),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 38,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
side: const BorderSide(color: Color(0xFFC8956E)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(19)),
),
onPressed: () {
ref.read(addBillFormProvider.notifier).fillFromOcr(result);
// 切回手动记账 Tab 并清空 OCR 状态
ref.read(addTabProvider.notifier).set(0);
ref.read(ocrProvider.notifier).clearImage();
},
child: const Text('选择此条', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
),
),
],
),
),
),
],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 42,
child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037))),
),
],
),
);
}
}
+208
View File
@@ -0,0 +1,208 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/network/api_exception.dart';
import '../../../providers/ocr_provider.dart';
import '../../../utils/toast.dart';
import 'ocr_result_card.dart';
/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab)
class OcrTab extends ConsumerWidget {
const OcrTab({super.key});
Future<void> _chooseImage(BuildContext context, WidgetRef ref, ImageSource source) async {
final picker = ImagePicker();
final xfile = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1600);
if (xfile != null) {
ref.read(ocrProvider.notifier).setImagePath(xfile.path);
}
}
Future<void> _recognize(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).recognize();
final count = ref.read(ocrProvider).results.length;
if (context.mounted) {
showToast(context, count > 0 ? '识别到 $count 条记录' : '未识别到账单信息', error: count == 0);
}
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '识别失败,请重试', error: true);
}
}
Future<void> _saveDirectly(BuildContext context, WidgetRef ref) async {
try {
await ref.read(ocrProvider.notifier).saveDirectly();
if (context.mounted) showToast(context, '✅ 已保存识别的账单');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '保存失败,请重试', error: true);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ocrProvider);
final notifier = ref.read(ocrProvider.notifier);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// 图片预览
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('图片预览', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037))),
const SizedBox(height: 10),
GestureDetector(
onTap: () => _showSourceSheet(context, ref),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: state.imagePath != null
? SizedBox(
height: 220,
width: double.infinity,
child: Image.file(
File(state.imagePath!),
fit: BoxFit.contain,
),
)
: Container(
height: 140,
width: double.infinity,
color: const Color(0xFFF5F5F5),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('📷', style: TextStyle(fontSize: 36)),
SizedBox(height: 6),
Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))),
],
),
),
),
),
if (state.imagePath != null)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: notifier.clearImage,
child: const Text('清除图片', style: TextStyle(color: Color(0xFF8D6E63))),
),
),
],
),
),
const SizedBox(height: 14),
// 识别按钮
if (state.imagePath != null && state.results.isEmpty)
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: state.recognizing ? null : () => _recognize(context, ref),
child: state.recognizing
? const SizedBox(
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text('识别账单', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
// 识别结果
if (state.results.isNotEmpty) ...[
SizedBox(
width: double.infinity,
height: 44,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
onPressed: state.saving ? null : () => _saveDirectly(context, ref),
icon: state.saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_outlined, size: 18),
label: const Text('直接存储', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'识别到 ${state.results.length} 条账单记录',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
),
const SizedBox(height: 10),
for (var i = 0; i < state.results.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: OcrResultCard(
index: i,
expanded: state.expandedIndexes.contains(i),
onToggle: () => notifier.toggleExpanded(i),
),
),
],
const SizedBox(height: 90), // 底部导航栏避让
],
);
}
void _showSourceSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍照'),
onTap: () {
Navigator.pop(sheetCtx);
_chooseImage(context, ref, ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () {
Navigator.pop(sheetCtx);
_chooseImage(context, ref, ImageSource.gallery);
},
),
],
),
),
);
}
}
+358
View File
@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../providers/bill_edit_provider.dart';
import '../../providers/category_provider.dart';
import '../../utils/amount_input_formatter.dart';
import '../../utils/date_util.dart';
import '../../utils/toast.dart';
import '../../widgets/category_picker_grid.dart';
import '../../widgets/state_views.dart';
/// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除)
class BillEditPage extends ConsumerStatefulWidget {
const BillEditPage({super.key, required this.billId});
final int billId;
@override
ConsumerState<BillEditPage> createState() => _BillEditPageState();
}
class _BillEditPageState extends ConsumerState<BillEditPage> {
final _amountController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_amountController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(billEditProvider(widget.billId));
final notifier = ref.read(billEditProvider(widget.billId).notifier);
// 服务端数据回填时同步非受控输入框(与记账页同一套 revision 机制)
ref.listen<int>(
billEditProvider(widget.billId).select((s) => s.revision),
(prev, next) {
if (prev == next) return;
final s = ref.read(billEditProvider(widget.billId));
_amountController.text = s.amount?.toStringAsFixed(2) ?? '';
_descriptionController.text = s.description;
},
);
return Scaffold(
appBar: AppBar(title: const Text('编辑账单')),
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: _body(state, notifier),
),
);
}
Widget _body(BillEditState state, BillEditNotifier notifier) {
if (state.loading) {
return const Center(child: LoadingView(tip: '账单加载中...'));
}
if (state.loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ErrorView(message: state.loadError!, onRetry: notifier.reload),
),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
_sectionLabel('账单类型'),
_typeSelector(state, notifier),
const SizedBox(height: 16),
_sectionLabel('分类'),
_categorySection(state, notifier),
const SizedBox(height: 16),
_sectionLabel('金额'),
_amountField(state, notifier),
const SizedBox(height: 16),
_sectionLabel('日期'),
_dateField(state, notifier),
const SizedBox(height: 16),
_sectionLabel('备注'),
_descriptionField(notifier),
const SizedBox(height: 28),
_footer(state, notifier),
const SizedBox(height: 40),
],
);
}
Widget _sectionLabel(String text) => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
);
// ---- 类型 ----
Widget _typeSelector(BillEditState state, BillEditNotifier notifier) {
return Row(
children: [
_typeCard('📤', '支出', 1, state, notifier),
const SizedBox(width: 10),
_typeCard('📥', '收入', 2, state, notifier),
],
);
}
Widget _typeCard(
String icon,
String label,
int type,
BillEditState state,
BillEditNotifier notifier,
) {
final active = state.type == type;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setType(type),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: active ? const Color(0xFFC8956E) : Colors.transparent,
width: 2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
],
),
),
),
);
}
// ---- 分类 ----
Widget _categorySection(BillEditState state, BillEditNotifier notifier) {
return Consumer(
builder: (context, ref, _) {
final categoriesAsync = ref.watch(categoriesProvider(state.type));
return categoriesAsync.when(
loading: () => const LoadingView(padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(message: asApiException(e).message),
data: (categories) => CategoryPickerGrid(
categories: categories,
selectedId: state.categoryId,
onSelect: notifier.setCategory,
crossAxisCount: 4,
),
);
},
);
}
// ---- 金额 ----
Widget _amountField(BillEditState state, BillEditNotifier notifier) {
return TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [AmountInputFormatter()],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入金额',
prefixText: '¥ ',
prefixStyle: const TextStyle(
fontSize: 18,
color: Color(0xFFC8956E),
fontWeight: FontWeight.bold,
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onChanged: (v) => notifier.setAmount(double.tryParse(v)),
);
}
// ---- 日期 ----
Widget _dateField(BillEditState state, BillEditNotifier notifier) {
return GestureDetector(
onTap: () async {
final initial = DateTime.tryParse(state.billDate) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) notifier.setDate(formatDate(picked));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Text(state.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
const Spacer(),
const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)),
],
),
),
);
}
// ---- 备注 ----
Widget _descriptionField(BillEditNotifier notifier) {
return TextField(
controller: _descriptionController,
maxLines: 3,
maxLength: 200,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: '请输入备注(可选)',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onChanged: notifier.setDescription,
);
}
// ---- 底部:删除 + 保存 ----
Widget _footer(BillEditState state, BillEditNotifier notifier) {
final busy = state.submitting;
return Row(
children: [
Expanded(
child: SizedBox(
height: 48,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFFD32F2F),
side: const BorderSide(color: Color(0xFFE0C9A6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: busy ? null : () => _confirmDelete(notifier),
child: const Text('删除', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: busy ? null : () => _save(notifier),
child: busy
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: const Text(
'保 存',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: 3),
),
),
),
),
],
);
}
Future<void> _save(BillEditNotifier notifier) async {
try {
await notifier.save();
if (!mounted) return;
showToast(context, '✅ 已保存');
Navigator.of(context).pop();
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '保存失败,请重试', error: true);
}
}
Future<void> _confirmDelete(BillEditNotifier notifier) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除这笔账单吗?删除后无法恢复'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))),
),
],
),
);
if (confirmed != true) return;
try {
await notifier.remove();
if (!mounted) return;
showToast(context, '已删除');
Navigator.of(context).pop();
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '删除失败,请重试', error: true);
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../models/bill.dart';
import '../../models/statistics_response.dart';
import '../../providers/bill_provider.dart';
import '../../providers/bill_refresh.dart';
import '../../providers/budget_provider.dart';
import '../../utils/amount_util.dart';
import '../../utils/bill_grouping.dart';
import '../../utils/date_util.dart';
import '../../widgets/state_views.dart';
import '../bill/bill_edit_page.dart';
import 'widgets/bill_group_card.dart';
import 'widgets/budget_card.dart';
import 'widgets/month_switcher.dart';
/// 首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final anchor = ref.watch(monthAnchorProvider);
final billsAsync = ref.watch(billsProvider(anchor));
final statsAsync = ref.watch(monthlyStatisticsProvider(anchor));
final budgetAsync = ref.watch(budgetProvider);
final isCurrentMonth = isSameMonth(anchor, DateTime.now());
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: RefreshIndicator(
color: const Color(0xFFC8956E),
onRefresh: () => _onRefresh(ref),
child: ListView(
// 内容不足一屏时也要能下拉刷新
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
children: [
_header(anchor),
const SizedBox(height: 14),
_summaryCard(anchor, statsAsync),
const SizedBox(height: 12),
// 后端预算接口只提供当月数据,历史月份不展示,避免串数据
if (isCurrentMonth)
BudgetCard(budget: budgetAsync)
else
_budgetUnavailableTip(),
const SizedBox(height: 16),
..._billSection(context, ref, billsAsync),
const SizedBox(height: 90), // 底部导航避让
],
),
),
),
),
);
}
// ---- 顶部标题 + 月份切换 ----
Widget _header(DateTime anchor) {
return Row(
children: [
const Text(
'我的账单',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const Spacer(),
const MonthSwitcher(),
],
);
}
// ---- 本月汇总 ----
Widget _summaryCard(DateTime anchor, AsyncValue<StatisticsResponse> statsAsync) {
final stats = statsAsync.value;
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
_summaryItem('${anchor.month}月收入', stats?.totalIncome, const Color(0xFF19BE6B)),
_vDivider(),
_summaryItem('${anchor.month}月支出', stats?.totalExpense, const Color(0xFFFA3534)),
_vDivider(),
_summaryItem('结余', stats?.balance, const Color(0xFF5D4037)),
],
),
);
}
Widget _summaryItem(String label, double? amount, Color color) {
return Expanded(
child: Column(
children: [
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
const SizedBox(height: 6),
Text(
// 统计未加载完时显示占位,避免整块转圈导致头部抖动
amount == null ? '--' : '¥${formatAmount(amount)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
);
}
Widget _vDivider() => Container(width: 1, height: 28, color: const Color(0xFFF0E6D8));
Widget _budgetUnavailableTip() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎯 ', style: TextStyle(fontSize: 14)),
Text(
'仅可查看本月预算',
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
),
],
),
);
}
// ---- 账单列表 ----
List<Widget> _billSection(
BuildContext context,
WidgetRef ref,
AsyncValue<List<Bill>> billsAsync,
) {
return [
billsAsync.when(
loading: () => const LoadingView(),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(billsProvider),
),
data: (bills) {
if (bills.isEmpty) {
return const EmptyView(
title: '本月暂无账单记录',
tip: '点击底部「记账」添加,或用 OCR 识别小票',
);
}
return Column(
children: groupBillsByDate(bills)
.map((group) => BillGroupCard(
group: group,
onTapBill: (bill) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => BillEditPage(billId: bill.id)),
),
))
.toList(),
);
},
),
];
}
// ---- 下拉刷新 ----
Future<void> _onRefresh(WidgetRef ref) async {
final anchor = ref.read(monthAnchorProvider);
invalidateBillDataFromWidget(ref);
// 等三个请求结束再收起下拉动画;异常吞掉,由各自的 AsyncValue 渲染错误态
await Future.wait<void>([
_silent(ref.read(billsProvider(anchor).future)),
_silent(ref.read(monthlyStatisticsProvider(anchor).future)),
_silent(ref.read(budgetProvider.future)),
]);
}
Future<void> _silent(Future<Object?> future) =>
future.then<void>((_) {}, onError: (_, _) {});
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../../models/bill.dart';
import '../../../utils/amount_util.dart';
import '../../../utils/bill_grouping.dart';
import '../../../utils/date_util.dart';
/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目
class BillGroupCard extends StatelessWidget {
const BillGroupCard({super.key, required this.group, required this.onTapBill});
final BillDayGroup group;
final ValueChanged<Bill> onTapBill;
static const _expenseColor = Color(0xFFFA3534);
static const _incomeColor = Color(0xFF19BE6B);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
child: Row(
children: [
Text(
formatGroupDate(group.date),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const Spacer(),
// 原版把当日收入与支出直接相加(无意义),这里分开展示
if (group.expenseTotal > 0)
Text(
'支 ¥${formatAmount(group.expenseTotal)}',
style: const TextStyle(fontSize: 12, color: _expenseColor),
),
if (group.expenseTotal > 0 && group.incomeTotal > 0)
const SizedBox(width: 10),
if (group.incomeTotal > 0)
Text(
'收 ¥${formatAmount(group.incomeTotal)}',
style: const TextStyle(fontSize: 12, color: _incomeColor),
),
],
),
),
const Divider(height: 1, color: Color(0xFFF5EEE4)),
for (var i = 0; i < group.bills.length; i++) ...[
_BillTile(bill: group.bills[i], onTap: () => onTapBill(group.bills[i])),
if (i != group.bills.length - 1)
const Divider(height: 1, indent: 56, color: Color(0xFFF5EEE4)),
],
],
),
);
}
}
class _BillTile extends StatelessWidget {
const _BillTile({required this.bill, required this.onTap});
final Bill bill;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// 金额正负与颜色按 type 判断(后端 amount 恒为正数,不能用金额正负判色)
final color = bill.isExpense ? BillGroupCard._expenseColor : BillGroupCard._incomeColor;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFBEEDD),
),
alignment: Alignment.center,
child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bill.categoryName,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
if (bill.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
bill.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
],
),
),
const SizedBox(width: 8),
Text(
formatSignedAmount(bill.absAmount, isExpense: bill.isExpense),
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
),
);
}
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/budget.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/donut_chart.dart';
import '../../../widgets/state_views.dart';
/// 本月预算卡:环形图 + 预算 / 剩余 / 日均可用
class BudgetCard extends StatelessWidget {
const BudgetCard({super.key, required this.budget});
final AsyncValue<BudgetInfo> budget;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: budget.when(
loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(message: '预算加载失败'),
data: (info) => info.hasBudget ? _content(info) : _noBudget(),
),
);
}
Widget _noBudget() {
return const EmptyView(
icon: '🎯',
title: '本月未设置预算',
tip: '可在「我的 → 预算设置」中设置',
padding: EdgeInsets.symmetric(vertical: 20),
);
}
Widget _content(BudgetInfo info) {
final over = info.isOverBudget;
final ringColor = over ? const Color(0xFFE53935) : const Color(0xFFC8956E);
final usedRatio = info.usedRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 12),
child: Text(
'本月预算',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
),
Row(
children: [
DonutChart(
size: 116,
centerSpaceRadius: 38,
slices: [
DonutSlice(value: usedRatio, color: ringColor),
DonutSlice(value: 1 - usedRatio, color: const Color(0xFFF0E6D8)),
],
center: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
over ? '超支' : '${(usedRatio * 100).round()}%',
style: TextStyle(
fontSize: over ? 14 : 17,
fontWeight: FontWeight.bold,
color: over ? const Color(0xFFE53935) : const Color(0xFF5D4037),
),
),
if (!over)
const Text('已用', style: TextStyle(fontSize: 10, color: Color(0xFF8D6E63))),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_amountRow('预算总额', '¥${formatAmount(info.amount)}', const Color(0xFF5D4037)),
const SizedBox(height: 8),
_amountRow('已用', '¥${formatAmount(info.usedAmount)}', ringColor),
const SizedBox(height: 8),
_amountRow(
'剩余',
'¥${formatAmount(info.remainingAmount)}',
info.remainingAmount < 0 ? const Color(0xFFE53935) : const Color(0xFF19BE6B),
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0x33C8956E), Color(0x33FFD700)],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(info.remainingDaily)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
],
);
}
Widget _amountRow(String label, String value, Color valueColor) {
return Row(
children: [
Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))),
const Spacer(),
Text(
value,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: valueColor),
),
],
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/bill_provider.dart';
import '../../../utils/date_util.dart';
/// 月份切换条:‹ 2026年9月 ›
class MonthSwitcher extends ConsumerWidget {
const MonthSwitcher({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final anchor = ref.watch(monthAnchorProvider);
final notifier = ref.read(monthAnchorProvider.notifier);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_iconButton(
icon: Icons.chevron_left,
tooltip: '上一月',
onTap: notifier.prev,
),
GestureDetector(
onTap: () => _pickMonth(context, ref),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatMonthTitle(anchor),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 18, color: Color(0xFF8D6E63)),
],
),
),
),
_iconButton(
icon: Icons.chevron_right,
tooltip: '下一月',
// 不允许超过当前月
onTap: notifier.canGoNext ? notifier.next : null,
),
],
),
);
}
Widget _iconButton({
required IconData icon,
required String tooltip,
VoidCallback? onTap,
}) {
final enabled = onTap != null;
return IconButton(
icon: Icon(icon),
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
color: enabled ? const Color(0xFF5D4037) : const Color(0xFFBCAAA4),
tooltip: tooltip,
onPressed: onTap,
);
}
Future<void> _pickMonth(BuildContext context, WidgetRef ref) async {
final anchor = ref.read(monthAnchorProvider);
final picked = await showDatePicker(
context: context,
initialDate: anchor,
initialDatePickerMode: DatePickerMode.year,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
ref.read(monthAnchorProvider.notifier).pick(picked);
}
}
}
+527
View File
@@ -0,0 +1,527 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../api/auth_api.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/local_storage.dart';
import '../../providers/auth_provider.dart';
import '../../utils/crypto.dart';
import '../../utils/toast.dart';
import '../shell/main_shell.dart';
/// 登录/注册页(复刻 uniApp pages/login/login.vue)
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage>
with SingleTickerProviderStateMixin {
bool _isRegister = false;
bool _showPassword = false;
bool _loading = false;
bool _agreeProtocol = false;
bool _rememberMe = false;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _nicknameController = TextEditingController();
final _formKey = GlobalKey<FormState>();
late final AnimationController _floatController = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat(reverse: true);
@override
void initState() {
super.initState();
final saved = LocalStorage.instance.getRememberedAccount();
if (saved != null) {
_usernameController.text = saved.username;
_passwordController.text = decryptPassword(saved.password);
_rememberMe = true;
}
}
@override
void dispose() {
_floatController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_nicknameController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_loading) return;
final username = _usernameController.text.trim();
final password = _passwordController.text;
if (username.isEmpty || password.isEmpty) {
showToast(context, '请输入用户名和密码', error: true);
return;
}
if (_isRegister) {
if (password.length < 6) {
showToast(context, '密码至少6位', error: true);
return;
}
if (!_agreeProtocol) {
showToast(context, '请先阅读并同意用户协议');
return;
}
}
setState(() => _loading = true);
try {
final api = ref.read(authApiProvider);
final response = _isRegister
? await api.register(
username,
password,
_nicknameController.text.trim().isEmpty
? username
: _nicknameController.text.trim(),
)
: await api.login(username, password);
// 记住密码
if (!_isRegister && _rememberMe) {
await LocalStorage.instance.saveRememberedPassword(
username,
encryptPassword(password),
);
} else if (!_isRegister && !_rememberMe) {
await LocalStorage.instance.clearRememberedPassword();
}
await ref.read(authProvider.notifier).loginSuccess(response);
if (!mounted) return;
showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功');
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const MainShell()),
(route) => false,
);
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '操作失败,请重试', error: true);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: Stack(
children: [
..._buildBackgroundBalls(),
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
const SizedBox(height: 32),
_buildFormCard(),
],
),
),
),
),
],
),
),
);
}
// ---- 背景装饰圆球 ----
List<Widget> _buildBackgroundBalls() {
final t = _floatController.value;
Offset offset(double phase) =>
Offset(20 * _wave(t + phase), 30 * _wave(t + phase + 0.25));
Widget ball({
required double size,
required List<Color> colors,
required Offset pos,
required double phase,
}) {
return Positioned(
left: pos.dx,
top: pos.dy,
child: Opacity(
opacity: 0.3,
child: AnimatedBuilder(
animation: _floatController,
builder: (context, _) => Transform.translate(
offset: offset(phase),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: colors),
),
),
),
),
),
);
}
return [
ball(
size: 280,
colors: const [Color(0xFFC8956E), Color(0xFFFFB6C1)],
pos: const Offset(-70, -70),
phase: 0,
),
ball(
size: 200,
colors: const [Color(0xFFF5D59E), Color(0xFFFFD700)],
pos: const Offset(-40, 180),
phase: 0.25,
),
ball(
size: 240,
colors: const [Color(0xFFFFB6C1), Color(0xFFC8956E)],
pos: const Offset(40, -180),
phase: 0.5,
),
ball(
size: 170,
colors: const [Color(0xFFFFD700), Color(0xFFF5D59E)],
pos: const Offset(240, 420),
phase: 0.75,
),
];
}
double _wave(double x) {
final v = (x % 1) * 2;
return v <= 1 ? v : 2 - v;
}
// ---- Logo + 标题 ----
Widget _buildHeader() {
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.all(10),
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_decoLine(),
const SizedBox(width: 10),
const Text(
'强宝爱记账',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
letterSpacing: 2,
shadows: [
Shadow(
color: Color(0x66FFD700),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
),
const SizedBox(width: 10),
_decoLine(),
],
),
const SizedBox(height: 6),
const Text(
'智能记账,轻松管理',
style: TextStyle(fontSize: 14, color: Color(0xFF8D6E63)),
),
],
);
}
Widget _decoLine() => Container(
width: 40,
height: 4,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFFFD700)],
),
),
);
// ---- 表单卡片(毛玻璃) ----
Widget _buildFormCard() {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: _glassFilter,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFC8956E), width: 1.5),
),
child: Form(
key: _formKey,
child: Column(
children: [
_buildSegmentTabs(),
const SizedBox(height: 20),
_inputField(
icon: '👤',
controller: _usernameController,
hint: '请输入用户名',
maxLength: 20,
),
const SizedBox(height: 14),
_inputField(
icon: '🔒',
controller: _passwordController,
hint: '请输入密码',
maxLength: 20,
obscure: !_showPassword,
suffix: IconButton(
icon: Text(
_showPassword ? '👁️' : '👁️‍🗨️',
style: const TextStyle(fontSize: 16),
),
onPressed: () =>
setState(() => _showPassword = !_showPassword),
),
),
if (_isRegister) ...[
const SizedBox(height: 14),
_inputField(
icon: '✨',
controller: _nicknameController,
hint: '请输入昵称(可选)',
maxLength: 20,
),
],
if (!_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _rememberMe,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _rememberMe = v ?? false),
),
),
const SizedBox(width: 6),
const Text(
'记住密码',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
],
),
],
if (_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _agreeProtocol,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _agreeProtocol = v ?? false),
),
),
const Expanded(
child: Text(
'我已阅读并同意用户协议',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(
_isRegister ? '注 册' : '登 录',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: 4,
),
),
),
),
],
),
),
),
),
);
}
static final ImageFilter _glassFilter = ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
);
// ---- 登录/注册切换 ----
Widget _buildSegmentTabs() {
return Container(
decoration: BoxDecoration(
color: const Color(0x33C8956E),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
_segment(
'登录',
!_isRegister,
() => setState(() => _isRegister = false),
),
_segment('注册', _isRegister, () => setState(() => _isRegister = true)),
],
),
);
}
Widget _segment(String text, bool active, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: active ? const Color(0xFFC8956E) : Colors.transparent,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: active ? Colors.white : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
Widget _inputField({
required String icon,
required TextEditingController controller,
required String hint,
int maxLength = 50,
bool obscure = false,
Widget? suffix,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE0C9A6)),
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
obscureText: obscure,
maxLength: maxLength,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
color: Color(0xFFBCAAA4),
fontSize: 14,
),
border: InputBorder.none,
counterText: '',
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
?suffix,
],
),
);
}
}
+524
View File
@@ -0,0 +1,524 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../core/network/auth_redirect.dart';
import '../../models/account.dart';
import '../../models/budget.dart';
import '../../providers/account_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/budget_provider.dart';
import '../../utils/amount_input_formatter.dart';
import '../../utils/amount_util.dart';
import '../../utils/toast.dart';
import '../../widgets/state_views.dart';
/// 「我的」页面
///
/// 账户余额可手动修改;总收入 / 总支出由账单汇总得出,只读;
/// 本月预算可设置与随时修改(按月生效)。
class MinePage extends ConsumerWidget {
const MinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authProvider).user;
final accountAsync = ref.watch(accountProvider);
final budgetAsync = ref.watch(budgetProvider);
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: RefreshIndicator(
color: const Color(0xFFC8956E),
onRefresh: () => _onRefresh(ref),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_userCard(user?.nickname ?? '用户', user?.username ?? '-'),
const SizedBox(height: 14),
_accountCard(context, ref, accountAsync),
const SizedBox(height: 14),
_budgetCard(context, ref, budgetAsync),
const SizedBox(height: 24),
_logoutButton(context),
const SizedBox(height: 90),
],
),
),
),
),
);
}
Future<void> _onRefresh(WidgetRef ref) async {
ref.invalidate(accountProvider);
ref.invalidate(budgetProvider);
await Future.wait<void>([
_silent(ref.read(accountProvider.future)),
_silent(ref.read(budgetProvider.future)),
]);
}
Future<void> _silent(Future<Object?> future) =>
future.then<void>((_) {}, onError: (_, _) {});
// ---------------- 用户信息 ----------------
Widget _userCard(String nickname, String username) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)],
),
border: Border.all(color: const Color(0xFF5D4037), width: 1.5),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.2),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
alignment: Alignment.center,
child: const Text('👤', style: TextStyle(fontSize: 30)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname.isEmpty ? '用户' : nickname,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
Text(
'账号:$username',
style: const TextStyle(fontSize: 13, color: Color(0xFF6D4C41)),
),
],
),
),
],
),
);
}
// ---------------- 账户卡 ----------------
Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue<AccountInfo> accountAsync) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: accountAsync.when(
loading: () => const LoadingView(tip: '账户加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(accountProvider),
),
data: (account) => _accountContent(context, ref, account),
),
);
}
Widget _accountContent(BuildContext context, WidgetRef ref, AccountInfo account) {
final negative = account.balance < 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'账户余额',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const Spacer(),
_editButton(
label: '修改',
onPressed: () => _editBalance(context, ref, account),
),
],
),
const SizedBox(height: 6),
Text(
'¥${formatAmount(account.balance)}',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: negative ? const Color(0xFFFA3534) : const Color(0xFF5D4037),
),
),
const SizedBox(height: 16),
const Divider(height: 1, color: Color(0xFFF5EEE4)),
const SizedBox(height: 14),
Row(
children: [
_readOnlyStat('总收入', account.totalIncome, const Color(0xFF19BE6B)),
Container(width: 1, height: 26, color: const Color(0xFFF0E6D8)),
_readOnlyStat('总支出', account.totalExpense, const Color(0xFFFA3534)),
],
),
],
);
}
/// 只读统计项:来自账单汇总,不可修改(用锁图标提示)
Widget _readOnlyStat(String label, double amount, Color color) {
return Expanded(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))),
const SizedBox(width: 4),
const Icon(Icons.lock_outline, size: 11, color: Color(0xFFBCAAA4)),
],
),
const SizedBox(height: 6),
Text(
'¥${formatAmount(amount)}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color),
),
],
),
);
}
// ---------------- 预算卡 ----------------
Widget _budgetCard(BuildContext context, WidgetRef ref, AsyncValue<BudgetInfo> budgetAsync) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: budgetAsync.when(
loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(budgetProvider),
),
data: (budget) => _budgetContent(context, ref, budget),
),
);
}
Widget _budgetContent(BuildContext context, WidgetRef ref, BudgetInfo budget) {
final hasBudget = budget.hasBudget;
final over = budget.isOverBudget;
final ratio = budget.usedRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'本月预算',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const SizedBox(width: 6),
if (!hasBudget)
const Text(
'(未设置)',
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
const Spacer(),
_editButton(
label: hasBudget ? '修改' : '设置',
onPressed: () => _editBudget(context, ref, budget),
),
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'¥${formatAmount(budget.amount)}',
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 8),
if (hasBudget)
Text(
over ? '已超支' : '每月生效,可随时修改',
style: TextStyle(
fontSize: 12,
color: over ? const Color(0xFFE53935) : const Color(0xFFBCAAA4),
),
),
],
),
if (hasBudget) ...[
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Stack(
children: [
Container(height: 8, color: const Color(0xFFF5EEE4)),
FractionallySizedBox(
widthFactor: ratio,
child: Container(
height: 8,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: over
? const [Color(0xFFE53935), Color(0xFFFF8A80)]
: const [Color(0xFFC8956E), Color(0xFFFFD700)],
),
),
),
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
'已用 ${(ratio * 100).round()}%',
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
),
const Spacer(),
Text(
'剩余 ¥${formatAmount(budget.remainingAmount)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: budget.remainingAmount < 0
? const Color(0xFFE53935)
: const Color(0xFF19BE6B),
),
),
],
),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0x33C8956E), Color(0x33FFD700)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
Text(
'¥${formatAmount(budget.remainingDaily)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
],
],
);
}
Widget _editButton({required String label, required VoidCallback onPressed}) {
return TextButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(label, style: const TextStyle(fontSize: 13)),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
);
}
// ---------------- 修改余额 ----------------
Future<void> _editBalance(BuildContext context, WidgetRef ref, AccountInfo account) async {
final input = await _showAmountDialog(
context: context,
title: '修改账户余额',
initialValue: account.balance,
hint: '请输入当前账户余额',
note: '修改后的余额将作为新的基准,之后记账会在其基础上增减。',
allowNegative: true, // 余额可能为负
);
if (input == null) return;
try {
await ref.read(accountProvider.notifier).setBalance(input);
if (context.mounted) showToast(context, '✅ 余额已更新');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '余额更新失败,请重试', error: true);
}
}
// ---------------- 修改预算 ----------------
Future<void> _editBudget(BuildContext context, WidgetRef ref, BudgetInfo budget) async {
final input = await _showAmountDialog(
context: context,
title: budget.hasBudget ? '修改本月预算' : '设置本月预算',
initialValue: budget.amount,
hint: '请输入预算金额',
note: '预算按月生效,可随时修改;本月账单支出会累计到「已用」。',
);
if (input == null) return;
try {
await ref.read(budgetProvider.notifier).setMonthBudget(input);
if (context.mounted) showToast(context, '✅ 预算已保存');
} on ApiException catch (e) {
if (context.mounted) showToast(context, e.message, error: true);
} catch (_) {
if (context.mounted) showToast(context, '预算保存失败,请重试', error: true);
}
}
/// 金额输入弹窗(修改余额 / 修改预算 共用)
///
/// [allowNegative] 仅余额编辑开启——余额可能为负,预算与记账金额不会。
Future<double?> _showAmountDialog({
required BuildContext context,
required String title,
required double initialValue,
required String hint,
required String note,
bool allowNegative = false,
}) async {
final controller = TextEditingController(text: initialValue.toStringAsFixed(2));
// 预填内容默认全选,输入即替换,省去手动清空
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
try {
return await showDialog<double>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
inputFormatters: [AmountInputFormatter(allowNegative: allowNegative)],
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
decoration: InputDecoration(
hintText: hint,
prefixText: '¥ ',
prefixStyle: const TextStyle(
fontSize: 18,
color: Color(0xFFC8956E),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(note, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, double.tryParse(controller.text)),
child: const Text('保存', style: TextStyle(color: Color(0xFFC8956E))),
),
],
),
);
} finally {
controller.dispose();
}
}
// ---------------- 退出登录 ----------------
Widget _logoutButton(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFFD32F2F),
side: const BorderSide(color: Color(0xFFE0C9A6)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: () => _confirmLogout(context),
child: const Text('退出登录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
);
}
Future<void> _confirmLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('提示'),
content: const Text('确定要退出登录吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('确定退出', style: TextStyle(color: Color(0xFFD32F2F))),
),
],
),
);
if (confirmed != true) return;
await logoutAndGoLogin();
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../add/add_page.dart';
import '../home/home_page.dart';
import '../login/login_page.dart';
import '../mine/mine_page.dart';
import '../statistics/statistics_page.dart';
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@override
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell> {
int _index = 0; // 默认选中「首页」
@override
void initState() {
super.initState();
// 兜底:未登录直接回登录页
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!ref.read(authProvider).isLoggedIn) {
_goLogin();
}
});
}
void _goLogin() {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
@override
Widget build(BuildContext context) {
// IndexedStack 保证四个页面常驻:写操作后首页/统计的数据失效能立即重取,
// 用户切回时看到的就是最新数据。
const pages = [
HomePage(),
AddPage(),
StatisticsPage(),
MinePage(),
];
return Scaffold(
body: IndexedStack(index: _index, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
backgroundColor: Colors.white,
indicatorColor: const Color(0x33C8956E),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: '首页'),
NavigationDestination(icon: Icon(Icons.add_circle_outline), selectedIcon: Icon(Icons.add_circle), label: '记账'),
NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: '统计'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'),
],
),
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../models/statistics_response.dart';
import '../../providers/statistics_provider.dart';
import '../../utils/amount_util.dart';
import '../../widgets/state_views.dart';
import 'widgets/category_pie_card.dart';
import 'widgets/category_rank_list.dart';
/// 统计页:按日/周/月切换 + 汇总 + 分类占比饼图 + 分类明细
class StatisticsPage extends ConsumerWidget {
const StatisticsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tab = ref.watch(statisticsTabProvider);
final statsAsync = ref.watch(statisticsProvider(tab));
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
children: [
const Text(
'统计分析',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 14),
_tabBar(tab, ref),
const SizedBox(height: 14),
_content(ref, statsAsync),
const SizedBox(height: 90), // 底部导航避让
],
),
),
),
);
}
// ---- 时间粒度切换 ----
Widget _tabBar(StatisticsTab current, WidgetRef ref) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFC8956E)),
),
child: Row(
children: [
_tabItem('按日', StatisticsTab.daily, current, ref),
_tabItem('按周', StatisticsTab.weekly, current, ref),
_tabItem('按月', StatisticsTab.monthly, current, ref),
],
),
);
}
Widget _tabItem(String text, StatisticsTab tab, StatisticsTab current, WidgetRef ref) {
final active = current == tab;
return Expanded(
child: GestureDetector(
onTap: () => ref.read(statisticsTabProvider.notifier).set(tab),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: active
? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)])
: null,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
// ---- 内容区 ----
Widget _content(WidgetRef ref, AsyncValue<StatisticsResponse> statsAsync) {
return statsAsync.when(
loading: () => const LoadingView(),
error: (e, _) => ErrorView(
message: asApiException(e).message,
onRetry: () => ref.invalidate(statisticsProvider),
),
data: (stats) => Column(
children: [
_summaryCard(stats),
const SizedBox(height: 12),
if (stats.categoryStatistics.isEmpty)
const EmptyView(title: '暂无统计数据', tip: '所选时间范围内还没有账单')
else ...[
CategoryPieCard(stats: stats),
const SizedBox(height: 12),
CategoryRankList(stats: stats),
],
],
),
);
}
// ---- 汇总卡 ----
Widget _summaryCard(StatisticsResponse stats) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.18),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
_summaryItem('总收入', stats.totalIncome, const Color(0xFF1B5E20)),
_vDivider(),
_summaryItem('总支出', stats.totalExpense, const Color(0xFFB71C1C)),
_vDivider(),
_summaryItem('余额', stats.balance, const Color(0xFF3E2723)),
],
),
);
}
Widget _summaryItem(String label, double amount, Color color) {
return Expanded(
child: Column(
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: Color(0xFF4E342E)),
),
const SizedBox(height: 6),
Text(
'¥${formatAmount(amount)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color),
),
],
),
);
}
Widget _vDivider() =>
Container(width: 1, height: 26, color: const Color(0xFF5D4037).withValues(alpha: 0.25));
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../models/statistics_response.dart';
import '../../../utils/amount_util.dart';
import '../../../widgets/donut_chart.dart';
/// 支出分类占比环形图卡
class CategoryPieCard extends StatelessWidget {
const CategoryPieCard({super.key, required this.stats});
final StatisticsResponse stats;
@override
Widget build(BuildContext context) {
// 只画支出分类(收入分类在下方排行列表里看)
final items = stats.categoryStatistics
.where((e) => e.isExpense && e.amount > 0)
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
if (items.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'支出分类占比',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
DonutChart(
size: 150,
centerSpaceRadius: 48,
slices: items
.asMap()
.entries
.map((e) => DonutSlice(
value: e.value.amount,
color: chartColorAt(e.key),
))
.toList(),
center: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('支出', style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
Text(
'¥${formatAmount(stats.totalExpense)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
const SizedBox(width: 16),
// 图例最多显示前 6 项,避免色块与名称对不上号
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items
.take(6)
.toList()
.asMap()
.entries
.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: chartColorAt(e.key),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
e.value.categoryName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8D6E63),
),
),
),
Text(
'${percentOf(e.value.amount, stats.totalExpense).toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
))
.toList(),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../../../models/statistics_response.dart';
import '../../../utils/amount_util.dart';
/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比
class CategoryRankList extends StatelessWidget {
const CategoryRankList({super.key, required this.stats});
final StatisticsResponse stats;
@override
Widget build(BuildContext context) {
final items = [...stats.categoryStatistics]
..sort((a, b) => b.amount.compareTo(a.amount));
if (items.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'分类明细',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)),
),
const SizedBox(height: 12),
...items.map(_row),
],
),
);
}
Widget _row(CategoryStat item) {
// 分母按行内类型取:支出项用总支出、收入项用总收入
// (uniApp 原版一律用总支出做分母,导致收入项占比显示成百分比 > 100%)
final total = item.isExpense ? stats.totalExpense : stats.totalIncome;
final pct = percentOf(item.amount, total);
final barColor = item.isExpense ? const Color(0xFFC8956E) : const Color(0xFF19BE6B);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.categoryIcon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Text(
item.categoryName,
style: const TextStyle(fontSize: 13.5, color: Color(0xFF5D4037)),
),
),
Text(
'¥${formatAmount(item.amount)}',
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 8),
SizedBox(
width: 46,
child: Text(
'${pct.toStringAsFixed(1)}%',
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 6, color: const Color(0xFFF5EEE4)),
FractionallySizedBox(
widthFactor: (pct / 100).clamp(0.0, 1.0),
child: Container(
height: 6,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [barColor, barColor.withValues(alpha: 0.55)],
),
),
),
),
],
),
),
],
),
);
}
}
+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);
}
});
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/services.dart';
/// 金额输入过滤:仅数字 + 单小数点 + 最多 2 位小数
///
/// [allowNegative] 为 true 时允许开头的负号(账户余额可能为负)。
/// 记账金额、预算不使用负号,保持默认 false。
class AmountInputFormatter extends TextInputFormatter {
AmountInputFormatter({this.allowNegative = false});
final bool allowNegative;
static final _positiveRegex = RegExp(r'^\d*\.?\d{0,2}$');
static final _negativeRegex = RegExp(r'^-?\d*\.?\d{0,2}$');
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
final regex = allowNegative ? _negativeRegex : _positiveRegex;
if (!regex.hasMatch(text)) return oldValue;
return newValue;
}
}
+27
View File
@@ -0,0 +1,27 @@
/// 金额展示与计算工具
///
/// 统一入口的意义:后端返回 double,可能出现 null / NaN / Infinity,
/// 直接 toStringAsFixed 会抛异常或显示 "NaN",所以全部走这里兜底。
library;
/// 金额展示:固定两位小数,非法值回退 '0.00'
String formatAmount(double? value) {
if (value == null || !value.isFinite) return '0.00';
return value.toStringAsFixed(2);
}
/// 带符号金额:支出 '-¥12.00',收入 '+¥12.00'
///
/// 是否支出必须由调用方显式传入(用 bill.type 判断),
/// 不要用金额正负判断——后端返回的 amount 恒为正数。
String formatSignedAmount(double? amount, {required bool isExpense}) {
final text = formatAmount(amount?.abs());
return isExpense ? '-¥$text' : '+¥$text';
}
/// 占比(0~100)。分母 <= 0 或非法时返回 0,避免除零与 NaN
double percentOf(double part, double total) {
if (!part.isFinite || !total.isFinite || total <= 0) return 0;
final pct = part / total * 100;
return pct.clamp(0, 100);
}
+45
View File
@@ -0,0 +1,45 @@
import '../models/bill.dart';
/// 按日期分组的账单
class BillDayGroup {
final String date; // yyyy-MM-dd
final List<Bill> bills;
final double expenseTotal; // type==1 的合计
final double incomeTotal; // type==2 的合计
const BillDayGroup({
required this.date,
required this.bills,
required this.expenseTotal,
required this.incomeTotal,
});
}
/// 按 billDate 分组,并分别累计当日支出/收入。
///
/// 后端已按 `billDate DESC, createTime DESC` 排序,Dart 的 Map 保持插入顺序,
/// 因此直接顺序遍历累积即可,不要再次排序(否则会打乱同日内的时间顺序)。
List<BillDayGroup> groupBillsByDate(List<Bill> bills) {
final grouped = <String, List<Bill>>{};
for (final bill in bills) {
grouped.putIfAbsent(bill.billDate, () => []).add(bill);
}
return grouped.entries.map((entry) {
var expense = 0.0;
var income = 0.0;
for (final bill in entry.value) {
if (bill.isExpense) {
expense += bill.absAmount;
} else {
income += bill.absAmount;
}
}
return BillDayGroup(
date: entry.key,
bills: entry.value,
expenseTotal: expense,
incomeTotal: income,
);
}).toList();
}
+29
View File
@@ -0,0 +1,29 @@
import 'dart:convert';
/// 与 uniApp 端 utils/crypto.js 完全一致的密码混淆算法(XOR + Base64)。
/// 注意:仅防明文存储,非强加密。
const String _secretKey = 'QIANGBAO_AI_JIZHANG_2024';
String encryptPassword(String password) {
if (password.isEmpty) return '';
final chars = password.codeUnits;
final keyUnits = _secretKey.codeUnits;
final encrypted = String.fromCharCodes([
for (var i = 0; i < chars.length; i++) chars[i] ^ keyUnits[i % keyUnits.length],
]);
return base64Encode(utf8.encode(encrypted));
}
String decryptPassword(String encrypted) {
if (encrypted.isEmpty) return '';
try {
final decoded = utf8.decode(base64Decode(encrypted));
final chars = decoded.codeUnits;
final keyUnits = _secretKey.codeUnits;
return String.fromCharCodes([
for (var i = 0; i < chars.length; i++) chars[i] ^ keyUnits[i % keyUnits.length],
]);
} catch (_) {
return '';
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:intl/intl.dart';
/// billDate 标准格式 yyyy-MM-dd
String formatDate(DateTime date) => DateFormat('yyyy-MM-dd').format(date);
/// 解析后端日期字段,兼容 yyyy-MM-dd 与 yyyy-MM-ddTHH:mm:ss(LocalDateTime)两种格式
String parseBillDate(String? raw) {
if (raw == null || raw.isEmpty) return formatDate(DateTime.now());
final tIndex = raw.indexOf('T');
if (tIndex > 0) return raw.substring(0, tIndex);
final spaceIndex = raw.indexOf(' ');
if (spaceIndex > 0) return raw.substring(0, spaceIndex);
return raw;
}
/// 解析后端日期为 DateTime,失败返回 null
DateTime? tryParseDate(String? raw) {
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
// ---------------- 月份 / 周 相关(首页、统计页用) ----------------
/// 当月 1 日 00:00,作为月份锚点使用
DateTime monthStart(DateTime date) => DateTime(date.year, date.month, 1);
/// 当月最后一天 00:00。
/// 用 day = 0 让 DateTime 自动回退到上个月最后一天,避免手算 28/30/31。
DateTime monthEnd(DateTime date) => DateTime(date.year, date.month + 1, 0);
/// 月份平移(delta 为月数,可为负),跨年由 DateTime 自动归一
DateTime shiftMonth(DateTime month, int delta) =>
DateTime(month.year, month.month + delta, 1);
/// 本周一 00:00(以周一为一周起点,对齐 uniApp 的 getWeekStart)
DateTime weekStart(DateTime date) {
final d = DateTime(date.year, date.month, date.day);
return d.subtract(Duration(days: d.weekday - DateTime.monday));
}
/// 是否为同一个自然月
bool isSameMonth(DateTime a, DateTime b) => a.year == b.year && a.month == b.month;
/// 月份标题:2026年9月
String formatMonthTitle(DateTime month) => '${month.year}年${month.month}月';
const List<String> _weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
/// 分组头日期:今天 / 昨天 / 09-21 周一
///
/// 注意:不要用 DateFormat('EEEE', 'zh_CN')——本项目未调用
/// initializeDateFormatting,会抛 LocaleDataException,所以手写星期名。
String formatGroupDate(String billDate) {
final parsed = tryParseDate(billDate);
if (parsed == null) return billDate;
final today = DateTime.now();
final todayStart = DateTime(today.year, today.month, today.day);
final target = DateTime(parsed.year, parsed.month, parsed.day);
final diff = todayStart.difference(target).inDays;
if (diff == 0) return '今天';
if (diff == 1) return '昨天';
final mm = target.month.toString().padLeft(2, '0');
final dd = target.day.toString().padLeft(2, '0');
return '$mm-$dd ${_weekdayNames[target.weekday - 1]}';
}
+25
View File
@@ -0,0 +1,25 @@
import 'dart:convert';
/// 本地解析 JWT 的 exp 字段,判断是否已过期。
///
/// 目的:App 启动时立刻发现"登录已过期",不必先发一次请求才报错。
/// 注意:只做本地时间判断,不校验签名——签名的真伪由后端负责。
/// 返回 false(视为未过期)的条件:token 不是标准 JWT 格式或没有 exp 字段,
/// 避免因格式差异把正常登录态误判为过期。
bool isJwtExpired(String? token, {Duration leeway = const Duration(seconds: 30)}) {
if (token == null || token.isEmpty) return true;
final parts = token.split('.');
if (parts.length != 3) return false;
try {
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final claims = jsonDecode(payload);
if (claims is! Map) return false;
final exp = claims['exp'];
if (exp is! num) return false; // 无 exp 字段 → 不做本地判断
final expireAt = DateTime.fromMillisecondsSinceEpoch(exp.toInt() * 1000);
return expireAt.isBefore(DateTime.now().add(leeway));
} catch (_) {
return false;
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import '../core/navigation/navigator_key.dart';
/// 统一提示封装(对齐 uni.showToast 的常用场景)
void showToast(BuildContext context, String message, {bool error = false}) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: error ? const Color(0xFFB71C1C) : const Color(0xFF323232),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
/// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider)
void showGlobalToast(String message, {bool error = false}) {
final context = navigatorKey.currentContext;
if (context != null) showToast(context, message, error: error);
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../models/category.dart';
/// 分类 emoji 宫格(手动记账页与账单编辑页共用)
///
/// 这是纯同步组件:分类列表的加载/错误/空态由调用方用 AsyncValue.when 处理,
/// 组件本身不关心 type 之类的业务参数。
class CategoryPickerGrid extends StatelessWidget {
const CategoryPickerGrid({
super.key,
required this.categories,
required this.selectedId,
required this.onSelect,
this.crossAxisCount = 5,
this.emptyText = '暂无分类',
});
final List<Category> categories;
final int? selectedId;
final ValueChanged<int> onSelect;
final int crossAxisCount;
final String emptyText;
@override
Widget build(BuildContext context) {
if (categories.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(14),
),
child: Text(emptyText, style: const TextStyle(color: Color(0xFF8D6E63))),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 10,
crossAxisSpacing: 6,
childAspectRatio: 0.85,
),
itemCount: categories.length,
itemBuilder: (context, i) => _item(categories[i]),
);
}
Widget _item(Category category) {
final active = selectedId == category.id;
return GestureDetector(
onTap: () => onSelect(category.id),
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? const Color(0xFFC8956E) : Colors.white,
border: Border.all(
color: active ? const Color(0xFF5D4037) : const Color(0xFFE0C9A6),
),
),
alignment: Alignment.center,
child: Text(category.icon, style: const TextStyle(fontSize: 24)),
),
const SizedBox(height: 4),
Text(
category.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63),
fontWeight: active ? FontWeight.bold : FontWeight.normal,
),
),
],
),
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
/// 一个扇区
class DonutSlice {
final double value;
final Color color;
final String label;
const DonutSlice({required this.value, required this.color, this.label = ''});
}
/// 图表配色:以主题橙棕/金色为主,穿插少量对比色便于区分分类
const List<Color> kChartPalette = [
Color(0xFFC8956E), // 橙棕(主色)
Color(0xFFFFB74D), // 亮橙
Color(0xFFE57373), // 红
Color(0xFF81C784), // 绿
Color(0xFF64B5F6), // 蓝
Color(0xFFBA68C8), // 紫
Color(0xFF4DB6AC), // 青
Color(0xFFF06292), // 粉
Color(0xFFA1887F), // 棕灰
Color(0xFF90A4AE), // 蓝灰
];
Color chartColorAt(int index) => kChartPalette[index % kChartPalette.length];
/// 通用环形图(首页预算环 + 统计页分类饼图共用)
///
/// 内部处理了 fl_chart 的空数据问题:`PieChartSectionData.value` 必须 > 0,
/// 全 0 / NaN 会算出 NaN 角度导致渲染异常甚至崩溃,所以这里统一过滤并兜底成灰环。
/// fl_chart 类型只出现在本文件,升级图表库时只需改这一处。
class DonutChart extends StatelessWidget {
const DonutChart({
super.key,
required this.slices,
this.size = 150,
this.centerSpaceRadius = 46,
this.sectionsSpace = 2,
this.center,
});
final List<DonutSlice> slices;
final double size;
final double centerSpaceRadius;
final double sectionsSpace;
/// 圆心内容,例如 Text('68%')
final Widget? center;
@override
Widget build(BuildContext context) {
final valid = slices.where((s) => s.value.isFinite && s.value > 0).toList();
// 无有效数据时退化为单段灰环,避免空数据渲染问题
final effective = valid.isEmpty
? const [DonutSlice(value: 1, color: Color(0xFFE0C9A6))]
: valid;
final ringRadius = (size / 2 - centerSpaceRadius).clamp(8.0, double.infinity);
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
PieChart(
PieChartData(
sections: effective
.map((s) => PieChartSectionData(
value: s.value,
color: s.color,
radius: ringRadius,
showTitle: false,
))
.toList(),
centerSpaceRadius: centerSpaceRadius,
sectionsSpace: sectionsSpace,
),
),
?center,
],
),
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
/// 加载态
class LoadingView extends StatelessWidget {
const LoadingView({super.key, this.tip = '加载中...', this.padding = const EdgeInsets.all(40)});
final String tip;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Column(
children: [
const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Color(0xFFC8956E)),
),
const SizedBox(height: 12),
Text(tip, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
],
),
);
}
}
/// 错误态(可带重试)
class ErrorView extends StatelessWidget {
const ErrorView({super.key, required this.message, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: [
const Text('😵', style: TextStyle(fontSize: 30)),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
),
if (onRetry != null) ...[
const SizedBox(height: 12),
OutlinedButton(
onPressed: onRetry,
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
side: const BorderSide(color: Color(0xFFC8956E)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
),
child: const Text('重试'),
),
],
],
),
);
}
}
/// 空状态
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.icon = '📭',
required this.title,
this.tip,
this.padding = const EdgeInsets.symmetric(vertical: 40),
});
final String icon;
final String title;
final String? tip;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Column(
children: [
Text(icon, style: const TextStyle(fontSize: 44)),
const SizedBox(height: 10),
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
if (tip != null) ...[
const SizedBox(height: 6),
Text(
tip!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12.5, color: Color(0xFFBCAAA4)),
),
],
],
),
);
}
}