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', () {