From 72d7b41944e0839be803cf6896f6c29c65723377 Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 19:43:16 +0800 Subject: [PATCH 1/7] feat: gather daily widget activity --- lib/widget/widget_data_service.dart | 68 +++++++++++++++++++++++ test/widget/widget_data_service_test.dart | 54 ++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/lib/widget/widget_data_service.dart b/lib/widget/widget_data_service.dart index 93f0c82d6..170ca3129 100644 --- a/lib/widget/widget_data_service.dart +++ b/lib/widget/widget_data_service.dart @@ -21,6 +21,20 @@ class GlanceWidgetData { }); } +/// 桌面小组件近若干日的记账活动:每天至少保留一条(无交易日为 0 / false), +/// 供「消费节奏」与「记账连续蜂迹」共用。 +class DailyWidgetActivity { + final DateTime date; + final double expenseTotal; + final bool hasRecord; + + const DailyWidgetActivity({ + required this.date, + required this.expenseTotal, + required this.hasRecord, + }); +} + /// 净资产(netWorth)小组件的总览数据:已折算到主币种(见 /// [WidgetDataService.gatherNetWorthBreakdown])。 class NetWorthBreakdownData { @@ -167,6 +181,53 @@ class WidgetDataService { ); } + /// 取以 [now] 当天为终点的连续日活动。使用单个区间查询后在内存中按日 + /// 聚合,避免小组件渲染时按天发出 N 次数据库查询。 + /// + /// 与统计页口径一致,`excludeFromStats` 的交易完全忽略;其余任何类型的 + /// 交易都代表当天完成过记账,但只有支出计入 [expenseTotal]。 + static Future> gatherDailyActivity({ + required BaseRepository repository, + required int ledgerId, + int days = 30, + DateTime? now, + }) async { + assert(days > 0); + final anchor = now ?? DateTime.now(); + final endDay = DateTime(anchor.year, anchor.month, anchor.day); + final startDay = endDay.subtract(Duration(days: days - 1)); + final endExclusive = endDay.add(const Duration(days: 1)); + final expenses = List.filled(days, 0); + final records = List.filled(days, false); + + final transactions = await repository.getTransactionsByLedgerInRange( + ledgerId: ledgerId, + start: startDay, + end: endExclusive, + ); + for (final transaction in transactions) { + if (transaction.excludeFromStats) continue; + final date = DateTime(transaction.happenedAt.year, + transaction.happenedAt.month, transaction.happenedAt.day); + final index = date.difference(startDay).inDays; + if (index < 0 || index >= days) continue; + records[index] = true; + if (transaction.type == 'expense') { + expenses[index] += transaction.amount; + } + } + + return List.generate( + days, + (index) => DailyWidgetActivity( + date: startDay.add(Duration(days: index)), + expenseTotal: expenses[index], + hasRecord: records[index], + ), + growable: false, + ); + } + // --------------------------------------------------------------------- // 净资产(netWorth) // --------------------------------------------------------------------- @@ -555,6 +616,13 @@ class WidgetGatherBatch { Future glance() => _glance ??= WidgetDataService.gatherGlance(repository: repository, ledgerId: ledgerId); + Future>? _dailyActivity30; + + /// 近 30 个自然日的记账/消费活动,两个行为型小组件共用同一份聚合结果。 + Future> dailyActivity30() => _dailyActivity30 ??= + WidgetDataService.gatherDailyActivity( + repository: repository, ledgerId: ledgerId); + Future? _netWorthBreakdown; Future netWorthBreakdown() => _netWorthBreakdown ??= WidgetDataService.gatherNetWorthBreakdown( diff --git a/test/widget/widget_data_service_test.dart b/test/widget/widget_data_service_test.dart index 3a51a5c50..dd9530b11 100644 --- a/test/widget/widget_data_service_test.dart +++ b/test/widget/widget_data_service_test.dart @@ -83,6 +83,60 @@ void main() { expect(data.monthIncomeTotal, 0); }); + group('gatherDailyActivity', () { + test('补齐日序列,仅累计支出,并排除不计统计的交易', () async { + await db.customStatement( + "INSERT INTO ledgers (id, name, currency) VALUES (301, 'L', 'CNY')"); + final now = DateTime(2026, 8, 30, 14); + final start = DateTime(2026, 8, 1); + + await repo.addTransaction( + ledgerId: 301, + type: 'expense', + amount: 12, + happenedAt: start.add(const Duration(days: 3, hours: 9))); + await repo.addTransaction( + ledgerId: 301, + type: 'income', + amount: 88, + happenedAt: start.add(const Duration(days: 3, hours: 18))); + await repo.addTransaction( + ledgerId: 301, + type: 'transfer', + amount: 50, + happenedAt: start.add(const Duration(days: 4))); + await repo.addTransaction( + ledgerId: 301, + type: 'expense', + amount: 99, + happenedAt: start.add(const Duration(days: 5)), + excludeFromStats: true); + await repo.addTransaction( + ledgerId: 301, + type: 'expense', + amount: 777, + happenedAt: start.subtract(const Duration(days: 1))); + + final days = await WidgetDataService.gatherDailyActivity( + repository: repo, + ledgerId: 301, + now: now, + ); + + expect(days, hasLength(30)); + expect(days.first.date, start); + expect(days.last.date, DateTime(2026, 8, 30)); + expect(days[3].expenseTotal, 12); + expect(days[3].hasRecord, isTrue); + expect(days[4].expenseTotal, 0); + expect(days[4].hasRecord, isTrue); + expect(days[5].expenseTotal, 0); + expect(days[5].hasRecord, isFalse); + expect(days[6].date, DateTime(2026, 8, 7)); + expect(days[6].hasRecord, isFalse); + }); + }); + group('gatherNetWorthBreakdown', () { test('单币种:折算口径与直接口径一致(rate=1.0)', () async { await repo.createAccount( From 48314753577ae4b1add9d286975a96e08cfaf3bc Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 19:47:18 +0800 Subject: [PATCH 2/7] feat: add rhythm and bee trail widget views --- lib/widget/views/bee_trail_view.dart | 160 +++++++++++++++++ lib/widget/views/consumption_rhythm_view.dart | 161 ++++++++++++++++++ test/widget/bee_trail_view_test.dart | 66 +++++++ test/widget/consumption_rhythm_view_test.dart | 73 ++++++++ 4 files changed, 460 insertions(+) create mode 100644 lib/widget/views/bee_trail_view.dart create mode 100644 lib/widget/views/consumption_rhythm_view.dart create mode 100644 test/widget/bee_trail_view_test.dart create mode 100644 test/widget/consumption_rhythm_view_test.dart diff --git a/lib/widget/views/bee_trail_view.dart b/lib/widget/views/bee_trail_view.dart new file mode 100644 index 000000000..53b18e8da --- /dev/null +++ b/lib/widget/views/bee_trail_view.dart @@ -0,0 +1,160 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../widget_data_service.dart' show DailyWidgetActivity; +import 'widget_view_style.dart'; + +/// 小号「记账连续蜂迹」小组件:以 28 枚蜂巢格呈现最近记录,并强调当前连续 +/// 记账天数而不是消费金额。 +class BeeTrailView extends StatelessWidget { + final List activity; + final Color themeColor; + final bool dark; + final String titleLabel; + final String streakSuffix; + final String completionLabel; + final String emptyLabel; + final double width; + final double height; + + const BeeTrailView({ + super.key, + required this.activity, + required this.themeColor, + required this.dark, + required this.titleLabel, + required this.streakSuffix, + required this.completionLabel, + required this.emptyLabel, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context) { + final recorded = activity.where((day) => day.hasRecord).length; + final streak = _currentStreak(activity); + final recent = activity.length > 28 + ? activity.sublist(activity.length - 28) + : activity; + final dots = [for (final day in recent) day.hasRecord]; + while (dots.length < 28) { + dots.insert(0, false); + } + + return Container( + width: width, + height: height, + padding: const EdgeInsets.fromLTRB(10, 9, 10, 9), + decoration: BoxDecoration( + color: widgetCardBackground(dark), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(titleLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: widgetTextSecondary(dark), + fontSize: 11, + fontWeight: FontWeight.w600)), + const SizedBox(height: 3), + if (recorded == 0) + Expanded( + child: Center( + child: Text(emptyLabel, + textAlign: TextAlign.center, + style: TextStyle(color: widgetTextTertiary(dark), fontSize: 11)), + ), + ) + else ...[ + Text('$streak $streakSuffix', + style: TextStyle( + color: widgetTextPrimary(dark), + fontSize: 20, + fontWeight: FontWeight.w700, + height: 1.0)), + const SizedBox(height: 4), + Expanded(child: CustomPaint(painter: _HivePainter(dots, themeColor, dark))), + const SizedBox(height: 3), + Row( + children: [ + Expanded( + child: Text(completionLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: widgetTextTertiary(dark), fontSize: 9)), + ), + Text('${(recorded / activity.length * 100).round()}%', + style: TextStyle( + color: widgetTextSecondary(dark), + fontSize: 10, + fontWeight: FontWeight.w700)), + ], + ), + ], + ], + ), + ); + } + + int _currentStreak(List days) { + var count = 0; + for (final day in days.reversed) { + if (!day.hasRecord) break; + count++; + } + return count; + } +} + +class _HivePainter extends CustomPainter { + final List dots; + final Color color; + final bool dark; + + const _HivePainter(this.dots, this.color, this.dark); + + @override + void paint(Canvas canvas, Size size) { + const rows = 4; + const columns = 7; + final cellWidth = size.width / (columns + 0.5); + final radius = cellWidth * 0.46; + final rowHeight = size.height / rows; + for (var row = 0; row < rows; row++) { + for (var column = 0; column < columns; column++) { + final index = row * columns + column; + final center = Offset( + cellWidth * (column + 0.5 + (row.isOdd ? 0.5 : 0)), + rowHeight * (row + 0.5), + ); + final path = Path(); + for (var side = 0; side < 6; side++) { + final angle = (60 * side - 30) * 3.141592653589793 / 180; + final point = Offset(center.dx + radius * math.cos(angle), + center.dy + radius * math.sin(angle)); + if (side == 0) { + path.moveTo(point.dx, point.dy); + } else { + path.lineTo(point.dx, point.dy); + } + } + path.close(); + canvas.drawPath( + path, + Paint() + ..color = dots[index] + ? color.withValues(alpha: 0.90) + : (dark ? Colors.white.withValues(alpha: 0.09) : const Color(0xFFF1F1F1))); + } + } + } + + @override + bool shouldRepaint(covariant _HivePainter oldDelegate) => + oldDelegate.dots != dots || oldDelegate.color != color || oldDelegate.dark != dark; +} diff --git a/lib/widget/views/consumption_rhythm_view.dart b/lib/widget/views/consumption_rhythm_view.dart new file mode 100644 index 000000000..755ae1bbc --- /dev/null +++ b/lib/widget/views/consumption_rhythm_view.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; + +import '../widget_data_service.dart' show DailyWidgetActivity; +import 'widget_view_style.dart'; + +/// 中号「消费节奏」小组件:以近 30 日支出热力格展示消费是否集中,并用最近 +/// 七天与前七天的比较给出一句简短的节奏提示。 +class ConsumptionRhythmView extends StatelessWidget { + final List activity; + final Color themeColor; + final bool dark; + final String titleLabel; + final String stableLabel; + final String increaseLabel; + final String decreaseLabel; + final String emptyLabel; + final double width; + final double height; + + const ConsumptionRhythmView({ + super.key, + required this.activity, + required this.themeColor, + required this.dark, + required this.titleLabel, + required this.stableLabel, + required this.increaseLabel, + required this.decreaseLabel, + required this.emptyLabel, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context) { + final days = activity.length > 30 + ? activity.sublist(activity.length - 30) + : activity; + final maxExpense = days.fold( + 0, (maximum, day) => day.expenseTotal > maximum ? day.expenseTotal : maximum); + final empty = maxExpense == 0; + + return Container( + width: width, + height: height, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + color: widgetCardBackground(dark), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: themeColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text(titleLabel, + style: TextStyle( + color: widgetTextSecondary(dark), + fontSize: 12, + fontWeight: FontWeight.w600)), + const Spacer(), + Text('近 30 天', + style: TextStyle(color: widgetTextTertiary(dark), fontSize: 10)), + ], + ), + const SizedBox(height: 7), + Expanded( + child: empty + ? Center( + child: Text(emptyLabel, + style: TextStyle( + color: widgetTextTertiary(dark), fontSize: 12))) + : _HeatMap(days: days, maxExpense: maxExpense, color: themeColor, dark: dark), + ), + const SizedBox(height: 5), + Text( + empty ? '' : _comparisonLabel(days), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: empty ? widgetTextTertiary(dark) : widgetTextSecondary(dark), + fontSize: 11, + fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + String _comparisonLabel(List days) { + if (days.every((day) => day.expenseTotal == 0)) return emptyLabel; + final recent = days.skip((days.length - 7).clamp(0, days.length)).fold( + 0, (sum, day) => sum + day.expenseTotal); + final previousStart = (days.length - 14).clamp(0, days.length); + final previousEnd = (days.length - 7).clamp(0, days.length); + final previous = days + .sublist(previousStart, previousEnd) + .fold(0, (sum, day) => sum + day.expenseTotal); + if (previous == 0) return stableLabel; + final change = (recent - previous).abs() / previous; + if (change < 0.1) return stableLabel; + return recent > previous ? increaseLabel : decreaseLabel; + } +} + +class _HeatMap extends StatelessWidget { + final List days; + final double maxExpense; + final Color color; + final bool dark; + + const _HeatMap({ + required this.days, + required this.maxExpense, + required this.color, + required this.dark, + }); + + @override + Widget build(BuildContext context) { + final cells = [...days]; + while (cells.length < 30) { + cells.insert(0, null); + } + return Column( + children: [ + for (var row = 0; row < 3; row++) + Expanded( + child: Row( + children: [ + for (var column = 0; column < 10; column++) + Expanded( + child: Padding( + padding: const EdgeInsets.all(2), + child: _cell(cells[row * 10 + column]), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _cell(DailyWidgetActivity? day) { + final ratio = day == null || maxExpense == 0 ? 0.0 : day.expenseTotal / maxExpense; + final alpha = ratio == 0 ? (dark ? 0.10 : 0.08) : 0.22 + ratio * 0.70; + return DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: alpha), + borderRadius: BorderRadius.circular(4), + ), + ); + } +} diff --git a/test/widget/bee_trail_view_test.dart b/test/widget/bee_trail_view_test.dart new file mode 100644 index 000000000..c1be31417 --- /dev/null +++ b/test/widget/bee_trail_view_test.dart @@ -0,0 +1,66 @@ +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:beecount/widget/views/bee_trail_view.dart'; +import 'package:beecount/widget/widget_data_service.dart'; + +void main() { + const size = Size(155, 155); + + List activity({bool empty = false}) => List.generate( + 30, + (index) => DailyWidgetActivity( + date: DateTime(2026, 8, index + 1), + expenseTotal: 0, + hasRecord: !empty && index >= 25, + ), + ); + + Widget wrap(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: size.width, height: size.height, child: child), + ); + + for (final dark in [false, true]) { + testWidgets('155x155 ${dark ? "暗色" : "亮色"}显示连续记账和完成率', + (tester) async { + await tester.pumpWidget(wrap(BeeTrailView( + activity: activity(), + themeColor: const Color(0xFFF5A623), + dark: dark, + titleLabel: '记账连续蜂迹', + streakSuffix: '天', + completionLabel: '本月完成率', + emptyLabel: '今天记一笔,点亮第一格', + width: size.width, + height: size.height, + ))); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('记账连续蜂迹'), findsOneWidget); + expect(find.text('5 天'), findsOneWidget); + expect(find.text('本月完成率'), findsOneWidget); + }); + } + + testWidgets('没有记账历史时显示引导文案', (tester) async { + await tester.pumpWidget(wrap(BeeTrailView( + activity: activity(empty: true), + themeColor: const Color(0xFFF5A623), + dark: false, + titleLabel: '记账连续蜂迹', + streakSuffix: '天', + completionLabel: '本月完成率', + emptyLabel: '今天记一笔,点亮第一格', + width: size.width, + height: size.height, + ))); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('今天记一笔,点亮第一格'), findsOneWidget); + }); +} diff --git a/test/widget/consumption_rhythm_view_test.dart b/test/widget/consumption_rhythm_view_test.dart new file mode 100644 index 000000000..de7451c42 --- /dev/null +++ b/test/widget/consumption_rhythm_view_test.dart @@ -0,0 +1,73 @@ +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:beecount/widget/views/consumption_rhythm_view.dart'; +import 'package:beecount/widget/widget_data_service.dart'; + +void main() { + const size = Size(364, 169); + + List activity() => List.generate( + 30, + (index) => DailyWidgetActivity( + date: DateTime(2026, 8, index + 1), + expenseTotal: switch (index % 4) { 0 => 0, 1 => 12, 2 => 45, _ => 120 }, + hasRecord: index.isEven, + ), + ); + + Widget wrap(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: size.width, height: size.height, child: child), + ); + + for (final dark in [false, true]) { + testWidgets('364x169 ${dark ? "暗色" : "亮色"}显示热力图和节奏提示', + (tester) async { + await tester.pumpWidget(wrap(ConsumptionRhythmView( + activity: activity(), + themeColor: const Color(0xFFF5A623), + dark: dark, + titleLabel: '消费节奏', + stableLabel: '消费很均匀', + increaseLabel: '比上周更快', + decreaseLabel: '比上周更稳', + emptyLabel: '本月还没有支出', + width: size.width, + height: size.height, + ))); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('消费节奏'), findsOneWidget); + expect(find.byType(ConsumptionRhythmView), findsOneWidget); + }); + } + + testWidgets('无支出时显示明确的空状态文案', (tester) async { + final empty = List.generate( + 30, + (index) => DailyWidgetActivity( + date: DateTime(2026, 8, index + 1), + expenseTotal: 0, + hasRecord: false)); + await tester.pumpWidget(wrap(ConsumptionRhythmView( + activity: empty, + themeColor: const Color(0xFFF5A623), + dark: false, + titleLabel: '消费节奏', + stableLabel: '消费很均匀', + increaseLabel: '比上周更快', + decreaseLabel: '比上周更稳', + emptyLabel: '本月还没有支出', + width: size.width, + height: size.height, + ))); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('本月还没有支出'), findsOneWidget); + }); +} From eaa82053a47b2e6f4659108df57f9e63385cbc4a Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 19:53:15 +0800 Subject: [PATCH 3/7] feat: register rhythm and bee trail widgets --- lib/l10n/app_en.arb | 11 ++ lib/l10n/app_ko.arb | 11 ++ lib/l10n/app_localizations.dart | 66 ++++++++++ lib/l10n/app_localizations_en.dart | 33 +++++ lib/l10n/app_localizations_ko.dart | 33 +++++ lib/l10n/app_localizations_zh.dart | 66 ++++++++++ lib/l10n/app_zh.arb | 11 ++ lib/l10n/app_zh_TW.arb | 11 ++ .../settings/widget_management_page.dart | 58 +++++++++ lib/providers/widget_provider.dart | 9 ++ lib/widget/widget_manager.dart | 121 ++++++++++++++++++ lib/widget/widget_spec.dart | 34 ++++- test/widget/widget_spec_test.dart | 19 ++- 13 files changed, 480 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b3341331f..6050a8b83 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1729,6 +1729,17 @@ "widgetGalleryDashboardTitle": "Dashboard", "widgetDashboardTitle": "This Month", "widgetGalleryDashboardDesc": "Income, trend and recent transactions in one view", + "widgetConsumptionRhythmTitle": "Spending Rhythm", + "widgetConsumptionRhythmStable": "Spending is steady", + "widgetConsumptionRhythmIncrease": "Faster than last week", + "widgetConsumptionRhythmDecrease": "Steadier than last week", + "widgetConsumptionRhythmEmpty": "No spending this month", + "widgetBeeTrailTitle": "Record Bee Trail", + "widgetBeeTrailStreakSuffix": "days", + "widgetBeeTrailCompletion": "Monthly completion", + "widgetBeeTrailEmpty": "Add a record to light the first cell", + "widgetGalleryConsumptionRhythmDesc": "See your spending pace across the last 30 days", + "widgetGalleryBeeTrailDesc": "Build a gentle daily recording habit", "widgetSizeSmall": "Small", "widgetSizeMedium": "Medium", "widgetSizeLarge": "Large", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 349435bae..7e43ce826 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1212,6 +1212,17 @@ "welcomeCloudSyncFeature3": "또는 iCloud / WebDAV / Supabase / S3 중 선택", "widgetManagement": "홈 화면 위젯", "widgetManagementDesc": "홈 화면에서 수입과 지출을 빠르게 확인하세요", + "widgetConsumptionRhythmTitle": "소비 리듬", + "widgetConsumptionRhythmStable": "소비가 안정적이에요", + "widgetConsumptionRhythmIncrease": "지난주보다 빨라요", + "widgetConsumptionRhythmDecrease": "지난주보다 안정적이에요", + "widgetConsumptionRhythmEmpty": "이번 달 지출이 없어요", + "widgetBeeTrailTitle": "기록 꿀벌 궤적", + "widgetBeeTrailStreakSuffix": "일", + "widgetBeeTrailCompletion": "이번 달 완료율", + "widgetBeeTrailEmpty": "첫 칸을 밝히려면 기록을 추가하세요", + "widgetGalleryConsumptionRhythmDesc": "최근 30일의 소비 흐름을 확인하세요", + "widgetGalleryBeeTrailDesc": "벌집 칸으로 매일 기록하는 습관을 만드세요", "widgetPreview": "위젯 미리보기", "widgetPreviewDesc": "위젯은 현재 가계부의 실제 데이터를 자동으로 표시하며, 테마 색상은 앱 설정을 따릅니다", "howToAddWidget": "위젯 추가 방법", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c4dd52ddd..1930991e8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7718,6 +7718,72 @@ abstract class AppLocalizations { /// **'Income, trend and recent transactions in one view'** String get widgetGalleryDashboardDesc; + /// No description provided for @widgetConsumptionRhythmTitle. + /// + /// In en, this message translates to: + /// **'Spending Rhythm'** + String get widgetConsumptionRhythmTitle; + + /// No description provided for @widgetConsumptionRhythmStable. + /// + /// In en, this message translates to: + /// **'Spending is steady'** + String get widgetConsumptionRhythmStable; + + /// No description provided for @widgetConsumptionRhythmIncrease. + /// + /// In en, this message translates to: + /// **'Faster than last week'** + String get widgetConsumptionRhythmIncrease; + + /// No description provided for @widgetConsumptionRhythmDecrease. + /// + /// In en, this message translates to: + /// **'Steadier than last week'** + String get widgetConsumptionRhythmDecrease; + + /// No description provided for @widgetConsumptionRhythmEmpty. + /// + /// In en, this message translates to: + /// **'No spending this month'** + String get widgetConsumptionRhythmEmpty; + + /// No description provided for @widgetBeeTrailTitle. + /// + /// In en, this message translates to: + /// **'Record Bee Trail'** + String get widgetBeeTrailTitle; + + /// No description provided for @widgetBeeTrailStreakSuffix. + /// + /// In en, this message translates to: + /// **'days'** + String get widgetBeeTrailStreakSuffix; + + /// No description provided for @widgetBeeTrailCompletion. + /// + /// In en, this message translates to: + /// **'Monthly completion'** + String get widgetBeeTrailCompletion; + + /// No description provided for @widgetBeeTrailEmpty. + /// + /// In en, this message translates to: + /// **'Add a record to light the first cell'** + String get widgetBeeTrailEmpty; + + /// No description provided for @widgetGalleryConsumptionRhythmDesc. + /// + /// In en, this message translates to: + /// **'See your spending pace across the last 30 days'** + String get widgetGalleryConsumptionRhythmDesc; + + /// No description provided for @widgetGalleryBeeTrailDesc. + /// + /// In en, this message translates to: + /// **'Build a gentle daily recording habit'** + String get widgetGalleryBeeTrailDesc; + /// No description provided for @widgetSizeSmall. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eb5eeea4d..57a884166 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4042,6 +4042,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get widgetGalleryDashboardDesc => 'Income, trend and recent transactions in one view'; + @override + String get widgetConsumptionRhythmTitle => 'Spending Rhythm'; + + @override + String get widgetConsumptionRhythmStable => 'Spending is steady'; + + @override + String get widgetConsumptionRhythmIncrease => 'Faster than last week'; + + @override + String get widgetConsumptionRhythmDecrease => 'Steadier than last week'; + + @override + String get widgetConsumptionRhythmEmpty => 'No spending this month'; + + @override + String get widgetBeeTrailTitle => 'Record Bee Trail'; + + @override + String get widgetBeeTrailStreakSuffix => 'days'; + + @override + String get widgetBeeTrailCompletion => 'Monthly completion'; + + @override + String get widgetBeeTrailEmpty => 'Add a record to light the first cell'; + + @override + String get widgetGalleryConsumptionRhythmDesc => 'See your spending pace across the last 30 days'; + + @override + String get widgetGalleryBeeTrailDesc => 'Build a gentle daily recording habit'; + @override String get widgetSizeSmall => 'Small'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 40bedeb0c..3aff538eb 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4042,6 +4042,39 @@ class AppLocalizationsKo extends AppLocalizations { @override String get widgetGalleryDashboardDesc => 'Income, trend and recent transactions in one view'; + @override + String get widgetConsumptionRhythmTitle => '소비 리듬'; + + @override + String get widgetConsumptionRhythmStable => '소비가 안정적이에요'; + + @override + String get widgetConsumptionRhythmIncrease => '지난주보다 빨라요'; + + @override + String get widgetConsumptionRhythmDecrease => '지난주보다 안정적이에요'; + + @override + String get widgetConsumptionRhythmEmpty => '이번 달 지출이 없어요'; + + @override + String get widgetBeeTrailTitle => '기록 꿀벌 궤적'; + + @override + String get widgetBeeTrailStreakSuffix => '일'; + + @override + String get widgetBeeTrailCompletion => '이번 달 완료율'; + + @override + String get widgetBeeTrailEmpty => '첫 칸을 밝히려면 기록을 추가하세요'; + + @override + String get widgetGalleryConsumptionRhythmDesc => '최근 30일의 소비 흐름을 확인하세요'; + + @override + String get widgetGalleryBeeTrailDesc => '벌집 칸으로 매일 기록하는 습관을 만드세요'; + @override String get widgetSizeSmall => 'Small'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a3651de9a..04ead513e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4042,6 +4042,39 @@ class AppLocalizationsZh extends AppLocalizations { @override String get widgetGalleryDashboardDesc => '收支、趋势与最近交易一屏看尽'; + @override + String get widgetConsumptionRhythmTitle => '消费节奏'; + + @override + String get widgetConsumptionRhythmStable => '消费很均匀'; + + @override + String get widgetConsumptionRhythmIncrease => '比上周更快'; + + @override + String get widgetConsumptionRhythmDecrease => '比上周更稳'; + + @override + String get widgetConsumptionRhythmEmpty => '本月还没有支出'; + + @override + String get widgetBeeTrailTitle => '记账连续蜂迹'; + + @override + String get widgetBeeTrailStreakSuffix => '天'; + + @override + String get widgetBeeTrailCompletion => '本月完成率'; + + @override + String get widgetBeeTrailEmpty => '今天记一笔,点亮第一格'; + + @override + String get widgetGalleryConsumptionRhythmDesc => '近 30 天消费节奏一眼看清'; + + @override + String get widgetGalleryBeeTrailDesc => '用蜂巢格养成每日记账习惯'; + @override String get widgetSizeSmall => '小号'; @@ -11554,6 +11587,39 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get widgetGalleryDashboardDesc => '收支、趨勢與最近交易一屏看盡'; + @override + String get widgetConsumptionRhythmTitle => '消費節奏'; + + @override + String get widgetConsumptionRhythmStable => '消費很均勻'; + + @override + String get widgetConsumptionRhythmIncrease => '比上週更快'; + + @override + String get widgetConsumptionRhythmDecrease => '比上週更穩'; + + @override + String get widgetConsumptionRhythmEmpty => '本月還沒有支出'; + + @override + String get widgetBeeTrailTitle => '記帳連續蜂跡'; + + @override + String get widgetBeeTrailStreakSuffix => '天'; + + @override + String get widgetBeeTrailCompletion => '本月完成率'; + + @override + String get widgetBeeTrailEmpty => '今天記一筆,點亮第一格'; + + @override + String get widgetGalleryConsumptionRhythmDesc => '近 30 天消費節奏一眼看清'; + + @override + String get widgetGalleryBeeTrailDesc => '用蜂巢格養成每日記帳習慣'; + @override String get widgetSizeSmall => '小號'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f9fe7a7fc..7bee0ed24 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1593,6 +1593,17 @@ "widgetGalleryDashboardTitle": "综合仪表盘", "widgetDashboardTitle": "本月概览", "widgetGalleryDashboardDesc": "收支、趋势与最近交易一屏看尽", + "widgetConsumptionRhythmTitle": "消费节奏", + "widgetConsumptionRhythmStable": "消费很均匀", + "widgetConsumptionRhythmIncrease": "比上周更快", + "widgetConsumptionRhythmDecrease": "比上周更稳", + "widgetConsumptionRhythmEmpty": "本月还没有支出", + "widgetBeeTrailTitle": "记账连续蜂迹", + "widgetBeeTrailStreakSuffix": "天", + "widgetBeeTrailCompletion": "本月完成率", + "widgetBeeTrailEmpty": "今天记一笔,点亮第一格", + "widgetGalleryConsumptionRhythmDesc": "近 30 天消费节奏一眼看清", + "widgetGalleryBeeTrailDesc": "用蜂巢格养成每日记账习惯", "widgetSizeSmall": "小号", "widgetSizeMedium": "中号", "widgetSizeLarge": "大号", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 313ff000a..0b3eb7fdc 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2359,6 +2359,17 @@ "widgetGalleryDashboardTitle": "綜合儀表盤", "widgetDashboardTitle": "本月概覽", "widgetGalleryDashboardDesc": "收支、趨勢與最近交易一屏看盡", + "widgetConsumptionRhythmTitle": "消費節奏", + "widgetConsumptionRhythmStable": "消費很均勻", + "widgetConsumptionRhythmIncrease": "比上週更快", + "widgetConsumptionRhythmDecrease": "比上週更穩", + "widgetConsumptionRhythmEmpty": "本月還沒有支出", + "widgetBeeTrailTitle": "記帳連續蜂跡", + "widgetBeeTrailStreakSuffix": "天", + "widgetBeeTrailCompletion": "本月完成率", + "widgetBeeTrailEmpty": "今天記一筆,點亮第一格", + "widgetGalleryConsumptionRhythmDesc": "近 30 天消費節奏一眼看清", + "widgetGalleryBeeTrailDesc": "用蜂巢格養成每日記帳習慣", "widgetSizeSmall": "小號", "widgetSizeMedium": "中號", "widgetSizeLarge": "大號", diff --git a/lib/pages/settings/widget_management_page.dart b/lib/pages/settings/widget_management_page.dart index a4c251e17..23f98ff83 100644 --- a/lib/pages/settings/widget_management_page.dart +++ b/lib/pages/settings/widget_management_page.dart @@ -10,6 +10,8 @@ import '../../providers.dart'; import '../../styles/tokens.dart'; import '../../utils/ui_scale_extensions.dart'; import '../../widget/views/budget_view.dart'; +import '../../widget/views/bee_trail_view.dart'; +import '../../widget/views/consumption_rhythm_view.dart'; import '../../widget/views/dashboard_view.dart'; import '../../widget/views/glance_view.dart'; import '../../widget/views/net_worth_view.dart'; @@ -18,6 +20,7 @@ import '../../widget/views/recent_view.dart'; import '../../widget/widget_data_service.dart' show DashboardWidgetData, + DailyWidgetActivity, GlanceWidgetData, NetWorthAccountItem, QuickAddCategoryItem, @@ -250,6 +253,49 @@ class WidgetManagementPage extends ConsumerWidget { height: 382, ), ), + SizedBox(height: 12.0.scaled(context, ref)), + + _buildGalleryCard( + context, + ref, + title: l10n.widgetConsumptionRhythmTitle, + subtitle: l10n.widgetGalleryConsumptionRhythmDesc, + sizeLabel: l10n.widgetSizeMedium, + previewSize: const Size(364, 169), + preview: ConsumptionRhythmView( + activity: _sampleDailyActivity(), + themeColor: primaryColor, + dark: dark, + titleLabel: l10n.widgetConsumptionRhythmTitle, + stableLabel: l10n.widgetConsumptionRhythmStable, + increaseLabel: l10n.widgetConsumptionRhythmIncrease, + decreaseLabel: l10n.widgetConsumptionRhythmDecrease, + emptyLabel: l10n.widgetConsumptionRhythmEmpty, + width: 364, + height: 169, + ), + ), + SizedBox(height: 12.0.scaled(context, ref)), + + _buildGalleryCard( + context, + ref, + title: l10n.widgetBeeTrailTitle, + subtitle: l10n.widgetGalleryBeeTrailDesc, + sizeLabel: l10n.widgetSizeSmall, + previewSize: const Size(155, 155), + preview: BeeTrailView( + activity: _sampleDailyActivity(), + themeColor: primaryColor, + dark: dark, + titleLabel: l10n.widgetBeeTrailTitle, + streakSuffix: l10n.widgetBeeTrailStreakSuffix, + completionLabel: l10n.widgetBeeTrailCompletion, + emptyLabel: l10n.widgetBeeTrailEmpty, + width: 155, + height: 155, + ), + ), SizedBox(height: 20.0.scaled(context, ref)), // 添加指引 @@ -693,6 +739,18 @@ List<({DateTime date, double assets, double liabilities, double net})> }); } +List _sampleDailyActivity() { + final start = DateTime.now().subtract(const Duration(days: 29)); + return List.generate( + 30, + (index) => DailyWidgetActivity( + date: DateTime(start.year, start.month, start.day + index), + expenseTotal: switch (index % 5) { 0 => 0, 1 => 35, 2 => 86, _ => 150 }, + hasRecord: index % 4 != 0, + ), + ); +} + /// 净资产大号卡的账户明细:含一个正常折算 + 一个现金账户,数值与 /// net_worth_view_test.dart 的示例保持同一量级。 List _sampleNetWorthAccounts() { diff --git a/lib/providers/widget_provider.dart b/lib/providers/widget_provider.dart index 309c03beb..4dfb424ec 100644 --- a/lib/providers/widget_provider.dart +++ b/lib/providers/widget_provider.dart @@ -55,6 +55,15 @@ Future updateAppWidget(WidgetRef ref, BuildContext context) async { uncategorizedLabel: l10n.commonUncategorized, noTransactionsLabel: l10n.widgetNoTransactions, dashboardRecentLabel: l10n.widgetRecentTransactions, + consumptionRhythmTitleLabel: l10n.widgetConsumptionRhythmTitle, + consumptionRhythmStableLabel: l10n.widgetConsumptionRhythmStable, + consumptionRhythmIncreaseLabel: l10n.widgetConsumptionRhythmIncrease, + consumptionRhythmDecreaseLabel: l10n.widgetConsumptionRhythmDecrease, + consumptionRhythmEmptyLabel: l10n.widgetConsumptionRhythmEmpty, + beeTrailTitleLabel: l10n.widgetBeeTrailTitle, + beeTrailStreakSuffix: l10n.widgetBeeTrailStreakSuffix, + beeTrailCompletionLabel: l10n.widgetBeeTrailCompletion, + beeTrailEmptyLabel: l10n.widgetBeeTrailEmpty, ); } catch (e) { // Silently fail to avoid disrupting the app diff --git a/lib/widget/widget_manager.dart b/lib/widget/widget_manager.dart index 0d5d625c5..e63e6ee7a 100644 --- a/lib/widget/widget_manager.dart +++ b/lib/widget/widget_manager.dart @@ -10,6 +10,8 @@ import '../services/system/logger_service.dart'; import '../utils/currencies.dart' show getCurrencySymbol; import '../widgets/biz/format_money.dart' show formatMoneyCompact; import 'views/budget_view.dart'; +import 'views/bee_trail_view.dart'; +import 'views/consumption_rhythm_view.dart'; import 'views/dashboard_view.dart'; import 'views/glance_view.dart'; import 'views/net_worth_view.dart'; @@ -209,6 +211,15 @@ class WidgetManager { // `widgetRecentTransactions`。其余文案(本月支出/收入、未分类、暂无交易、 // 记一笔)全部复用上面 glance/recent/quickAdd 已有的同名参数,不重复造词。 String dashboardRecentLabel = '最近交易', + String consumptionRhythmTitleLabel = '消费节奏', + String consumptionRhythmStableLabel = '消费很均匀', + String consumptionRhythmIncreaseLabel = '比上周更快', + String consumptionRhythmDecreaseLabel = '比上周更稳', + String consumptionRhythmEmptyLabel = '本月还没有支出', + String beeTrailTitleLabel = '记账连续蜂迹', + String beeTrailStreakSuffix = '天', + String beeTrailCompletionLabel = '本月完成率', + String beeTrailEmptyLabel = '今天记一笔,点亮第一格', // 预热:true 时渲染整个 [WidgetSpec.catalog] 而非仅"已安装"(D5 的显式 // 例外)。用于 App 启动 / 切账本这类低频时机,把全部类型×尺寸的图先备好 // ——否则用户添加一个从未渲染过的组件类型时,共享存储里没有对应图片, @@ -284,6 +295,15 @@ class WidgetManager { uncategorizedLabel: uncategorizedLabel, noTransactionsLabel: noTransactionsLabel, dashboardRecentLabel: dashboardRecentLabel, + consumptionRhythmTitleLabel: consumptionRhythmTitleLabel, + consumptionRhythmStableLabel: consumptionRhythmStableLabel, + consumptionRhythmIncreaseLabel: consumptionRhythmIncreaseLabel, + consumptionRhythmDecreaseLabel: consumptionRhythmDecreaseLabel, + consumptionRhythmEmptyLabel: consumptionRhythmEmptyLabel, + beeTrailTitleLabel: beeTrailTitleLabel, + beeTrailStreakSuffix: beeTrailStreakSuffix, + beeTrailCompletionLabel: beeTrailCompletionLabel, + beeTrailEmptyLabel: beeTrailEmptyLabel, ); } catch (e, st) { // 单个 spec 渲染失败不应阻断其余 spec。 @@ -375,6 +395,15 @@ class WidgetManager { uncategorizedLabel: l10n.commonUncategorized, noTransactionsLabel: l10n.widgetNoTransactions, dashboardRecentLabel: l10n.widgetRecentTransactions, + consumptionRhythmTitleLabel: l10n.widgetConsumptionRhythmTitle, + consumptionRhythmStableLabel: l10n.widgetConsumptionRhythmStable, + consumptionRhythmIncreaseLabel: l10n.widgetConsumptionRhythmIncrease, + consumptionRhythmDecreaseLabel: l10n.widgetConsumptionRhythmDecrease, + consumptionRhythmEmptyLabel: l10n.widgetConsumptionRhythmEmpty, + beeTrailTitleLabel: l10n.widgetBeeTrailTitle, + beeTrailStreakSuffix: l10n.widgetBeeTrailStreakSuffix, + beeTrailCompletionLabel: l10n.widgetBeeTrailCompletion, + beeTrailEmptyLabel: l10n.widgetBeeTrailEmpty, ); } @@ -424,6 +453,15 @@ class WidgetManager { required String uncategorizedLabel, required String noTransactionsLabel, required String dashboardRecentLabel, + required String consumptionRhythmTitleLabel, + required String consumptionRhythmStableLabel, + required String consumptionRhythmIncreaseLabel, + required String consumptionRhythmDecreaseLabel, + required String consumptionRhythmEmptyLabel, + required String beeTrailTitleLabel, + required String beeTrailStreakSuffix, + required String beeTrailCompletionLabel, + required String beeTrailEmptyLabel, }) async { switch (spec.type) { case HWType.glance: @@ -507,9 +545,92 @@ class WidgetManager { titleLabel: dashboardTitleLabel, ); return; + case HWType.consumptionRhythm: + await _renderConsumptionRhythm( + spec, + batch: batch, + themeColor: themeColor, + dark: dark, + titleLabel: consumptionRhythmTitleLabel, + stableLabel: consumptionRhythmStableLabel, + increaseLabel: consumptionRhythmIncreaseLabel, + decreaseLabel: consumptionRhythmDecreaseLabel, + emptyLabel: consumptionRhythmEmptyLabel, + ); + return; + case HWType.beeTrail: + await _renderBeeTrail( + spec, + batch: batch, + themeColor: themeColor, + dark: dark, + titleLabel: beeTrailTitleLabel, + streakSuffix: beeTrailStreakSuffix, + completionLabel: beeTrailCompletionLabel, + emptyLabel: beeTrailEmptyLabel, + ); + return; } } + Future _renderConsumptionRhythm( + WidgetSpec spec, { + required WidgetGatherBatch batch, + required Color themeColor, + required bool dark, + required String titleLabel, + required String stableLabel, + required String increaseLabel, + required String decreaseLabel, + required String emptyLabel, + }) async { + final view = ConsumptionRhythmView( + activity: await batch.dailyActivity30(), + themeColor: themeColor, + dark: dark, + titleLabel: titleLabel, + stableLabel: stableLabel, + increaseLabel: increaseLabel, + decreaseLabel: decreaseLabel, + emptyLabel: emptyLabel, + width: spec.logicalSize.width, + height: spec.logicalSize.height, + ); + await _renderView(view, + spec: spec, + logicalSize: spec.logicalSize, + themeColor: themeColor, + dark: dark); + } + + Future _renderBeeTrail( + WidgetSpec spec, { + required WidgetGatherBatch batch, + required Color themeColor, + required bool dark, + required String titleLabel, + required String streakSuffix, + required String completionLabel, + required String emptyLabel, + }) async { + final view = BeeTrailView( + activity: await batch.dailyActivity30(), + themeColor: themeColor, + dark: dark, + titleLabel: titleLabel, + streakSuffix: streakSuffix, + completionLabel: completionLabel, + emptyLabel: emptyLabel, + width: spec.logicalSize.width, + height: spec.logicalSize.height, + ); + await _renderView(view, + spec: spec, + logicalSize: spec.logicalSize, + themeColor: themeColor, + dark: dark); + } + /// 渲染收支速览(glance):小/中两档,均已接 [GlanceView] 真实视图。 Future _renderGlance( WidgetSpec spec, { diff --git a/lib/widget/widget_spec.dart b/lib/widget/widget_spec.dart index 9ffbe0928..fc59242b3 100644 --- a/lib/widget/widget_spec.dart +++ b/lib/widget/widget_spec.dart @@ -14,7 +14,16 @@ import 'package:home_widget/home_widget.dart' show HomeWidgetInfo; /// /// P1(本阶段)只落地了 [WidgetSpec.glanceMedium] 的真实取数/渲染,其余 /// 类型仅登记目录条目,渲染管线会按 Phase B(P2)前的约定跳过它们。 -enum HWType { glance, netWorth, quickAdd, budget, recent, dashboard } +enum HWType { + glance, + netWorth, + quickAdd, + budget, + recent, + dashboard, + consumptionRhythm, + beeTrail, +} /// 桌面小组件尺寸档位,对应 iOS `systemSmall/Medium/Large`、Android 对应 /// 网格尺寸。 @@ -252,6 +261,27 @@ class WidgetSpec { androidClassName: 'com.tntlikely.beecount.BeeCountDashboardWidgetProvider', ); + // ---- 消费节奏(consumptionRhythm):仅中 ---- + static const consumptionRhythmMedium = WidgetSpec._( + type: HWType.consumptionRhythm, + size: HWSize.medium, + logicalSize: Size(364, 169), + iosKind: 'BeeCountConsumptionRhythmWidget', + iosFamily: 'systemMedium', + androidClassName: + 'com.tntlikely.beecount.BeeCountConsumptionRhythmWidgetProvider', + ); + + // ---- 记账连续蜂迹(beeTrail):仅小 ---- + static const beeTrailSmall = WidgetSpec._( + type: HWType.beeTrail, + size: HWSize.small, + logicalSize: Size(155, 155), + iosKind: 'BeeCountBeeTrailWidget', + iosFamily: 'systemSmall', + androidClassName: 'com.tntlikely.beecount.BeeCountBeeTrailWidgetProvider', + ); + /// 全部合法 (type, size) 组合的目录(见 plan.md §二逐组件 spec)。 static const List catalog = [ glanceSmall, @@ -266,6 +296,8 @@ class WidgetSpec { recentMedium, recentLarge, dashboardLarge, + consumptionRhythmMedium, + beeTrailSmall, ]; /// 渲染管线拿不到"已安装组件"列表时(home_widget 版本过低 / 平台调用 diff --git a/test/widget/widget_spec_test.dart b/test/widget/widget_spec_test.dart index ca2e8703a..67785214a 100644 --- a/test/widget/widget_spec_test.dart +++ b/test/widget/widget_spec_test.dart @@ -36,8 +36,8 @@ void main() { }); group('WidgetSpec.catalog', () { - test('覆盖 plan.md §二 全部合法 (type,size) 组合,12 条', () { - expect(WidgetSpec.catalog.length, 12); + test('覆盖全部合法 (type,size) 组合,14 条', () { + expect(WidgetSpec.catalog.length, 14); const expected = <(HWType, HWSize)>{ (HWType.glance, HWSize.small), @@ -52,6 +52,8 @@ void main() { (HWType.recent, HWSize.medium), (HWType.recent, HWSize.large), (HWType.dashboard, HWSize.large), + (HWType.consumptionRhythm, HWSize.medium), + (HWType.beeTrail, HWSize.small), }; final actual = WidgetSpec.catalog.map((s) => (s.type, s.size)).toSet(); @@ -71,6 +73,19 @@ void main() { .toSet(); expect(recentSizes, {HWSize.medium, HWSize.large}); }); + + test('消费节奏仅中号、记账连续蜂迹仅小号', () { + expect( + WidgetSpec.catalog + .where((s) => s.type == HWType.consumptionRhythm) + .toList(), + [WidgetSpec.consumptionRhythmMedium], + ); + expect( + WidgetSpec.catalog.where((s) => s.type == HWType.beeTrail).toList(), + [WidgetSpec.beeTrailSmall], + ); + }); }); group('WidgetSpec.defaultSet', () { From bcc2a00bb74c5b6be20a6d956786feeb87f98d9b Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 19:57:28 +0800 Subject: [PATCH 4/7] feat: add native rhythm and bee trail widgets --- android/app/src/main/AndroidManifest.xml | 11 ++++ .../BeeCountBeeTrailWidgetProvider.kt | 35 ++++++++++ ...BeeCountConsumptionRhythmWidgetProvider.kt | 35 ++++++++++ .../src/main/res/layout/bee_trail_widget.xml | 5 ++ .../res/layout/consumption_rhythm_widget.xml | 5 ++ android/app/src/main/res/values/strings.xml | 4 ++ .../main/res/xml/bee_trail_widget_info.xml | 5 ++ .../xml/consumption_rhythm_widget_info.xml | 5 ++ .../BeeCountBehaviorWidgets.swift | 66 +++++++++++++++++++ ios/BeeCountWidget/BeeCountWidgetBundle.swift | 2 + 10 files changed, 173 insertions(+) create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/BeeCountBeeTrailWidgetProvider.kt create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/BeeCountConsumptionRhythmWidgetProvider.kt create mode 100644 android/app/src/main/res/layout/bee_trail_widget.xml create mode 100644 android/app/src/main/res/layout/consumption_rhythm_widget.xml create mode 100644 android/app/src/main/res/xml/bee_trail_widget_info.xml create mode 100644 android/app/src/main/res/xml/consumption_rhythm_widget_info.xml create mode 100644 ios/BeeCountWidget/BeeCountBehaviorWidgets.swift diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 713c5fdd4..36ddd8482 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -260,6 +260,17 @@ android:resource="@xml/dashboard_widget_info" /> + + + + + + + + + 查看今日和本月的收支情况 @@ -25,6 +27,8 @@ 预算进度实时掌握 快速查看最近几笔账单 收支、趋势与最近交易一屏看尽 + 近 30 天消费节奏一眼看清 + 用蜂巢格养成每日记账习惯 diff --git a/android/app/src/main/res/xml/bee_trail_widget_info.xml b/android/app/src/main/res/xml/bee_trail_widget_info.xml new file mode 100644 index 000000000..02f771f58 --- /dev/null +++ b/android/app/src/main/res/xml/bee_trail_widget_info.xml @@ -0,0 +1,5 @@ + + diff --git a/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml b/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml new file mode 100644 index 000000000..af41f42b5 --- /dev/null +++ b/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml @@ -0,0 +1,5 @@ + + diff --git a/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift b/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift new file mode 100644 index 000000000..5f0e2159b --- /dev/null +++ b/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift @@ -0,0 +1,66 @@ +import WidgetKit +import SwiftUI +import UIKit + +private struct BehaviorWidgetEntry: TimelineEntry { + let date: Date + let imagePath: String +} + +private struct BehaviorWidgetProvider: TimelineProvider { + let imageKey: String + + private func entry() -> BehaviorWidgetEntry { + let path = UserDefaults(suiteName: "group.com.tntlikely.beecount")?.string(forKey: imageKey) ?? "" + return BehaviorWidgetEntry(date: Date(), imagePath: path) + } + + func placeholder(in context: Context) -> BehaviorWidgetEntry { entry() } + func getSnapshot(in context: Context, completion: @escaping (BehaviorWidgetEntry) -> ()) { completion(entry()) } + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())! + completion(Timeline(entries: [entry()], policy: .after(next))) + } +} + +private struct BehaviorWidgetEntryView: View { + let entry: BehaviorWidgetEntry + let destination: URL + let symbol: String + let label: String + + var body: some View { + if let image = UIImage(contentsOfFile: entry.imagePath) { + Link(destination: destination) { + Image(uiImage: image).resizable().scaledToFill().frame(maxWidth: .infinity, maxHeight: .infinity).clipped() + } + } else { + ZStack { + Color(red: 1.0, green: 0.76, blue: 0.03) + VStack { Image(systemName: symbol).font(.system(size: 28)); Text(label).font(.system(size: 13, weight: .semibold)) }.foregroundColor(.white) + }.widgetURL(destination) + } + } +} + +struct BeeCountConsumptionRhythmWidget: Widget { + let kind = "BeeCountConsumptionRhythmWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: BehaviorWidgetProvider(imageKey: "widget_consumptionRhythm_medium")) { entry in + BehaviorWidgetEntryView(entry: entry, destination: URL(string: "beecount://open?page=statistics")!, symbol: "chart.bar.fill", label: "消费节奏") + } + .configurationDisplayName("消费节奏").description("近 30 天消费节奏一眼看清") + .supportedFamilies([.systemMedium]).contentMarginsDisabled() + } +} + +struct BeeCountBeeTrailWidget: Widget { + let kind = "BeeCountBeeTrailWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: BehaviorWidgetProvider(imageKey: "widget_beeTrail_small")) { entry in + BehaviorWidgetEntryView(entry: entry, destination: URL(string: "beecount://open?page=transactions")!, symbol: "hexagon.fill", label: "记账连续蜂迹") + } + .configurationDisplayName("记账连续蜂迹").description("用蜂巢格养成每日记账习惯") + .supportedFamilies([.systemSmall]).contentMarginsDisabled() + } +} diff --git a/ios/BeeCountWidget/BeeCountWidgetBundle.swift b/ios/BeeCountWidget/BeeCountWidgetBundle.swift index e7c247f73..17d8b4425 100644 --- a/ios/BeeCountWidget/BeeCountWidgetBundle.swift +++ b/ios/BeeCountWidget/BeeCountWidgetBundle.swift @@ -21,5 +21,7 @@ struct BeeCountWidgetBundle: WidgetBundle { BeeCountBudgetWidget() BeeCountRecentWidget() BeeCountDashboardWidget() + BeeCountConsumptionRhythmWidget() + BeeCountBeeTrailWidget() } } From fccf8ab9c1bbde54ac316b7776662fe39faea529 Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 19:57:53 +0800 Subject: [PATCH 5/7] style: format new widget code --- .../settings/widget_management_page.dart | 12 ++-- lib/widget/views/bee_trail_view.dart | 18 ++++-- lib/widget/views/consumption_rhythm_view.dart | 28 ++++++--- lib/widget/widget_data_service.dart | 59 +++++++++++-------- lib/widget/widget_manager.dart | 3 +- lib/widget/widget_spec.dart | 4 +- test/widget/bee_trail_view_test.dart | 3 +- test/widget/consumption_rhythm_view_test.dart | 10 +++- test/widget/widget_data_service_test.dart | 58 +++++++++++------- test/widget/widget_manager_test.dart | 6 +- test/widget/widget_spec_test.dart | 3 +- 11 files changed, 131 insertions(+), 73 deletions(-) diff --git a/lib/pages/settings/widget_management_page.dart b/lib/pages/settings/widget_management_page.dart index 23f98ff83..02735fbfe 100644 --- a/lib/pages/settings/widget_management_page.dart +++ b/lib/pages/settings/widget_management_page.dart @@ -687,7 +687,8 @@ Account _sampleAccount( ); } -Category _sampleCategory(int id, String name, {String? icon, String kind = 'expense'}) { +Category _sampleCategory(int id, String name, + {String? icon, String kind = 'expense'}) { return Category( id: id, name: name, @@ -776,9 +777,12 @@ List _sampleNetWorthAccounts() { /// 快速记账 4 个常用分类(含一个 emoji 图标,展示 emoji/图标两种渲染路径)。 List _sampleQuickAddCategories() { return const [ - QuickAddCategoryItem(categoryId: 1, name: '餐饮', icon: 'restaurant', total: 680), - QuickAddCategoryItem(categoryId: 2, name: '交通', icon: 'directions_car', total: 210), - QuickAddCategoryItem(categoryId: 3, name: '购物', icon: 'shopping_bag', total: 450), + QuickAddCategoryItem( + categoryId: 1, name: '餐饮', icon: 'restaurant', total: 680), + QuickAddCategoryItem( + categoryId: 2, name: '交通', icon: 'directions_car', total: 210), + QuickAddCategoryItem( + categoryId: 3, name: '购物', icon: 'shopping_bag', total: 450), QuickAddCategoryItem(categoryId: 4, name: '奶茶', icon: '🧋', total: 68), ]; } diff --git a/lib/widget/views/bee_trail_view.dart b/lib/widget/views/bee_trail_view.dart index 53b18e8da..29841618e 100644 --- a/lib/widget/views/bee_trail_view.dart +++ b/lib/widget/views/bee_trail_view.dart @@ -67,7 +67,8 @@ class BeeTrailView extends StatelessWidget { child: Center( child: Text(emptyLabel, textAlign: TextAlign.center, - style: TextStyle(color: widgetTextTertiary(dark), fontSize: 11)), + style: TextStyle( + color: widgetTextTertiary(dark), fontSize: 11)), ), ) else ...[ @@ -78,7 +79,9 @@ class BeeTrailView extends StatelessWidget { fontWeight: FontWeight.w700, height: 1.0)), const SizedBox(height: 4), - Expanded(child: CustomPaint(painter: _HivePainter(dots, themeColor, dark))), + Expanded( + child: + CustomPaint(painter: _HivePainter(dots, themeColor, dark))), const SizedBox(height: 3), Row( children: [ @@ -86,7 +89,8 @@ class BeeTrailView extends StatelessWidget { child: Text(completionLabel, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: widgetTextTertiary(dark), fontSize: 9)), + style: TextStyle( + color: widgetTextTertiary(dark), fontSize: 9)), ), Text('${(recorded / activity.length * 100).round()}%', style: TextStyle( @@ -149,12 +153,16 @@ class _HivePainter extends CustomPainter { Paint() ..color = dots[index] ? color.withValues(alpha: 0.90) - : (dark ? Colors.white.withValues(alpha: 0.09) : const Color(0xFFF1F1F1))); + : (dark + ? Colors.white.withValues(alpha: 0.09) + : const Color(0xFFF1F1F1))); } } } @override bool shouldRepaint(covariant _HivePainter oldDelegate) => - oldDelegate.dots != dots || oldDelegate.color != color || oldDelegate.dark != dark; + oldDelegate.dots != dots || + oldDelegate.color != color || + oldDelegate.dark != dark; } diff --git a/lib/widget/views/consumption_rhythm_view.dart b/lib/widget/views/consumption_rhythm_view.dart index 755ae1bbc..53533a6dd 100644 --- a/lib/widget/views/consumption_rhythm_view.dart +++ b/lib/widget/views/consumption_rhythm_view.dart @@ -37,7 +37,9 @@ class ConsumptionRhythmView extends StatelessWidget { ? activity.sublist(activity.length - 30) : activity; final maxExpense = days.fold( - 0, (maximum, day) => day.expenseTotal > maximum ? day.expenseTotal : maximum); + 0, + (maximum, day) => + day.expenseTotal > maximum ? day.expenseTotal : maximum); final empty = maxExpense == 0; return Container( @@ -56,7 +58,8 @@ class ConsumptionRhythmView extends StatelessWidget { Container( width: 7, height: 7, - decoration: BoxDecoration(color: themeColor, shape: BoxShape.circle), + decoration: + BoxDecoration(color: themeColor, shape: BoxShape.circle), ), const SizedBox(width: 6), Text(titleLabel, @@ -66,7 +69,8 @@ class ConsumptionRhythmView extends StatelessWidget { fontWeight: FontWeight.w600)), const Spacer(), Text('近 30 天', - style: TextStyle(color: widgetTextTertiary(dark), fontSize: 10)), + style: + TextStyle(color: widgetTextTertiary(dark), fontSize: 10)), ], ), const SizedBox(height: 7), @@ -76,7 +80,11 @@ class ConsumptionRhythmView extends StatelessWidget { child: Text(emptyLabel, style: TextStyle( color: widgetTextTertiary(dark), fontSize: 12))) - : _HeatMap(days: days, maxExpense: maxExpense, color: themeColor, dark: dark), + : _HeatMap( + days: days, + maxExpense: maxExpense, + color: themeColor, + dark: dark), ), const SizedBox(height: 5), Text( @@ -84,7 +92,9 @@ class ConsumptionRhythmView extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: empty ? widgetTextTertiary(dark) : widgetTextSecondary(dark), + color: empty + ? widgetTextTertiary(dark) + : widgetTextSecondary(dark), fontSize: 11, fontWeight: FontWeight.w500), ), @@ -95,8 +105,9 @@ class ConsumptionRhythmView extends StatelessWidget { String _comparisonLabel(List days) { if (days.every((day) => day.expenseTotal == 0)) return emptyLabel; - final recent = days.skip((days.length - 7).clamp(0, days.length)).fold( - 0, (sum, day) => sum + day.expenseTotal); + final recent = days + .skip((days.length - 7).clamp(0, days.length)) + .fold(0, (sum, day) => sum + day.expenseTotal); final previousStart = (days.length - 14).clamp(0, days.length); final previousEnd = (days.length - 7).clamp(0, days.length); final previous = days @@ -149,7 +160,8 @@ class _HeatMap extends StatelessWidget { } Widget _cell(DailyWidgetActivity? day) { - final ratio = day == null || maxExpense == 0 ? 0.0 : day.expenseTotal / maxExpense; + final ratio = + day == null || maxExpense == 0 ? 0.0 : day.expenseTotal / maxExpense; final alpha = ratio == 0 ? (dark ? 0.10 : 0.08) : 0.22 + ratio * 0.70; return DecoratedBox( decoration: BoxDecoration( diff --git a/lib/widget/widget_data_service.dart b/lib/widget/widget_data_service.dart index 170ca3129..61f33e894 100644 --- a/lib/widget/widget_data_service.dart +++ b/lib/widget/widget_data_service.dart @@ -264,7 +264,8 @@ class WidgetDataService { /// `ref.watch(baseCurrencyProvider)` / `effectiveRatesProvider` 拿,这里 /// 直接把 [baseCurrency] 当参数传入,避免依赖 Riverpod。 static Future< - List<({DateTime date, double assets, double liabilities, double net})>> + List< + ({DateTime date, double assets, double liabilities, double net})>> gatherNetWorthTrend({ required BaseRepository repository, required String baseCurrency, @@ -317,8 +318,8 @@ class WidgetDataService { ); }).toList(); - items.sort((x, y) => - (y.convertedBalance ?? y.balance).compareTo(x.convertedBalance ?? x.balance)); + items.sort((x, y) => (y.convertedBalance ?? y.balance) + .compareTo(x.convertedBalance ?? x.balance)); return items.take(limit).toList(); } @@ -485,12 +486,15 @@ class WidgetDataService { final items = []; for (final t in txs) { - final category = - t.categoryId != null ? await repository.getCategoryById(t.categoryId!) : null; - final account = - t.accountId != null ? await repository.getAccount(t.accountId!) : null; - final toAccount = - t.toAccountId != null ? await repository.getAccount(t.toAccountId!) : null; + final category = t.categoryId != null + ? await repository.getCategoryById(t.categoryId!) + : null; + final account = t.accountId != null + ? await repository.getAccount(t.accountId!) + : null; + final toAccount = t.toAccountId != null + ? await repository.getAccount(t.toAccountId!) + : null; items.add(RecentTransactionItem( transaction: t, category: category, @@ -514,7 +518,8 @@ class WidgetDataService { int recentCount = 3, int quickAddCount = 4, }) async { - final glance = await gatherGlance(repository: repository, ledgerId: ledgerId); + final glance = + await gatherGlance(repository: repository, ledgerId: ledgerId); final end = trendTodayAnchor(); final start = end.subtract(const Duration(days: 29)); // 近 30 日(含今天) @@ -560,7 +565,8 @@ class WidgetDataService { final overrides = await repository.getOverrides(base); return mergeEffectiveRates( autoRates: [ - for (final r in autos) (quote: r.quoteCurrency, rate: r.rate, rateDate: r.rateDate) + for (final r in autos) + (quote: r.quoteCurrency, rate: r.rate, rateDate: r.rateDate) ], overrides: [ for (final o in overrides) (quote: o.quoteCurrency, rate: o.rate) @@ -613,14 +619,15 @@ class WidgetGatherBatch { }); Future? _glance; - Future glance() => _glance ??= - WidgetDataService.gatherGlance(repository: repository, ledgerId: ledgerId); + Future glance() => + _glance ??= WidgetDataService.gatherGlance( + repository: repository, ledgerId: ledgerId); Future>? _dailyActivity30; /// 近 30 个自然日的记账/消费活动,两个行为型小组件共用同一份聚合结果。 - Future> dailyActivity30() => _dailyActivity30 ??= - WidgetDataService.gatherDailyActivity( + Future> dailyActivity30() => + _dailyActivity30 ??= WidgetDataService.gatherDailyActivity( repository: repository, ledgerId: ledgerId); Future? _netWorthBreakdown; @@ -628,8 +635,14 @@ class WidgetGatherBatch { _netWorthBreakdown ??= WidgetDataService.gatherNetWorthBreakdown( repository: repository, baseCurrency: baseCurrency); - Future>? - _trend30; + Future< + List< + ({ + DateTime date, + double assets, + double liabilities, + double net + })>>? _trend30; /// 近 30 天(含今天)净值趋势,netWorth 三档与 dashboard 共用同一份。 Future> @@ -662,8 +675,8 @@ class WidgetGatherBatch { repository: repository, ledgerId: ledgerId, limit: 7); Future? _budget; - Future budget() => _budget ??= - WidgetDataService.gatherBudget(repository: repository, ledgerId: ledgerId); + Future budget() => _budget ??= WidgetDataService.gatherBudget( + repository: repository, ledgerId: ledgerId); Future>? _topShares; @@ -673,16 +686,16 @@ class WidgetGatherBatch { repository: repository, ledgerId: ledgerId); Future? _ledgerCurrency; - Future ledgerCurrency() => _ledgerCurrency ??= - WidgetDataService.gatherLedgerCurrency( + Future ledgerCurrency() => + _ledgerCurrency ??= WidgetDataService.gatherLedgerCurrency( repository: repository, ledgerId: ledgerId); Future>? _recent; /// 最近交易,按批次内最大需求(large 的 6 笔)取一次;medium(3)与 /// dashboard(2)由 View 内部 take 截断。 - Future> recent() => _recent ??= - WidgetDataService.gatherRecent( + Future> recent() => + _recent ??= WidgetDataService.gatherRecent( repository: repository, ledgerId: ledgerId, limit: 6); /// dashboard 组合数据:全部由本批次已 memo 的各份数据拼装,不再走 diff --git a/lib/widget/widget_manager.dart b/lib/widget/widget_manager.dart index e63e6ee7a..ed48cad30 100644 --- a/lib/widget/widget_manager.dart +++ b/lib/widget/widget_manager.dart @@ -998,8 +998,7 @@ class WidgetManager { logicalSize: logicalSize, pixelRatio: 3.0, ); - logger.warning( - _tag, '${spec.imageKey} 渲染出错,已用兜底卡覆盖(根因见上条 error 日志)'); + logger.warning(_tag, '${spec.imageKey} 渲染出错,已用兜底卡覆盖(根因见上条 error 日志)'); return; } diff --git a/lib/widget/widget_spec.dart b/lib/widget/widget_spec.dart index fc59242b3..e4cc13c47 100644 --- a/lib/widget/widget_spec.dart +++ b/lib/widget/widget_spec.dart @@ -361,8 +361,8 @@ class WidgetSpec { final iosHit = spec.iosKind != null && spec.iosKind == info.iOSKind && (spec.iosFamily == null || spec.iosFamily == info.iOSFamily); - final androidHit = - _androidClassMatches(info.androidClassName, spec.androidAllClassNames); + final androidHit = _androidClassMatches( + info.androidClassName, spec.androidAllClassNames); if (iosHit || androidHit) { matched.add(spec); } diff --git a/test/widget/bee_trail_view_test.dart b/test/widget/bee_trail_view_test.dart index c1be31417..492398a25 100644 --- a/test/widget/bee_trail_view_test.dart +++ b/test/widget/bee_trail_view_test.dart @@ -24,8 +24,7 @@ void main() { ); for (final dark in [false, true]) { - testWidgets('155x155 ${dark ? "暗色" : "亮色"}显示连续记账和完成率', - (tester) async { + testWidgets('155x155 ${dark ? "暗色" : "亮色"}显示连续记账和完成率', (tester) async { await tester.pumpWidget(wrap(BeeTrailView( activity: activity(), themeColor: const Color(0xFFF5A623), diff --git a/test/widget/consumption_rhythm_view_test.dart b/test/widget/consumption_rhythm_view_test.dart index de7451c42..e7300a5ab 100644 --- a/test/widget/consumption_rhythm_view_test.dart +++ b/test/widget/consumption_rhythm_view_test.dart @@ -13,7 +13,12 @@ void main() { 30, (index) => DailyWidgetActivity( date: DateTime(2026, 8, index + 1), - expenseTotal: switch (index % 4) { 0 => 0, 1 => 12, 2 => 45, _ => 120 }, + expenseTotal: switch (index % 4) { + 0 => 0, + 1 => 12, + 2 => 45, + _ => 120 + }, hasRecord: index.isEven, ), ); @@ -24,8 +29,7 @@ void main() { ); for (final dark in [false, true]) { - testWidgets('364x169 ${dark ? "暗色" : "亮色"}显示热力图和节奏提示', - (tester) async { + testWidgets('364x169 ${dark ? "暗色" : "亮色"}显示热力图和节奏提示', (tester) async { await tester.pumpWidget(wrap(ConsumptionRhythmView( activity: activity(), themeColor: const Color(0xFFF5A623), diff --git a/test/widget/widget_data_service_test.dart b/test/widget/widget_data_service_test.dart index dd9530b11..22297bd07 100644 --- a/test/widget/widget_data_service_test.dart +++ b/test/widget/widget_data_service_test.dart @@ -62,10 +62,7 @@ void main() { ledgerId: 2, type: 'expense', amount: 30, happenedAt: range.start); // 上一周期最后一天一笔支出,不应计入本周期 await repo.addTransaction( - ledgerId: 2, - type: 'expense', - amount: 999, - happenedAt: justBeforeRange); + ledgerId: 2, type: 'expense', amount: 999, happenedAt: justBeforeRange); final data = await WidgetDataService.gatherGlance(repository: repo, ledgerId: 2); @@ -74,8 +71,8 @@ void main() { }); test('账本不存在(getLedgerById 返回 null)时按自然月兜底,不抛异常', () async { - final data = await WidgetDataService.gatherGlance( - repository: repo, ledgerId: 999); + final data = + await WidgetDataService.gatherGlance(repository: repo, ledgerId: 999); expect(data.todayExpenseTotal, 0); expect(data.todayIncomeTotal, 0); @@ -246,7 +243,8 @@ void main() { final data = await WidgetDataService.gatherNetWorthBreakdown( repository: repo, baseCurrency: 'CNY'); - expect(data.totalAssets, closeTo(1650, 1e-9)); // 1000 + 100×6.5(override 优先) + expect( + data.totalAssets, closeTo(1650, 1e-9)); // 1000 + 100×6.5(override 优先) }); }); @@ -302,8 +300,7 @@ void main() { }); group('gatherNetWorthTopAccounts', () { - test('按折算余额降序;隐藏账户排除;缺汇率账户仍返回(用原币余额兜底排序)', - () async { + test('按折算余额降序;隐藏账户排除;缺汇率账户仍返回(用原币余额兜底排序)', () async { final cnyId = await repo.createAccount( ledgerId: 1, name: '现金', @@ -468,8 +465,7 @@ void main() { expect(items.single.total, 30); }); - test('本期用过的分类不足 limit 时,用其余可用支出分类按 sortOrder 补齐(total=0)', - () async { + test('本期用过的分类不足 limit 时,用其余可用支出分类按 sortOrder 补齐(total=0)', () async { final food = await repo.createCategory( name: '餐饮', kind: 'expense', icon: 'fastfood'); // 未在本期消费的分类:排在有支出的分类之后,total 记 0。 @@ -516,13 +512,29 @@ void main() { final now = DateTime.now(); await repo.addTransaction( - ledgerId: 1, type: 'expense', amount: 490, categoryId: cat1, happenedAt: now); + ledgerId: 1, + type: 'expense', + amount: 490, + categoryId: cat1, + happenedAt: now); await repo.addTransaction( - ledgerId: 1, type: 'expense', amount: 100, categoryId: cat2, happenedAt: now); + ledgerId: 1, + type: 'expense', + amount: 100, + categoryId: cat2, + happenedAt: now); await repo.addTransaction( - ledgerId: 1, type: 'expense', amount: 200, categoryId: cat3, happenedAt: now); + ledgerId: 1, + type: 'expense', + amount: 200, + categoryId: cat3, + happenedAt: now); await repo.addTransaction( - ledgerId: 1, type: 'expense', amount: 10, categoryId: cat4, happenedAt: now); + ledgerId: 1, + type: 'expense', + amount: 10, + categoryId: cat4, + happenedAt: now); final overview = await WidgetDataService.gatherBudget( repository: repo, ledgerId: 1, topCategoryCount: 2); @@ -548,10 +560,12 @@ void main() { group('gatherRecent', () { test('拼上分类(支出)与转入转出账户(转账)', () async { - final cat = - await repo.createCategory(name: '餐饮', kind: 'expense', icon: 'fastfood'); - final accA = await repo.createAccount(ledgerId: 1, name: 'A', currency: 'CNY'); - final accB = await repo.createAccount(ledgerId: 1, name: 'B', currency: 'CNY'); + final cat = await repo.createCategory( + name: '餐饮', kind: 'expense', icon: 'fastfood'); + final accA = + await repo.createAccount(ledgerId: 1, name: 'A', currency: 'CNY'); + final accB = + await repo.createAccount(ledgerId: 1, name: 'B', currency: 'CNY'); await repo.addTransaction( ledgerId: 1, @@ -602,7 +616,11 @@ void main() { final cat = await repo.createCategory(name: '餐饮', kind: 'expense'); final now = DateTime.now(); await repo.addTransaction( - ledgerId: 1, type: 'expense', amount: 50, categoryId: cat, happenedAt: now); + ledgerId: 1, + type: 'expense', + amount: 50, + categoryId: cat, + happenedAt: now); await repo.addTransaction( ledgerId: 1, type: 'income', amount: 80, happenedAt: now); diff --git a/test/widget/widget_manager_test.dart b/test/widget/widget_manager_test.dart index 4ee3c3dee..e5f092500 100644 --- a/test/widget/widget_manager_test.dart +++ b/test/widget/widget_manager_test.dart @@ -119,7 +119,8 @@ void main() { expect(matchInstalledSpecs(infos), [WidgetSpec.netWorthLarge]); }); - test('glance 小号补全:iOS 同 kind 的 systemSmall family 命中 glanceSmall,' + test( + 'glance 小号补全:iOS 同 kind 的 systemSmall family 命中 glanceSmall,' '不影响中号', () { expect( matchInstalledSpecs([ @@ -184,7 +185,8 @@ void main() { // 预热是 D5「只渲已安装」的显式例外:App 启动/切账本时把全部类型×尺寸的 // 图备好,用户随后添加任何组件都立刻有图(修「添加后要等一会」)。 expect(selectSpecsToRender(null, warmUpAll: true), WidgetSpec.catalog); - expect(selectSpecsToRender(const [], warmUpAll: true), WidgetSpec.catalog); + expect( + selectSpecsToRender(const [], warmUpAll: true), WidgetSpec.catalog); expect( selectSpecsToRender(const [WidgetSpec.glanceMedium], warmUpAll: true), WidgetSpec.catalog, diff --git a/test/widget/widget_spec_test.dart b/test/widget/widget_spec_test.dart index 67785214a..0c8fd99fb 100644 --- a/test/widget/widget_spec_test.dart +++ b/test/widget/widget_spec_test.dart @@ -55,8 +55,7 @@ void main() { (HWType.consumptionRhythm, HWSize.medium), (HWType.beeTrail, HWSize.small), }; - final actual = - WidgetSpec.catalog.map((s) => (s.type, s.size)).toSet(); + final actual = WidgetSpec.catalog.map((s) => (s.type, s.size)).toSet(); expect(actual, expected); }); From 927279c359c2c71f91e21be6466fcb6aedcbb616 Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 20:23:49 +0800 Subject: [PATCH 6/7] fix: refine widget behavior and localization --- .../app/src/main/res/values-en/strings.xml | 4 ++ .../app/src/main/res/values-ko/strings.xml | 7 +++ .../src/main/res/values-zh-rTW/strings.xml | 4 ++ .../main/res/xml/bee_trail_widget_info.xml | 2 +- .../xml/consumption_rhythm_widget_info.xml | 2 +- .../BeeCountBehaviorWidgets.swift | 46 ++++++++++++++++--- lib/l10n/app_en.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_ko.dart | 3 ++ lib/l10n/app_localizations_zh.dart | 6 +++ lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + .../settings/widget_management_page.dart | 1 + lib/providers/widget_provider.dart | 1 + lib/widget/views/bee_trail_view.dart | 6 +-- lib/widget/views/consumption_rhythm_view.dart | 4 +- lib/widget/widget_manager.dart | 7 +++ lib/widget/widget_spec.dart | 2 +- test/widget/bee_trail_view_test.dart | 26 +++++++++++ test/widget/consumption_rhythm_view_test.dart | 1 + 22 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/res/values-ko/strings.xml diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 5b8ac2bca..a87f47aab 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -16,6 +16,8 @@ Recent (Medium) Recent (Large) Dashboard + Spending Rhythm + Record Bee Trail Today\'s and this month\'s income & expenses @@ -25,4 +27,6 @@ Track budget progress at a glance A quick look at your latest transactions Income, trend, and recent activity in one view + See your spending pace across the last 30 days + Build a daily recording habit with a honeycomb trail diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml new file mode 100644 index 000000000..6962403de --- /dev/null +++ b/android/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,7 @@ + + + 소비 리듬 + 기록 꿀벌 궤적 + 최근 30일의 소비 흐름을 한눈에 확인하세요 + 벌집 칸으로 매일 기록하는 습관을 만들어 보세요 + diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index 71625b7f8..c54416409 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -16,6 +16,8 @@ 最近交易·中 最近交易·大 綜合儀表盤 + 消費節奏 + 記帳連續蜂跡 查看今日和本月的收支情況 @@ -25,4 +27,6 @@ 預算進度即時掌握 快速查看最近幾筆帳單 收支、趨勢與最近交易一屏看盡 + 近 30 天消費節奏一眼看清 + 用蜂巢格養成每日記帳習慣 diff --git a/android/app/src/main/res/xml/bee_trail_widget_info.xml b/android/app/src/main/res/xml/bee_trail_widget_info.xml index 02f771f58..73b937b25 100644 --- a/android/app/src/main/res/xml/bee_trail_widget_info.xml +++ b/android/app/src/main/res/xml/bee_trail_widget_info.xml @@ -1,5 +1,5 @@ diff --git a/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml b/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml index af41f42b5..154290898 100644 --- a/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml +++ b/android/app/src/main/res/xml/consumption_rhythm_widget_info.xml @@ -1,5 +1,5 @@ diff --git a/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift b/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift index 5f0e2159b..8e8449975 100644 --- a/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift +++ b/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift @@ -2,6 +2,24 @@ import WidgetKit import SwiftUI import UIKit +private enum BehaviorWidgetCopy { + static func localized( + simplifiedChinese: String, + traditionalChinese: String, + english: String, + korean: String + ) -> String { + let locale = Locale.current + switch locale.languageCode { + case "en": return english + case "ko": return korean + case "zh" where locale.scriptCode == "Hant" || locale.regionCode == "TW" || locale.regionCode == "HK": + return traditionalChinese + default: return simplifiedChinese + } + } +} + private struct BehaviorWidgetEntry: TimelineEntry { let date: Date let imagePath: String @@ -45,22 +63,38 @@ private struct BehaviorWidgetEntryView: View { struct BeeCountConsumptionRhythmWidget: Widget { let kind = "BeeCountConsumptionRhythmWidget" + private var title: String { + BehaviorWidgetCopy.localized(simplifiedChinese: "消费节奏", traditionalChinese: "消費節奏", english: "Spending Rhythm", korean: "소비 리듬") + } + private var description: String { + BehaviorWidgetCopy.localized(simplifiedChinese: "近 30 天消费节奏一眼看清", traditionalChinese: "近 30 天消費節奏一眼看清", english: "See your spending pace across the last 30 days", korean: "최근 30일의 소비 흐름을 한눈에 확인하세요") + } var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: BehaviorWidgetProvider(imageKey: "widget_consumptionRhythm_medium")) { entry in - BehaviorWidgetEntryView(entry: entry, destination: URL(string: "beecount://open?page=statistics")!, symbol: "chart.bar.fill", label: "消费节奏") + let widgetTitle = title + let widgetDescription = description + return StaticConfiguration(kind: kind, provider: BehaviorWidgetProvider(imageKey: "widget_consumptionRhythm_medium")) { entry in + BehaviorWidgetEntryView(entry: entry, destination: URL(string: "beecount://open?page=statistics")!, symbol: "chart.bar.fill", label: widgetTitle) } - .configurationDisplayName("消费节奏").description("近 30 天消费节奏一眼看清") + .configurationDisplayName(widgetTitle).description(widgetDescription) .supportedFamilies([.systemMedium]).contentMarginsDisabled() } } struct BeeCountBeeTrailWidget: Widget { let kind = "BeeCountBeeTrailWidget" + private var title: String { + BehaviorWidgetCopy.localized(simplifiedChinese: "记账连续蜂迹", traditionalChinese: "記帳連續蜂跡", english: "Record Bee Trail", korean: "기록 꿀벌 궤적") + } + private var description: String { + BehaviorWidgetCopy.localized(simplifiedChinese: "用蜂巢格养成每日记账习惯", traditionalChinese: "用蜂巢格養成每日記帳習慣", english: "Build a daily recording habit with a honeycomb trail", korean: "벌집 칸으로 매일 기록하는 습관을 만들어 보세요") + } var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: BehaviorWidgetProvider(imageKey: "widget_beeTrail_small")) { entry in - BehaviorWidgetEntryView(entry: entry, destination: URL(string: "beecount://open?page=transactions")!, symbol: "hexagon.fill", label: "记账连续蜂迹") + let widgetTitle = title + let widgetDescription = description + return StaticConfiguration(kind: kind, provider: BehaviorWidgetProvider(imageKey: "widget_beeTrail_small")) { entry in + BehaviorWidgetEntryView(entry: entry, destination: URL(string: "beecount://open?page=transactions")!, symbol: "hexagon.fill", label: widgetTitle) } - .configurationDisplayName("记账连续蜂迹").description("用蜂巢格养成每日记账习惯") + .configurationDisplayName(widgetTitle).description(widgetDescription) .supportedFamilies([.systemSmall]).contentMarginsDisabled() } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6050a8b83..8045b96bf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1730,6 +1730,7 @@ "widgetDashboardTitle": "This Month", "widgetGalleryDashboardDesc": "Income, trend and recent transactions in one view", "widgetConsumptionRhythmTitle": "Spending Rhythm", + "widgetConsumptionRhythmRange": "Last 30 days", "widgetConsumptionRhythmStable": "Spending is steady", "widgetConsumptionRhythmIncrease": "Faster than last week", "widgetConsumptionRhythmDecrease": "Steadier than last week", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7e43ce826..97af8006c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1213,6 +1213,7 @@ "widgetManagement": "홈 화면 위젯", "widgetManagementDesc": "홈 화면에서 수입과 지출을 빠르게 확인하세요", "widgetConsumptionRhythmTitle": "소비 리듬", + "widgetConsumptionRhythmRange": "최근 30일", "widgetConsumptionRhythmStable": "소비가 안정적이에요", "widgetConsumptionRhythmIncrease": "지난주보다 빨라요", "widgetConsumptionRhythmDecrease": "지난주보다 안정적이에요", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1930991e8..47425db7e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7724,6 +7724,12 @@ abstract class AppLocalizations { /// **'Spending Rhythm'** String get widgetConsumptionRhythmTitle; + /// No description provided for @widgetConsumptionRhythmRange. + /// + /// In en, this message translates to: + /// **'Last 30 days'** + String get widgetConsumptionRhythmRange; + /// No description provided for @widgetConsumptionRhythmStable. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 57a884166..1a7a33a15 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4045,6 +4045,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get widgetConsumptionRhythmTitle => 'Spending Rhythm'; + @override + String get widgetConsumptionRhythmRange => 'Last 30 days'; + @override String get widgetConsumptionRhythmStable => 'Spending is steady'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 3aff538eb..378cb674e 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4045,6 +4045,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get widgetConsumptionRhythmTitle => '소비 리듬'; + @override + String get widgetConsumptionRhythmRange => '최근 30일'; + @override String get widgetConsumptionRhythmStable => '소비가 안정적이에요'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 04ead513e..e978ea32d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4045,6 +4045,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get widgetConsumptionRhythmTitle => '消费节奏'; + @override + String get widgetConsumptionRhythmRange => '近 30 天'; + @override String get widgetConsumptionRhythmStable => '消费很均匀'; @@ -11590,6 +11593,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get widgetConsumptionRhythmTitle => '消費節奏'; + @override + String get widgetConsumptionRhythmRange => '近 30 天'; + @override String get widgetConsumptionRhythmStable => '消費很均勻'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7bee0ed24..1633c1767 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1594,6 +1594,7 @@ "widgetDashboardTitle": "本月概览", "widgetGalleryDashboardDesc": "收支、趋势与最近交易一屏看尽", "widgetConsumptionRhythmTitle": "消费节奏", + "widgetConsumptionRhythmRange": "近 30 天", "widgetConsumptionRhythmStable": "消费很均匀", "widgetConsumptionRhythmIncrease": "比上周更快", "widgetConsumptionRhythmDecrease": "比上周更稳", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 0b3eb7fdc..2b2524df2 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2360,6 +2360,7 @@ "widgetDashboardTitle": "本月概覽", "widgetGalleryDashboardDesc": "收支、趨勢與最近交易一屏看盡", "widgetConsumptionRhythmTitle": "消費節奏", + "widgetConsumptionRhythmRange": "近 30 天", "widgetConsumptionRhythmStable": "消費很均勻", "widgetConsumptionRhythmIncrease": "比上週更快", "widgetConsumptionRhythmDecrease": "比上週更穩", diff --git a/lib/pages/settings/widget_management_page.dart b/lib/pages/settings/widget_management_page.dart index 02735fbfe..150beaeb3 100644 --- a/lib/pages/settings/widget_management_page.dart +++ b/lib/pages/settings/widget_management_page.dart @@ -267,6 +267,7 @@ class WidgetManagementPage extends ConsumerWidget { themeColor: primaryColor, dark: dark, titleLabel: l10n.widgetConsumptionRhythmTitle, + rangeLabel: l10n.widgetConsumptionRhythmRange, stableLabel: l10n.widgetConsumptionRhythmStable, increaseLabel: l10n.widgetConsumptionRhythmIncrease, decreaseLabel: l10n.widgetConsumptionRhythmDecrease, diff --git a/lib/providers/widget_provider.dart b/lib/providers/widget_provider.dart index 4dfb424ec..9b48ce702 100644 --- a/lib/providers/widget_provider.dart +++ b/lib/providers/widget_provider.dart @@ -56,6 +56,7 @@ Future updateAppWidget(WidgetRef ref, BuildContext context) async { noTransactionsLabel: l10n.widgetNoTransactions, dashboardRecentLabel: l10n.widgetRecentTransactions, consumptionRhythmTitleLabel: l10n.widgetConsumptionRhythmTitle, + consumptionRhythmRangeLabel: l10n.widgetConsumptionRhythmRange, consumptionRhythmStableLabel: l10n.widgetConsumptionRhythmStable, consumptionRhythmIncreaseLabel: l10n.widgetConsumptionRhythmIncrease, consumptionRhythmDecreaseLabel: l10n.widgetConsumptionRhythmDecrease, diff --git a/lib/widget/views/bee_trail_view.dart b/lib/widget/views/bee_trail_view.dart index 29841618e..d5a96026f 100644 --- a/lib/widget/views/bee_trail_view.dart +++ b/lib/widget/views/bee_trail_view.dart @@ -33,11 +33,11 @@ class BeeTrailView extends StatelessWidget { @override Widget build(BuildContext context) { - final recorded = activity.where((day) => day.hasRecord).length; - final streak = _currentStreak(activity); final recent = activity.length > 28 ? activity.sublist(activity.length - 28) : activity; + final recorded = recent.where((day) => day.hasRecord).length; + final streak = _currentStreak(recent); final dots = [for (final day in recent) day.hasRecord]; while (dots.length < 28) { dots.insert(0, false); @@ -92,7 +92,7 @@ class BeeTrailView extends StatelessWidget { style: TextStyle( color: widgetTextTertiary(dark), fontSize: 9)), ), - Text('${(recorded / activity.length * 100).round()}%', + Text('${(recorded / recent.length * 100).round()}%', style: TextStyle( color: widgetTextSecondary(dark), fontSize: 10, diff --git a/lib/widget/views/consumption_rhythm_view.dart b/lib/widget/views/consumption_rhythm_view.dart index 53533a6dd..3de5b241a 100644 --- a/lib/widget/views/consumption_rhythm_view.dart +++ b/lib/widget/views/consumption_rhythm_view.dart @@ -10,6 +10,7 @@ class ConsumptionRhythmView extends StatelessWidget { final Color themeColor; final bool dark; final String titleLabel; + final String rangeLabel; final String stableLabel; final String increaseLabel; final String decreaseLabel; @@ -23,6 +24,7 @@ class ConsumptionRhythmView extends StatelessWidget { required this.themeColor, required this.dark, required this.titleLabel, + this.rangeLabel = 'Last 30 days', required this.stableLabel, required this.increaseLabel, required this.decreaseLabel, @@ -68,7 +70,7 @@ class ConsumptionRhythmView extends StatelessWidget { fontSize: 12, fontWeight: FontWeight.w600)), const Spacer(), - Text('近 30 天', + Text(rangeLabel, style: TextStyle(color: widgetTextTertiary(dark), fontSize: 10)), ], diff --git a/lib/widget/widget_manager.dart b/lib/widget/widget_manager.dart index ed48cad30..ce91122ac 100644 --- a/lib/widget/widget_manager.dart +++ b/lib/widget/widget_manager.dart @@ -212,6 +212,7 @@ class WidgetManager { // 记一笔)全部复用上面 glance/recent/quickAdd 已有的同名参数,不重复造词。 String dashboardRecentLabel = '最近交易', String consumptionRhythmTitleLabel = '消费节奏', + String consumptionRhythmRangeLabel = '近 30 天', String consumptionRhythmStableLabel = '消费很均匀', String consumptionRhythmIncreaseLabel = '比上周更快', String consumptionRhythmDecreaseLabel = '比上周更稳', @@ -296,6 +297,7 @@ class WidgetManager { noTransactionsLabel: noTransactionsLabel, dashboardRecentLabel: dashboardRecentLabel, consumptionRhythmTitleLabel: consumptionRhythmTitleLabel, + consumptionRhythmRangeLabel: consumptionRhythmRangeLabel, consumptionRhythmStableLabel: consumptionRhythmStableLabel, consumptionRhythmIncreaseLabel: consumptionRhythmIncreaseLabel, consumptionRhythmDecreaseLabel: consumptionRhythmDecreaseLabel, @@ -396,6 +398,7 @@ class WidgetManager { noTransactionsLabel: l10n.widgetNoTransactions, dashboardRecentLabel: l10n.widgetRecentTransactions, consumptionRhythmTitleLabel: l10n.widgetConsumptionRhythmTitle, + consumptionRhythmRangeLabel: l10n.widgetConsumptionRhythmRange, consumptionRhythmStableLabel: l10n.widgetConsumptionRhythmStable, consumptionRhythmIncreaseLabel: l10n.widgetConsumptionRhythmIncrease, consumptionRhythmDecreaseLabel: l10n.widgetConsumptionRhythmDecrease, @@ -454,6 +457,7 @@ class WidgetManager { required String noTransactionsLabel, required String dashboardRecentLabel, required String consumptionRhythmTitleLabel, + required String consumptionRhythmRangeLabel, required String consumptionRhythmStableLabel, required String consumptionRhythmIncreaseLabel, required String consumptionRhythmDecreaseLabel, @@ -552,6 +556,7 @@ class WidgetManager { themeColor: themeColor, dark: dark, titleLabel: consumptionRhythmTitleLabel, + rangeLabel: consumptionRhythmRangeLabel, stableLabel: consumptionRhythmStableLabel, increaseLabel: consumptionRhythmIncreaseLabel, decreaseLabel: consumptionRhythmDecreaseLabel, @@ -579,6 +584,7 @@ class WidgetManager { required Color themeColor, required bool dark, required String titleLabel, + required String rangeLabel, required String stableLabel, required String increaseLabel, required String decreaseLabel, @@ -589,6 +595,7 @@ class WidgetManager { themeColor: themeColor, dark: dark, titleLabel: titleLabel, + rangeLabel: rangeLabel, stableLabel: stableLabel, increaseLabel: increaseLabel, decreaseLabel: decreaseLabel, diff --git a/lib/widget/widget_spec.dart b/lib/widget/widget_spec.dart index e4cc13c47..a279ac561 100644 --- a/lib/widget/widget_spec.dart +++ b/lib/widget/widget_spec.dart @@ -42,7 +42,7 @@ class WidgetSpec { /// 渲染时使用的逻辑尺寸(pt/dp,对应 `HomeWidget.renderFlutterWidget` 的 /// `logicalSize`)。 /// - /// 全部 12 个 spec 均按此尺寸渲染;唯一例外是 [glanceMedium]——其渲染 + /// 全部 14 个 spec 均按此尺寸渲染;唯一例外是 [glanceMedium]——其渲染 /// 尺寸按平台(iOS 364×169 / Android 364×182)分叉、不直接取用这里的值 /// (见 `WidgetManager._renderGlance` 注释,属 D2 back-compat)。取值接近 /// iOS systemSmall/Medium/Large 的常见尺寸。 diff --git a/test/widget/bee_trail_view_test.dart b/test/widget/bee_trail_view_test.dart index 492398a25..19d769886 100644 --- a/test/widget/bee_trail_view_test.dart +++ b/test/widget/bee_trail_view_test.dart @@ -62,4 +62,30 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('今天记一笔,点亮第一格'), findsOneWidget); }); + + testWidgets('仅最早两天有记录时,28 天蜂巢显示为空状态', (tester) async { + final oldestOnly = List.generate( + 30, + (index) => DailyWidgetActivity( + date: DateTime(2026, 8, index + 1), + expenseTotal: 0, + hasRecord: index < 2, + ), + ); + await tester.pumpWidget(wrap(BeeTrailView( + activity: oldestOnly, + themeColor: const Color(0xFFF5A623), + dark: false, + titleLabel: '记账连续蜂迹', + streakSuffix: '天', + completionLabel: '本月完成率', + emptyLabel: '今天记一笔,点亮第一格', + width: size.width, + height: size.height, + ))); + await tester.pump(); + + expect(find.text('今天记一笔,点亮第一格'), findsOneWidget); + expect(find.text('7%'), findsNothing); + }); } diff --git a/test/widget/consumption_rhythm_view_test.dart b/test/widget/consumption_rhythm_view_test.dart index e7300a5ab..a715dcd02 100644 --- a/test/widget/consumption_rhythm_view_test.dart +++ b/test/widget/consumption_rhythm_view_test.dart @@ -46,6 +46,7 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('消费节奏'), findsOneWidget); + expect(find.text('Last 30 days'), findsOneWidget); expect(find.byType(ConsumptionRhythmView), findsOneWidget); }); } From 2429d6af72ae1511d195d30d7a9853fe70b01ebc Mon Sep 17 00:00:00 2001 From: likely Date: Mon, 31 Aug 2026 20:25:51 +0800 Subject: [PATCH 7/7] fix: clarify widget activity windows --- lib/l10n/app_en.arb | 4 ++-- lib/l10n/app_ko.arb | 4 ++-- lib/l10n/app_localizations.dart | 4 ++-- lib/l10n/app_localizations_en.dart | 4 ++-- lib/l10n/app_localizations_ko.dart | 4 ++-- lib/l10n/app_localizations_zh.dart | 8 ++++---- lib/l10n/app_zh.arb | 4 ++-- lib/l10n/app_zh_TW.arb | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8045b96bf..316a751c9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1734,10 +1734,10 @@ "widgetConsumptionRhythmStable": "Spending is steady", "widgetConsumptionRhythmIncrease": "Faster than last week", "widgetConsumptionRhythmDecrease": "Steadier than last week", - "widgetConsumptionRhythmEmpty": "No spending this month", + "widgetConsumptionRhythmEmpty": "No spending in the last 30 days", "widgetBeeTrailTitle": "Record Bee Trail", "widgetBeeTrailStreakSuffix": "days", - "widgetBeeTrailCompletion": "Monthly completion", + "widgetBeeTrailCompletion": "28-day completion", "widgetBeeTrailEmpty": "Add a record to light the first cell", "widgetGalleryConsumptionRhythmDesc": "See your spending pace across the last 30 days", "widgetGalleryBeeTrailDesc": "Build a gentle daily recording habit", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 97af8006c..19d4703bf 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1217,10 +1217,10 @@ "widgetConsumptionRhythmStable": "소비가 안정적이에요", "widgetConsumptionRhythmIncrease": "지난주보다 빨라요", "widgetConsumptionRhythmDecrease": "지난주보다 안정적이에요", - "widgetConsumptionRhythmEmpty": "이번 달 지출이 없어요", + "widgetConsumptionRhythmEmpty": "최근 30일 지출이 없어요", "widgetBeeTrailTitle": "기록 꿀벌 궤적", "widgetBeeTrailStreakSuffix": "일", - "widgetBeeTrailCompletion": "이번 달 완료율", + "widgetBeeTrailCompletion": "최근 28일 완료율", "widgetBeeTrailEmpty": "첫 칸을 밝히려면 기록을 추가하세요", "widgetGalleryConsumptionRhythmDesc": "최근 30일의 소비 흐름을 확인하세요", "widgetGalleryBeeTrailDesc": "벌집 칸으로 매일 기록하는 습관을 만드세요", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 47425db7e..992ecbab2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7751,7 +7751,7 @@ abstract class AppLocalizations { /// No description provided for @widgetConsumptionRhythmEmpty. /// /// In en, this message translates to: - /// **'No spending this month'** + /// **'No spending in the last 30 days'** String get widgetConsumptionRhythmEmpty; /// No description provided for @widgetBeeTrailTitle. @@ -7769,7 +7769,7 @@ abstract class AppLocalizations { /// No description provided for @widgetBeeTrailCompletion. /// /// In en, this message translates to: - /// **'Monthly completion'** + /// **'28-day completion'** String get widgetBeeTrailCompletion; /// No description provided for @widgetBeeTrailEmpty. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1a7a33a15..1246230cf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4058,7 +4058,7 @@ class AppLocalizationsEn extends AppLocalizations { String get widgetConsumptionRhythmDecrease => 'Steadier than last week'; @override - String get widgetConsumptionRhythmEmpty => 'No spending this month'; + String get widgetConsumptionRhythmEmpty => 'No spending in the last 30 days'; @override String get widgetBeeTrailTitle => 'Record Bee Trail'; @@ -4067,7 +4067,7 @@ class AppLocalizationsEn extends AppLocalizations { String get widgetBeeTrailStreakSuffix => 'days'; @override - String get widgetBeeTrailCompletion => 'Monthly completion'; + String get widgetBeeTrailCompletion => '28-day completion'; @override String get widgetBeeTrailEmpty => 'Add a record to light the first cell'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 378cb674e..9f08d2a2b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4058,7 +4058,7 @@ class AppLocalizationsKo extends AppLocalizations { String get widgetConsumptionRhythmDecrease => '지난주보다 안정적이에요'; @override - String get widgetConsumptionRhythmEmpty => '이번 달 지출이 없어요'; + String get widgetConsumptionRhythmEmpty => '최근 30일 지출이 없어요'; @override String get widgetBeeTrailTitle => '기록 꿀벌 궤적'; @@ -4067,7 +4067,7 @@ class AppLocalizationsKo extends AppLocalizations { String get widgetBeeTrailStreakSuffix => '일'; @override - String get widgetBeeTrailCompletion => '이번 달 완료율'; + String get widgetBeeTrailCompletion => '최근 28일 완료율'; @override String get widgetBeeTrailEmpty => '첫 칸을 밝히려면 기록을 추가하세요'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e978ea32d..0048a1829 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4058,7 +4058,7 @@ class AppLocalizationsZh extends AppLocalizations { String get widgetConsumptionRhythmDecrease => '比上周更稳'; @override - String get widgetConsumptionRhythmEmpty => '本月还没有支出'; + String get widgetConsumptionRhythmEmpty => '近 30 天还没有支出'; @override String get widgetBeeTrailTitle => '记账连续蜂迹'; @@ -4067,7 +4067,7 @@ class AppLocalizationsZh extends AppLocalizations { String get widgetBeeTrailStreakSuffix => '天'; @override - String get widgetBeeTrailCompletion => '本月完成率'; + String get widgetBeeTrailCompletion => '近 28 天完成率'; @override String get widgetBeeTrailEmpty => '今天记一笔,点亮第一格'; @@ -11606,7 +11606,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get widgetConsumptionRhythmDecrease => '比上週更穩'; @override - String get widgetConsumptionRhythmEmpty => '本月還沒有支出'; + String get widgetConsumptionRhythmEmpty => '近 30 天還沒有支出'; @override String get widgetBeeTrailTitle => '記帳連續蜂跡'; @@ -11615,7 +11615,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get widgetBeeTrailStreakSuffix => '天'; @override - String get widgetBeeTrailCompletion => '本月完成率'; + String get widgetBeeTrailCompletion => '近 28 天完成率'; @override String get widgetBeeTrailEmpty => '今天記一筆,點亮第一格'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1633c1767..19afb9e36 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1598,10 +1598,10 @@ "widgetConsumptionRhythmStable": "消费很均匀", "widgetConsumptionRhythmIncrease": "比上周更快", "widgetConsumptionRhythmDecrease": "比上周更稳", - "widgetConsumptionRhythmEmpty": "本月还没有支出", + "widgetConsumptionRhythmEmpty": "近 30 天还没有支出", "widgetBeeTrailTitle": "记账连续蜂迹", "widgetBeeTrailStreakSuffix": "天", - "widgetBeeTrailCompletion": "本月完成率", + "widgetBeeTrailCompletion": "近 28 天完成率", "widgetBeeTrailEmpty": "今天记一笔,点亮第一格", "widgetGalleryConsumptionRhythmDesc": "近 30 天消费节奏一眼看清", "widgetGalleryBeeTrailDesc": "用蜂巢格养成每日记账习惯", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 2b2524df2..154564a93 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2364,10 +2364,10 @@ "widgetConsumptionRhythmStable": "消費很均勻", "widgetConsumptionRhythmIncrease": "比上週更快", "widgetConsumptionRhythmDecrease": "比上週更穩", - "widgetConsumptionRhythmEmpty": "本月還沒有支出", + "widgetConsumptionRhythmEmpty": "近 30 天還沒有支出", "widgetBeeTrailTitle": "記帳連續蜂跡", "widgetBeeTrailStreakSuffix": "天", - "widgetBeeTrailCompletion": "本月完成率", + "widgetBeeTrailCompletion": "近 28 天完成率", "widgetBeeTrailEmpty": "今天記一筆,點亮第一格", "widgetGalleryConsumptionRhythmDesc": "近 30 天消費節奏一眼看清", "widgetGalleryBeeTrailDesc": "用蜂巢格養成每日記帳習慣",