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" /> + + + + + + + + + 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/values/strings.xml b/android/app/src/main/res/values/strings.xml index 36a6095f3..bc09ad1ca 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ 最近交易·中 最近交易·大 综合仪表盘 + 消费节奏 + 记账连续蜂迹 查看今日和本月的收支情况 @@ -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..73b937b25 --- /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..154290898 --- /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..8e8449975 --- /dev/null +++ b/ios/BeeCountWidget/BeeCountBehaviorWidgets.swift @@ -0,0 +1,100 @@ +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 +} + +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" + 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 { + 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(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 { + 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(widgetTitle).description(widgetDescription) + .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() } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b3341331f..316a751c9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1729,6 +1729,18 @@ "widgetGalleryDashboardTitle": "Dashboard", "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", + "widgetConsumptionRhythmEmpty": "No spending in the last 30 days", + "widgetBeeTrailTitle": "Record Bee Trail", + "widgetBeeTrailStreakSuffix": "days", + "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", "widgetSizeSmall": "Small", "widgetSizeMedium": "Medium", "widgetSizeLarge": "Large", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 349435bae..19d4703bf 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1212,6 +1212,18 @@ "welcomeCloudSyncFeature3": "또는 iCloud / WebDAV / Supabase / S3 중 선택", "widgetManagement": "홈 화면 위젯", "widgetManagementDesc": "홈 화면에서 수입과 지출을 빠르게 확인하세요", + "widgetConsumptionRhythmTitle": "소비 리듬", + "widgetConsumptionRhythmRange": "최근 30일", + "widgetConsumptionRhythmStable": "소비가 안정적이에요", + "widgetConsumptionRhythmIncrease": "지난주보다 빨라요", + "widgetConsumptionRhythmDecrease": "지난주보다 안정적이에요", + "widgetConsumptionRhythmEmpty": "최근 30일 지출이 없어요", + "widgetBeeTrailTitle": "기록 꿀벌 궤적", + "widgetBeeTrailStreakSuffix": "일", + "widgetBeeTrailCompletion": "최근 28일 완료율", + "widgetBeeTrailEmpty": "첫 칸을 밝히려면 기록을 추가하세요", + "widgetGalleryConsumptionRhythmDesc": "최근 30일의 소비 흐름을 확인하세요", + "widgetGalleryBeeTrailDesc": "벌집 칸으로 매일 기록하는 습관을 만드세요", "widgetPreview": "위젯 미리보기", "widgetPreviewDesc": "위젯은 현재 가계부의 실제 데이터를 자동으로 표시하며, 테마 색상은 앱 설정을 따릅니다", "howToAddWidget": "위젯 추가 방법", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c4dd52ddd..992ecbab2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7718,6 +7718,78 @@ 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 @widgetConsumptionRhythmRange. + /// + /// In en, this message translates to: + /// **'Last 30 days'** + String get widgetConsumptionRhythmRange; + + /// 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 in the last 30 days'** + 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: + /// **'28-day 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..1246230cf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4042,6 +4042,42 @@ 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 widgetConsumptionRhythmRange => 'Last 30 days'; + + @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 in the last 30 days'; + + @override + String get widgetBeeTrailTitle => 'Record Bee Trail'; + + @override + String get widgetBeeTrailStreakSuffix => 'days'; + + @override + String get widgetBeeTrailCompletion => '28-day 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..9f08d2a2b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4042,6 +4042,42 @@ class AppLocalizationsKo extends AppLocalizations { @override String get widgetGalleryDashboardDesc => 'Income, trend and recent transactions in one view'; + @override + String get widgetConsumptionRhythmTitle => '소비 리듬'; + + @override + String get widgetConsumptionRhythmRange => '최근 30일'; + + @override + String get widgetConsumptionRhythmStable => '소비가 안정적이에요'; + + @override + String get widgetConsumptionRhythmIncrease => '지난주보다 빨라요'; + + @override + String get widgetConsumptionRhythmDecrease => '지난주보다 안정적이에요'; + + @override + String get widgetConsumptionRhythmEmpty => '최근 30일 지출이 없어요'; + + @override + String get widgetBeeTrailTitle => '기록 꿀벌 궤적'; + + @override + String get widgetBeeTrailStreakSuffix => '일'; + + @override + String get widgetBeeTrailCompletion => '최근 28일 완료율'; + + @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..0048a1829 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4042,6 +4042,42 @@ class AppLocalizationsZh extends AppLocalizations { @override String get widgetGalleryDashboardDesc => '收支、趋势与最近交易一屏看尽'; + @override + String get widgetConsumptionRhythmTitle => '消费节奏'; + + @override + String get widgetConsumptionRhythmRange => '近 30 天'; + + @override + String get widgetConsumptionRhythmStable => '消费很均匀'; + + @override + String get widgetConsumptionRhythmIncrease => '比上周更快'; + + @override + String get widgetConsumptionRhythmDecrease => '比上周更稳'; + + @override + String get widgetConsumptionRhythmEmpty => '近 30 天还没有支出'; + + @override + String get widgetBeeTrailTitle => '记账连续蜂迹'; + + @override + String get widgetBeeTrailStreakSuffix => '天'; + + @override + String get widgetBeeTrailCompletion => '近 28 天完成率'; + + @override + String get widgetBeeTrailEmpty => '今天记一笔,点亮第一格'; + + @override + String get widgetGalleryConsumptionRhythmDesc => '近 30 天消费节奏一眼看清'; + + @override + String get widgetGalleryBeeTrailDesc => '用蜂巢格养成每日记账习惯'; + @override String get widgetSizeSmall => '小号'; @@ -11554,6 +11590,42 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get widgetGalleryDashboardDesc => '收支、趨勢與最近交易一屏看盡'; + @override + String get widgetConsumptionRhythmTitle => '消費節奏'; + + @override + String get widgetConsumptionRhythmRange => '近 30 天'; + + @override + String get widgetConsumptionRhythmStable => '消費很均勻'; + + @override + String get widgetConsumptionRhythmIncrease => '比上週更快'; + + @override + String get widgetConsumptionRhythmDecrease => '比上週更穩'; + + @override + String get widgetConsumptionRhythmEmpty => '近 30 天還沒有支出'; + + @override + String get widgetBeeTrailTitle => '記帳連續蜂跡'; + + @override + String get widgetBeeTrailStreakSuffix => '天'; + + @override + String get widgetBeeTrailCompletion => '近 28 天完成率'; + + @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..19afb9e36 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1593,6 +1593,18 @@ "widgetGalleryDashboardTitle": "综合仪表盘", "widgetDashboardTitle": "本月概览", "widgetGalleryDashboardDesc": "收支、趋势与最近交易一屏看尽", + "widgetConsumptionRhythmTitle": "消费节奏", + "widgetConsumptionRhythmRange": "近 30 天", + "widgetConsumptionRhythmStable": "消费很均匀", + "widgetConsumptionRhythmIncrease": "比上周更快", + "widgetConsumptionRhythmDecrease": "比上周更稳", + "widgetConsumptionRhythmEmpty": "近 30 天还没有支出", + "widgetBeeTrailTitle": "记账连续蜂迹", + "widgetBeeTrailStreakSuffix": "天", + "widgetBeeTrailCompletion": "近 28 天完成率", + "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..154564a93 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2359,6 +2359,18 @@ "widgetGalleryDashboardTitle": "綜合儀表盤", "widgetDashboardTitle": "本月概覽", "widgetGalleryDashboardDesc": "收支、趨勢與最近交易一屏看盡", + "widgetConsumptionRhythmTitle": "消費節奏", + "widgetConsumptionRhythmRange": "近 30 天", + "widgetConsumptionRhythmStable": "消費很均勻", + "widgetConsumptionRhythmIncrease": "比上週更快", + "widgetConsumptionRhythmDecrease": "比上週更穩", + "widgetConsumptionRhythmEmpty": "近 30 天還沒有支出", + "widgetBeeTrailTitle": "記帳連續蜂跡", + "widgetBeeTrailStreakSuffix": "天", + "widgetBeeTrailCompletion": "近 28 天完成率", + "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..150beaeb3 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,50 @@ 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, + rangeLabel: l10n.widgetConsumptionRhythmRange, + 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)), // 添加指引 @@ -641,7 +688,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, @@ -693,6 +741,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() { @@ -718,9 +778,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/providers/widget_provider.dart b/lib/providers/widget_provider.dart index 309c03beb..9b48ce702 100644 --- a/lib/providers/widget_provider.dart +++ b/lib/providers/widget_provider.dart @@ -55,6 +55,16 @@ Future updateAppWidget(WidgetRef ref, BuildContext context) async { uncategorizedLabel: l10n.commonUncategorized, noTransactionsLabel: l10n.widgetNoTransactions, dashboardRecentLabel: l10n.widgetRecentTransactions, + consumptionRhythmTitleLabel: l10n.widgetConsumptionRhythmTitle, + consumptionRhythmRangeLabel: l10n.widgetConsumptionRhythmRange, + 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/views/bee_trail_view.dart b/lib/widget/views/bee_trail_view.dart new file mode 100644 index 000000000..d5a96026f --- /dev/null +++ b/lib/widget/views/bee_trail_view.dart @@ -0,0 +1,168 @@ +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 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); + } + + 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 / recent.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..3de5b241a --- /dev/null +++ b/lib/widget/views/consumption_rhythm_view.dart @@ -0,0 +1,175 @@ +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 rangeLabel; + 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, + this.rangeLabel = 'Last 30 days', + 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(rangeLabel, + 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/lib/widget/widget_data_service.dart b/lib/widget/widget_data_service.dart index 93f0c82d6..61f33e894 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) // --------------------------------------------------------------------- @@ -203,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, @@ -256,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(); } @@ -424,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, @@ -453,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 日(含今天) @@ -499,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) @@ -552,16 +619,30 @@ 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( + repository: repository, ledgerId: ledgerId); Future? _netWorthBreakdown; Future netWorthBreakdown() => _netWorthBreakdown ??= WidgetDataService.gatherNetWorthBreakdown( repository: repository, baseCurrency: baseCurrency); - Future>? - _trend30; + Future< + List< + ({ + DateTime date, + double assets, + double liabilities, + double net + })>>? _trend30; /// 近 30 天(含今天)净值趋势,netWorth 三档与 dashboard 共用同一份。 Future> @@ -594,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; @@ -605,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 0d5d625c5..ce91122ac 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,16 @@ class WidgetManager { // `widgetRecentTransactions`。其余文案(本月支出/收入、未分类、暂无交易、 // 记一笔)全部复用上面 glance/recent/quickAdd 已有的同名参数,不重复造词。 String dashboardRecentLabel = '最近交易', + String consumptionRhythmTitleLabel = '消费节奏', + String consumptionRhythmRangeLabel = '近 30 天', + String consumptionRhythmStableLabel = '消费很均匀', + String consumptionRhythmIncreaseLabel = '比上周更快', + String consumptionRhythmDecreaseLabel = '比上周更稳', + String consumptionRhythmEmptyLabel = '本月还没有支出', + String beeTrailTitleLabel = '记账连续蜂迹', + String beeTrailStreakSuffix = '天', + String beeTrailCompletionLabel = '本月完成率', + String beeTrailEmptyLabel = '今天记一笔,点亮第一格', // 预热:true 时渲染整个 [WidgetSpec.catalog] 而非仅"已安装"(D5 的显式 // 例外)。用于 App 启动 / 切账本这类低频时机,把全部类型×尺寸的图先备好 // ——否则用户添加一个从未渲染过的组件类型时,共享存储里没有对应图片, @@ -284,6 +296,16 @@ class WidgetManager { uncategorizedLabel: uncategorizedLabel, noTransactionsLabel: noTransactionsLabel, dashboardRecentLabel: dashboardRecentLabel, + consumptionRhythmTitleLabel: consumptionRhythmTitleLabel, + consumptionRhythmRangeLabel: consumptionRhythmRangeLabel, + consumptionRhythmStableLabel: consumptionRhythmStableLabel, + consumptionRhythmIncreaseLabel: consumptionRhythmIncreaseLabel, + consumptionRhythmDecreaseLabel: consumptionRhythmDecreaseLabel, + consumptionRhythmEmptyLabel: consumptionRhythmEmptyLabel, + beeTrailTitleLabel: beeTrailTitleLabel, + beeTrailStreakSuffix: beeTrailStreakSuffix, + beeTrailCompletionLabel: beeTrailCompletionLabel, + beeTrailEmptyLabel: beeTrailEmptyLabel, ); } catch (e, st) { // 单个 spec 渲染失败不应阻断其余 spec。 @@ -375,6 +397,16 @@ class WidgetManager { uncategorizedLabel: l10n.commonUncategorized, noTransactionsLabel: l10n.widgetNoTransactions, dashboardRecentLabel: l10n.widgetRecentTransactions, + consumptionRhythmTitleLabel: l10n.widgetConsumptionRhythmTitle, + consumptionRhythmRangeLabel: l10n.widgetConsumptionRhythmRange, + 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 +456,16 @@ class WidgetManager { required String uncategorizedLabel, required String noTransactionsLabel, required String dashboardRecentLabel, + required String consumptionRhythmTitleLabel, + required String consumptionRhythmRangeLabel, + 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 +549,95 @@ class WidgetManager { titleLabel: dashboardTitleLabel, ); return; + case HWType.consumptionRhythm: + await _renderConsumptionRhythm( + spec, + batch: batch, + themeColor: themeColor, + dark: dark, + titleLabel: consumptionRhythmTitleLabel, + rangeLabel: consumptionRhythmRangeLabel, + 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 rangeLabel, + 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, + rangeLabel: rangeLabel, + 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, { @@ -877,8 +1005,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 9ffbe0928..a279ac561 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 对应 /// 网格尺寸。 @@ -33,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 的常见尺寸。 @@ -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 版本过低 / 平台调用 @@ -329,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 new file mode 100644 index 000000000..19d769886 --- /dev/null +++ b/test/widget/bee_trail_view_test.dart @@ -0,0 +1,91 @@ +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); + }); + + 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 new file mode 100644 index 000000000..a715dcd02 --- /dev/null +++ b/test/widget/consumption_rhythm_view_test.dart @@ -0,0 +1,78 @@ +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.text('Last 30 days'), 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); + }); +} diff --git a/test/widget/widget_data_service_test.dart b/test/widget/widget_data_service_test.dart index 3a51a5c50..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); @@ -83,6 +80,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( @@ -192,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 优先) }); }); @@ -248,8 +300,7 @@ void main() { }); group('gatherNetWorthTopAccounts', () { - test('按折算余额降序;隐藏账户排除;缺汇率账户仍返回(用原币余额兜底排序)', - () async { + test('按折算余额降序;隐藏账户排除;缺汇率账户仍返回(用原币余额兜底排序)', () async { final cnyId = await repo.createAccount( ledgerId: 1, name: '现金', @@ -414,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。 @@ -462,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); @@ -494,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, @@ -548,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 ca2e8703a..0c8fd99fb 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,9 +52,10 @@ 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(); + final actual = WidgetSpec.catalog.map((s) => (s.type, s.size)).toSet(); expect(actual, expected); }); @@ -71,6 +72,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', () {