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