diff --git a/docs/FEATURE_TX_FILTERS_GROUPING.md b/docs/FEATURE_TX_FILTERS_GROUPING.md new file mode 100644 index 00000000..765e9329 --- /dev/null +++ b/docs/FEATURE_TX_FILTERS_GROUPING.md @@ -0,0 +1,78 @@ +# Transactions Filters & Grouping — Phase B Design (草案) + +Purpose +- Deliver practical, performant filtering + grouping for transactions. +- Keep UI declarative, leverage existing providers; avoid duplicating domain rules. + +Scope +- Filters: text (描述/备注/收款方), 日期范围, 类型(支出/收入/转账), 账户, 分类, 标签, 金额区间。 +- Grouping: 按 日期 / 分类 / 账户;每组显示小计,总计在页眉/页脚。 +- Persist: 轻量本地记忆最近一次筛选与分组(per-ledger)。 +- Non-goals: 复杂多级排序、跨家庭聚合、导出(独立PR)。 + +UX Outline +- TransactionsScreen 顶部显示 FilterBar(收起/展开)。 +- FilterBar: + - 搜索框(回车触发)、“筛选”按钮打开面板(日期/类型/账户/分类/标签/金额)、“重置”按钮。 + - 右侧“分组切换”:日期/分类/账户;在移动端为 Segmented 形式。 +- 列表: + - 分组头包含:组名 + 小计金额(正/负色),可折叠。 + - 空状态支持“清空筛选”。 + +State & Providers +- transactionControllerProvider(现有): + - 扩展:`applyFilter(TransactionQuery q)`、`clearFilter()`、`setGrouping(Grouping g)`、`toggleGroupCollapse(key)`。 + - 状态新增:`currentQuery`、`grouping`、`groupCollapse: Set`。 +- Query 模型(新): + ```dart + class TransactionQuery { + final String? text; + final DateTimeRange? dateRange; + final Set? types; + final Set? accountIds; + final Set? categoryIds; + final Set? tagIds; + final double? amountMin; + final double? amountMax; + const TransactionQuery({ ... }); + TransactionQuery copyWith(...); + bool get isEmpty; + } + ``` +- Grouping(新枚举):`date | category | account`。 +- 组合选择器:账户/分类/标签用现有 providers 源数据,支持多选(Chip/BottomSheet)。 + +Data Flow +- UI → controller.applyFilter(query) → 计算/筛选 in-memory(Phase B) +- 未来 Phase C:若列表很大,落地到服务端 query(分页 + 去抖)。 + +API/Backend Impact +- Phase B:无服务端改动。 +- Phase C(另案):增量新增 `/transactions/search` 支持字段与分页;Rust 侧生成 SQLx 查询 + .sqlx 更新。 + +Persistence +- SharedPreferences key: `tx_ui__{query,grouping}`; +- 在 initState 读取并应用。 + +Accessibility & i18n +- 分组头可聚焦;筛选面板控件提供语义标签;所有文案纳入 i18n 字典(后续批量替换)。 + +Performance +- 过滤与分组在内存进行: + - 先按日期预分桶(Map)复用; + - 计算小计 O(n); + - 大列表按需构建(ListView.builder)。 + +Acceptance Criteria +- 可对任意组合条件过滤,切换分组视图,显示正确小计与总计。 +- 刷新后保留上次筛选/分组。 +- analyzer 0 hard errors;tests 通过。 + +Phasing +- B1:完成 Query 模型 + 控制器 + UI 面板(不含服务端)。 +- B2:分组小计 + 折叠 + 持久化。 +- B3:微交互与过渡动画;边界测试。 + +Risks / Out of Scope +- 非线性金额转换/多货币换算(另案)。 +- 高维度组合过滤在低端机的性能(必要时分页)。 diff --git a/jive-flutter/lib/providers/transaction_provider.dart b/jive-flutter/lib/providers/transaction_provider.dart index 6e0bb9f7..cb8409ab 100644 --- a/jive-flutter/lib/providers/transaction_provider.dart +++ b/jive-flutter/lib/providers/transaction_provider.dart @@ -4,11 +4,16 @@ import 'package:jive_money/services/api/transaction_service.dart'; import 'package:jive_money/models/transaction.dart'; import 'package:jive_money/models/transaction_filter.dart'; import 'package:shared_preferences/shared_preferences.dart'; +<<<<<<< HEAD +======= import 'package:jive_money/providers/ledger_provider.dart'; enum TransactionGrouping { date, category, account } +>>>>>>> origin/main /// 交易状态 +enum TransactionGrouping { date, category, account } + class TransactionState { final List transactions; final List filteredTransactions; @@ -18,6 +23,10 @@ class TransactionState { final int totalCount; final double totalIncome; final double totalExpense; +<<<<<<< HEAD + // Phase B scaffolding: grouping + collapsed groups +======= +>>>>>>> origin/main final TransactionGrouping grouping; final Set groupCollapse; @@ -302,6 +311,65 @@ class TransactionController extends StateNotifier { state = state.copyWith(error: null); } + /// 设置分组方式(Phase B) + void setGrouping(TransactionGrouping grouping) { + if (state.grouping == grouping) return; + state = state.copyWith(grouping: grouping); + _persistGrouping(); + } + + /// 切换分组折叠状态(Phase B) + void toggleGroupCollapse(String key) { + final collapsed = Set.from(state.groupCollapse); + if (collapsed.contains(key)) { + collapsed.remove(key); + } else { + collapsed.add(key); + } + state = state.copyWith(groupCollapse: collapsed); + _persistGroupCollapse(collapsed); + } + + // ---- View preference persistence (Phase B1) ---- + Future _loadViewPrefs() async { + try { + final prefs = await SharedPreferences.getInstance(); + final groupingStr = prefs.getString("tx_grouping"); + TransactionGrouping grouping = state.grouping; + if (groupingStr != null) { + grouping = TransactionGrouping.values.firstWhere( + (g) => g.name == groupingStr, + orElse: () => TransactionGrouping.date, + ); + } + final collapsedList = + prefs.getStringList("tx_group_collapse") ?? const []; + if (grouping != state.grouping || + collapsedList.length != state.groupCollapse.length) { + state = state.copyWith( + grouping: grouping, + groupCollapse: collapsedList.toSet(), + ); + } + } catch (_) { + // Ignore persistence errors + } + } + + Future _persistGrouping() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString("tx_grouping", state.grouping.name); + } catch (_) {} + } + + Future _persistGroupCollapse(Set collapsed) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList("tx_group_collapse", collapsed.toList()); + } catch (_) {} + } + /// 更新状态并计算统计数据 void _updateState(List transactions) { final filteredTransactions = state.filter != null diff --git a/jive-flutter/lib/screens/dashboard/dashboard_screen.dart b/jive-flutter/lib/screens/dashboard/dashboard_screen.dart index 6e3455b4..e46baa29 100644 --- a/jive-flutter/lib/screens/dashboard/dashboard_screen.dart +++ b/jive-flutter/lib/screens/dashboard/dashboard_screen.dart @@ -86,7 +86,10 @@ class DashboardScreen extends ConsumerWidget { RecentTransactions( transactions: recentTransactions, onViewAll: () { - // 导航到交易页面 + context.go(AppRoutes.transactions); + }, + onFilter: () { + context.go(AppRoutes.transactions); }, ), const SizedBox(height: 24), diff --git a/jive-flutter/lib/screens/transactions/transactions_screen.dart b/jive-flutter/lib/screens/transactions/transactions_screen.dart index 281b86e6..b1f5f14c 100644 --- a/jive-flutter/lib/screens/transactions/transactions_screen.dart +++ b/jive-flutter/lib/screens/transactions/transactions_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:jive_money/core/router/app_router.dart'; import 'package:jive_money/providers/transaction_provider.dart'; -import 'package:jive_money/ui/components/transactions/transaction_list_item.dart'; +import 'package:jive_money/ui/components/transactions/transaction_list.dart'; import 'package:jive_money/models/transaction.dart'; class TransactionsScreen extends ConsumerStatefulWidget { @@ -35,6 +35,7 @@ class _TransactionsScreenState extends ConsumerState @override Widget build(BuildContext context) { final transactionState = ref.watch(transactionControllerProvider); + final groupByDate = transactionState.grouping == TransactionGrouping.date; return Scaffold( appBar: AppBar( @@ -49,6 +50,23 @@ class _TransactionsScreenState extends ConsumerState ], ), actions: [ + PopupMenuButton( + tooltip: "分组方式", + onSelected: (g) { + ref.read(transactionControllerProvider.notifier).setGrouping(g); + if (g != TransactionGrouping.date) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("分类/账户分组预览中,暂以平铺显示")), + ); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: TransactionGrouping.date, child: Text("按日期分组")), + PopupMenuItem(value: TransactionGrouping.category, child: Text("按分类分组(预览)")), + PopupMenuItem(value: TransactionGrouping.account, child: Text("按账户分组(预览)")), + ], + icon: const Icon(Icons.view_list), + ), IconButton( icon: const Icon(Icons.filter_list), onPressed: _showFilterDialog, @@ -131,22 +149,27 @@ class _TransactionsScreenState extends ConsumerState return _buildEmptyState(type); } - return RefreshIndicator( + return TransactionList( + transactions: filtered, + groupByDate: groupByDate, + showSearchBar: true, + onSearch: (q) => + ref.read(transactionControllerProvider.notifier).search(q), + onClearSearch: () => + ref.read(transactionControllerProvider.notifier).search(''), + onToggleGroup: () { + final next = groupByDate ? TransactionGrouping.account : TransactionGrouping.date; + ref.read(transactionControllerProvider.notifier).setGrouping(next); + if (next != TransactionGrouping.date) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("非日期分组预览中,暂以平铺显示")), + ); + } + }, onRefresh: () => ref.read(transactionControllerProvider.notifier).refresh(), - child: ListView.builder( - padding: const EdgeInsets.only(bottom: 80), - itemCount: filtered.length, - itemBuilder: (context, index) { - final transaction = filtered[index]; - return TransactionListItem( - transaction: transaction, - onTap: () { - context.go('${AppRoutes.transactions}/${transaction.id}'); - }, - ); - }, - ), + onTransactionTap: (t) => + context.go('${AppRoutes.transactions}/${t.id}'), ); } diff --git a/jive-flutter/lib/ui/components/dashboard/recent_transactions.dart b/jive-flutter/lib/ui/components/dashboard/recent_transactions.dart index 9195c36b..a78687d3 100644 --- a/jive-flutter/lib/ui/components/dashboard/recent_transactions.dart +++ b/jive-flutter/lib/ui/components/dashboard/recent_transactions.dart @@ -5,6 +5,7 @@ import 'package:jive_money/models/transaction.dart'; import 'package:jive_money/ui/components/cards/transaction_card.dart'; class RecentTransactions extends StatelessWidget { + final VoidCallback? onFilter; final List transactions; final String title; final VoidCallback? onViewAll; @@ -16,6 +17,7 @@ class RecentTransactions extends StatelessWidget { this.title = '最近交易', this.onViewAll, this.maxItems = 5, + this.onFilter, }); @override @@ -43,6 +45,12 @@ class RecentTransactions extends StatelessWidget { ), ), const Spacer(), + if (onFilter != null) + IconButton( + tooltip: "筛选", + icon: const Icon(Icons.filter_list), + onPressed: onFilter, + ), if (onViewAll != null) TextButton( onPressed: onViewAll, @@ -130,6 +138,7 @@ class GroupedRecentTransactions extends StatelessWidget { final List transactions; final String title; final VoidCallback? onViewAll; + final VoidCallback? onFilter; final int maxDays; const GroupedRecentTransactions({ @@ -137,6 +146,7 @@ class GroupedRecentTransactions extends StatelessWidget { required this.transactions, this.title = '最近交易', this.onViewAll, + this.onFilter, this.maxDays = 3, }); @@ -166,6 +176,12 @@ class GroupedRecentTransactions extends StatelessWidget { ), ), const Spacer(), + if (onFilter != null) + IconButton( + tooltip: "筛选", + icon: const Icon(Icons.filter_list), + onPressed: onFilter, + ), if (onViewAll != null) TextButton( onPressed: onViewAll, diff --git a/jive-flutter/test/transactions/transaction_controller_grouping_test.dart b/jive-flutter/test/transactions/transaction_controller_grouping_test.dart new file mode 100644 index 00000000..86b58f6c --- /dev/null +++ b/jive-flutter/test/transactions/transaction_controller_grouping_test.dart @@ -0,0 +1,79 @@ + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jive_money/providers/transaction_provider.dart'; +import 'package:jive_money/services/api/transaction_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Dummy service – will never be used because we override loadTransactions. +class _DummyTransactionService extends TransactionService {} + +/// Test controller that skips network loading on init. +class _TestTransactionController extends TransactionController { + _TestTransactionController() : super(_DummyTransactionService()); + + @override + Future loadTransactions() async { + // Immediately set an empty, non-loading state to avoid network calls. + state = state.copyWith( + transactions: const [], + filteredTransactions: const [], + isLoading: false, + error: null, + totalCount: 0, + totalIncome: 0.0, + totalExpense: 0.0, + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('TransactionController grouping & collapse persistence', () { + setUp(() async { + // Clear any previous mock values before each test. + SharedPreferences.setMockInitialValues({}); + }); + + test('setGrouping persists to SharedPreferences', () async { + final controller = _TestTransactionController(); + + // Default should be date + expect(controller.state.grouping, TransactionGrouping.date); + + controller.setGrouping(TransactionGrouping.category); + + // Allow async persistence to complete + await Future.delayed(const Duration(milliseconds: 10)); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('tx_grouping'), 'category'); + expect(controller.state.grouping, TransactionGrouping.category); + }); + + test('toggleGroupCollapse persists collapsed keys', () async { + final controller = _TestTransactionController(); + const key = 'category:未分类'; + + // Toggle on (collapse) + controller.toggleGroupCollapse(key); + await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.state.groupCollapse.contains(key), isTrue); + + var prefs = await SharedPreferences.getInstance(); + final stored1 = prefs.getStringList('tx_group_collapse') ?? []; + expect(stored1.contains(key), isTrue); + + // Toggle off (expand) + controller.toggleGroupCollapse(key); + await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.state.groupCollapse.contains(key), isFalse); + + prefs = await SharedPreferences.getInstance(); + final stored2 = prefs.getStringList('tx_group_collapse') ?? []; + expect(stored2.contains(key), isFalse); + }); + }); +}