diff --git a/.gitignore b/.gitignore
index 2200e7c3..44e142d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,4 +53,5 @@ app.*.map.json
/download
lib/libobjectbox.dylib
-lib/libobjectbox.so
\ No newline at end of file
+lib/libobjectbox.sovibe_git_config.txt
+vibe_git_config.txt
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 20c7dc05..1c1787a1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -116,6 +116,16 @@
android:name="android.appwidget.provider"
android:resource="@xml/summary_widget_info" />
+
+
+
+
+
+
+ get() = HomeWidgetGlanceStateDefinition()
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ provideContent {
+ GlanceTheme {
+ Content(context, currentState())
+ }
+ }
+ }
+}
+
+private data class CategoryRow(
+ val name: String,
+ val spentRaw: Double,
+ val spentDisplay: String,
+ val rawDisplay: String
+)
+
+/**
+ * Parsed theme colors from Flutter's FlowColorScheme, bridged via SharedPreferences.
+ * Used by the Flow-style widget to match the app's selected theme.
+ */
+private data class ThemeColors(
+ val surface: Color,
+ val onSurface: Color,
+ val primary: Color,
+ val secondary: Color,
+ val onSecondary: Color,
+ val income: Color,
+ val expense: Color,
+ val semi: Color,
+ val isDark: Boolean,
+)
+
+/** Parse a single color from SharedPreferences, with a hex fallback. */
+private fun parseColor(prefs: HomeWidgetGlanceState, key: String, fallback: Long): Color {
+ val raw = prefs.preferences.getString(key, null)?.toLongOrNull() ?: fallback
+ // Dart Color.value is a 32-bit ARGB int; Compose Color() expects the same
+ return Color(raw.toInt())
+}
+
+/** Read all theme colors from SharedPreferences bridged by Flutter. */
+private fun readThemeColors(prefs: HomeWidgetGlanceState): ThemeColors {
+ val isDarkStr = prefs.preferences.getString("theme_isDark", null) ?: "true"
+ return ThemeColors(
+ surface = parseColor(prefs, "theme_surface", 0xFFF5F6FA),
+ onSurface = parseColor(prefs, "theme_onSurface", 0xFF111111),
+ primary = parseColor(prefs, "theme_primary", 0xFF8600A5),
+ secondary = parseColor(prefs, "theme_secondary", 0xFFF5CCFF),
+ onSecondary = parseColor(prefs, "theme_onSecondary", 0xFF33004F),
+ income = parseColor(prefs, "theme_income", 0xFF32CC70),
+ expense = parseColor(prefs, "theme_expense", 0xFFFF4040),
+ semi = parseColor(prefs, "theme_semi", 0xFF6A666D),
+ isDark = isDarkStr == "true",
+ )
+}
+
+@Composable
+private fun Content(context: Context, currentState: HomeWidgetGlanceState) {
+ val countStr = currentState.preferences.getString("ynab_count", null) ?: "0"
+ val count = countStr.toIntOrNull() ?: 0
+ val style = currentState.preferences.getString("ynab_style", null) ?: "flow"
+
+ val categories = (0 until count).mapNotNull { i ->
+ val name = currentState.preferences.getString("ynab_${i}_name", null) ?: return@mapNotNull null
+ val spentStr = currentState.preferences.getString("ynab_${i}_spent", null) ?: "0.00"
+ val display = currentState.preferences.getString("ynab_${i}_display", null) ?: spentStr
+ val rawDisplay = currentState.preferences.getString("ynab_${i}_raw_display", null) ?: spentStr
+ CategoryRow(name, spentStr.toDoubleOrNull() ?: 0.0, display, rawDisplay)
+ }
+
+ // Tap action: open app to add a new expense transaction
+ val launchIntent = Intent(context, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = Uri.parse("flow-mn:///transaction/new?type=expense")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ when (style) {
+ "amoled" -> AmoledContent(context, categories, launchIntent)
+ else -> FlowContent(context, categories, launchIntent, currentState)
+ }
+}
+
+// =============================================================================
+// Shared: Amount Pill Badge — colored rounded rectangle with contrast text
+// =============================================================================
+
+@Composable
+private fun AmountPill(
+ amount: String,
+ spentRaw: Double,
+ isAmoled: Boolean,
+ themeColors: ThemeColors? = null,
+) {
+ // Pill background color
+ val pillBg: ColorProvider
+ val textColor: ColorProvider
+
+ if (isAmoled) {
+ // AMOLED: hardcoded YNAB colors (unchanged)
+ pillBg = when {
+ spentRaw < -0.001 -> ColorProvider(R.color.expense_red)
+ spentRaw > 0.001 -> ColorProvider(R.color.income_green)
+ else -> ColorProvider(R.color.ynab_pill_zero)
+ }
+ textColor = when {
+ spentRaw < -0.001 -> ColorProvider(Color.White)
+ spentRaw > 0.001 -> ColorProvider(Color(0xFF1A1A1A))
+ else -> ColorProvider(Color.White)
+ }
+ } else if (themeColors != null) {
+ // Flow: theme-aware colors from the app
+ pillBg = when {
+ spentRaw < -0.001 -> ColorProvider(themeColors.expense)
+ spentRaw > 0.001 -> ColorProvider(themeColors.income)
+ else -> ColorProvider(themeColors.semi)
+ }
+ // Contrast: dark text on light pills, light text on dark pills
+ textColor = when {
+ spentRaw < -0.001 -> ColorProvider(if (themeColors.isDark) Color.White else Color(0xFF1A1A1A))
+ spentRaw > 0.001 -> ColorProvider(Color(0xFF1A1A1A)) // income green is always light
+ else -> ColorProvider(if (themeColors.isDark) Color.White else Color(0xFF1A1A1A))
+ }
+ } else {
+ // Fallback: GlanceTheme (Material You)
+ pillBg = when {
+ spentRaw < -0.001 -> ColorProvider(R.color.expense_red)
+ spentRaw > 0.001 -> ColorProvider(R.color.income_green)
+ else -> GlanceTheme.colors.surfaceVariant
+ }
+ textColor = when {
+ spentRaw < -0.001 -> ColorProvider(Color.White)
+ spentRaw > 0.001 -> ColorProvider(Color.White)
+ else -> GlanceTheme.colors.onSurfaceVariant
+ }
+ }
+
+ Box(
+ modifier = GlanceModifier
+ .background(pillBg)
+ .cornerRadius(if (isAmoled) 16.dp else 12.dp)
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = amount,
+ style = TextStyle(
+ color = textColor,
+ fontSize = if (isAmoled) 15.sp else 14.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 1,
+ )
+ }
+}
+
+// =============================================================================
+// YNAB AMOLED Style — Pure black, flat rows, thin dividers, pill badges
+// =============================================================================
+
+@Composable
+private fun AmoledContent(
+ context: Context,
+ categories: List,
+ launchIntent: Intent
+) {
+ if (categories.isEmpty()) {
+ // Empty state: full background with centered message
+ Box(
+ modifier = GlanceModifier
+ .background(ColorProvider(R.color.ynab_background))
+ .fillMaxSize()
+ .clickable(onClick = actionStartActivity(launchIntent)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No categories yet",
+ style = TextStyle(
+ color = ColorProvider(R.color.ynab_zero_gray),
+ fontSize = 14.sp,
+ ),
+ )
+ }
+ } else {
+ // Any non-empty list → LazyColumn as ROOT for proper scrolling
+ // Each items{} block emits a SINGLE root Column to avoid Glance auto-Box wrapping
+ LazyColumn(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(ColorProvider(R.color.ynab_background))
+ ) {
+ items(categories.size) { index ->
+ val category = categories[index]
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ AmoledCategoryRow(category, launchIntent)
+ if (index < categories.lastIndex) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(start = 14.dp, end = 8.dp)
+ ) {
+ Spacer(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(ColorProvider(R.color.ynab_divider))
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AmoledCategoryRow(category: CategoryRow, launchIntent: Intent) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .clickable(onClick = actionStartActivity(launchIntent))
+ .padding(start = 14.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Category name (left) — light gray, truncates with ellipsis
+ Text(
+ text = category.name,
+ style = TextStyle(
+ color = ColorProvider(R.color.ynab_text_primary),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ maxLines = 1,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+
+ Spacer(modifier = GlanceModifier.width(8.dp))
+
+ // Amount pill — colored rounded rectangle
+ AmountPill(
+ amount = category.rawDisplay,
+ spentRaw = category.spentRaw,
+ isAmoled = true,
+ )
+ }
+}
+
+// =============================================================================
+// Flow Style — Theme-aware colors from the app, flat rows, dividers, pill badges
+// =============================================================================
+
+@Composable
+private fun FlowContent(
+ context: Context,
+ categories: List,
+ launchIntent: Intent,
+ currentState: HomeWidgetGlanceState
+) {
+ // Read theme colors bridged from Flutter
+ val theme = readThemeColors(currentState)
+ val surfaceBg = ColorProvider(theme.surface)
+ // Divider: semi color at reduced opacity (blend with surface)
+ val dividerColor = Color(
+ red = theme.semi.red * 0.3f + theme.surface.red * 0.7f,
+ green = theme.semi.green * 0.3f + theme.surface.green * 0.7f,
+ blue = theme.semi.blue * 0.3f + theme.surface.blue * 0.7f,
+ alpha = 1f,
+ )
+
+ if (categories.isEmpty()) {
+ // Empty state
+ Box(
+ modifier = GlanceModifier
+ .background(surfaceBg)
+ .fillMaxSize()
+ .clickable(onClick = actionStartActivity(launchIntent)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No categories yet",
+ style = TextStyle(
+ color = ColorProvider(theme.semi),
+ fontSize = 14.sp,
+ ),
+ )
+ }
+ } else {
+ // Any non-empty list → LazyColumn as ROOT for proper scrolling
+ // Each items{} block emits a SINGLE root Column to avoid Glance auto-Box wrapping
+ LazyColumn(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(surfaceBg)
+ ) {
+ items(categories.size) { index ->
+ val category = categories[index]
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ FlowCategoryRow(category, launchIntent, theme)
+ if (index < categories.lastIndex) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 8.dp)
+ ) {
+ Spacer(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(ColorProvider(dividerColor))
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FlowCategoryRow(
+ category: CategoryRow,
+ launchIntent: Intent,
+ theme: ThemeColors
+) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .clickable(onClick = actionStartActivity(launchIntent))
+ .padding(start = 12.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Category name (left) — uses theme onSurface color
+ Text(
+ text = category.name,
+ style = TextStyle(
+ color = ColorProvider(theme.onSurface),
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ maxLines = 1,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+
+ Spacer(modifier = GlanceModifier.width(8.dp))
+
+ // Amount pill — theme-aware colors, no currency (rawDisplay)
+ AmountPill(
+ amount = category.rawDisplay,
+ spentRaw = category.spentRaw,
+ isAmoled = false,
+ themeColors = theme,
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/YnabBudgetReceiver.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/YnabBudgetReceiver.kt
new file mode 100644
index 00000000..f8d1837b
--- /dev/null
+++ b/android/app/src/main/kotlin/mn/flow/flow/glance/YnabBudgetReceiver.kt
@@ -0,0 +1,8 @@
+package mn.flow.flow.glance
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+class YnabBudgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = YnabBudget()
+}
diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml
index afda8985..77f53607 100644
--- a/android/app/src/main/res/values-night/widget_colors.xml
+++ b/android/app/src/main/res/values-night/widget_colors.xml
@@ -1,5 +1,13 @@
- #FF32CC70
- #FFFF4040
+ #FF72B822
+ #FFBF313D
+ #FF000000
+ #FFE0E0E0
+ #FF333333
+ #FF606060
+ #FF3C3E53
+
+
+
diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml
index afda8985..77f53607 100644
--- a/android/app/src/main/res/values/widget_colors.xml
+++ b/android/app/src/main/res/values/widget_colors.xml
@@ -1,5 +1,13 @@
- #FF32CC70
- #FFFF4040
+ #FF72B822
+ #FFBF313D
+ #FF000000
+ #FFE0E0E0
+ #FF333333
+ #FF606060
+ #FF3C3E53
+
+
+
diff --git a/android/app/src/main/res/xml/ynab_budget_widget_info.xml b/android/app/src/main/res/xml/ynab_budget_widget_info.xml
new file mode 100644
index 00000000..9aa10364
--- /dev/null
+++ b/android/app/src/main/res/xml/ynab_budget_widget_info.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt
index 246c977c..76df3b56 100644
--- a/assets/fonts/OFL.txt
+++ b/assets/fonts/OFL.txt
@@ -1,93 +1,93 @@
-Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-http://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
+Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/lib/l10n/flow_localizations.dart b/lib/l10n/flow_localizations.dart
index 2414b715..982bf102 100644
--- a/lib/l10n/flow_localizations.dart
+++ b/lib/l10n/flow_localizations.dart
@@ -191,6 +191,7 @@ class _FlowLocalizationDelegate
);
await localization.load();
unawaited(WidgetSummarySync.sync().catchError((_) {}));
+ unawaited(WidgetSummarySync.syncYnabWidget().catchError((_) {}));
return localization;
}
diff --git a/lib/main.dart b/lib/main.dart
index ec88eb70..62759046 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,4 +1,4 @@
-// Flow - A personal finance tracking app
+ // Flow - A personal finance tracking app
//
// Copyright (C) 2024 Batmend Ganbaatar and authors of Flow
@@ -151,6 +151,7 @@ void main() async {
}
TransactionsService().addListener(() => WidgetSummarySync.sync());
+ TransactionsService().addListener(() => WidgetSummarySync.syncYnabWidget());
try {
Moment.minValue = DateTime(0);
@@ -437,6 +438,7 @@ class FlowState extends State {
void _syncWidgets() {
WidgetSummarySync.sync();
+ WidgetSummarySync.syncYnabWidget();
}
void _synchronizePlannedNotifications() {
diff --git a/lib/prefs/local_preferences.dart b/lib/prefs/local_preferences.dart
index 2c9a3d88..bf243542 100644
--- a/lib/prefs/local_preferences.dart
+++ b/lib/prefs/local_preferences.dart
@@ -60,6 +60,13 @@ class LocalPreferences {
/// Used to prevent id collisions
late final PrimitiveSettingsEntry notificationsIssuedCount;
+ /// Comma-separated UUIDs of categories pinned to the YNAB analytics widget.
+ /// Stored as a raw string to avoid jsonDecode(null) crashes on first install.
+ late final PrimitiveSettingsEntry ynabWidgetCategoryUuids;
+
+ /// Visual style for the YNAB widget: "amoled" or "flow" (default).
+ late final PrimitiveSettingsEntry ynabWidgetStyle;
+
late final PendingTransactionsLocalPreferences pendingTransactions;
late final TransitiveLocalPreferences transitive;
late final EnyLocalPreferences eny;
@@ -153,6 +160,16 @@ class LocalPreferences {
initialValue: 0,
);
+ ynabWidgetCategoryUuids = PrimitiveSettingsEntry(
+ key: "ynabWidgetCategoryUuids",
+ preferences: _prefs,
+ );
+
+ ynabWidgetStyle = PrimitiveSettingsEntry(
+ key: "ynabWidgetStyle",
+ preferences: _prefs,
+ );
+
pendingTransactions = PendingTransactionsLocalPreferences.initialize(
_prefs,
);
diff --git a/lib/routes.dart b/lib/routes.dart
index 18d95a55..c56b2db9 100644
--- a/lib/routes.dart
+++ b/lib/routes.dart
@@ -40,6 +40,7 @@ import "package:flow/routes/preferences/transaction_geo_preferences_page.dart";
import "package:flow/routes/preferences/transaction_list_item_appearance_preferences_page.dart";
import "package:flow/routes/preferences/transfer_preferences_page.dart";
import "package:flow/routes/preferences/trash_bin_preferences_page.dart";
+import "package:flow/routes/preferences/widget_preferences_page.dart";
import "package:flow/routes/preferences_page.dart";
import "package:flow/routes/profile_page.dart";
import "package:flow/routes/setup/setup_accounts_page.dart";
@@ -292,6 +293,10 @@ final GoRouter router = GoRouter(
path: "integrations/eny",
builder: (context, state) => const EnyPreferencesPage(),
),
+ GoRoute(
+ path: "widget",
+ builder: (context, state) => const WidgetPreferencesPage(),
+ ),
],
),
GoRoute(path: "/profile", builder: (context, state) => const ProfilePage()),
diff --git a/lib/routes/preferences/widget_preferences_page.dart b/lib/routes/preferences/widget_preferences_page.dart
new file mode 100644
index 00000000..862acb69
--- /dev/null
+++ b/lib/routes/preferences/widget_preferences_page.dart
@@ -0,0 +1,168 @@
+import "dart:async";
+
+import "package:flow/entity/category.dart";
+import "package:flow/objectbox.dart";
+import "package:flow/prefs/local_preferences.dart";
+import "package:flow/services/widget_summary_sync.dart";
+import "package:flow/widgets/general/list_header.dart";
+import "package:flutter/material.dart";
+import "package:material_symbols_icons/symbols.dart";
+
+class WidgetPreferencesPage extends StatefulWidget {
+ const WidgetPreferencesPage({super.key});
+
+ @override
+ State createState() => _WidgetPreferencesPageState();
+}
+
+class _WidgetPreferencesPageState extends State {
+ late String _currentStyle;
+ List _allCategories = [];
+ Set _selectedUuids = {};
+ bool _showAll = true;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _currentStyle = LocalPreferences().ynabWidgetStyle.value ?? "flow";
+
+ // Load pinned category UUIDs
+ final String? rawUuids =
+ LocalPreferences().ynabWidgetCategoryUuids.value;
+ if (rawUuids == null || rawUuids.isEmpty) {
+ _showAll = true;
+ _selectedUuids = {};
+ } else {
+ _showAll = false;
+ _selectedUuids =
+ rawUuids.split(",").where((s) => s.isNotEmpty).toSet();
+ }
+
+ // Load all categories from ObjectBox
+ _loadCategories();
+ }
+
+ Future _loadCategories() async {
+ final categories = ObjectBox().box().getAll();
+ if (mounted) {
+ setState(() {
+ _allCategories = categories;
+ // If show all, select all UUIDs
+ if (_showAll) {
+ _selectedUuids = categories.map((c) => c.uuid).toSet();
+ }
+ });
+ }
+ }
+
+ void _onStyleChanged(String? value) {
+ if (value == null) return;
+ setState(() {
+ _currentStyle = value;
+ });
+ LocalPreferences().ynabWidgetStyle.set(value);
+ _syncWidget();
+ }
+
+ void _onCategoryToggled(String uuid, bool selected) {
+ setState(() {
+ if (selected) {
+ _selectedUuids.add(uuid);
+ } else {
+ _selectedUuids.remove(uuid);
+ }
+ _showAll = _selectedUuids.length == _allCategories.length;
+ });
+ _saveCategories();
+ }
+
+ void _onSelectAll(bool selectAll) {
+ setState(() {
+ _showAll = selectAll;
+ if (selectAll) {
+ _selectedUuids = _allCategories.map((c) => c.uuid).toSet();
+ } else {
+ _selectedUuids.clear();
+ }
+ });
+ _saveCategories();
+ }
+
+ void _saveCategories() {
+ if (_showAll) {
+ // Empty string = show all (default behavior)
+ LocalPreferences().ynabWidgetCategoryUuids.set("");
+ } else {
+ LocalPreferences().ynabWidgetCategoryUuids.set(
+ _selectedUuids.join(","),
+ );
+ }
+ _syncWidget();
+ }
+
+ void _syncWidget() {
+ unawaited(WidgetSummarySync.syncYnabWidget().catchError((_) {}));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(title: const Text("Widget Settings")),
+ body: SafeArea(
+ child: ListView(
+ children: [
+ const SizedBox(height: 8.0),
+ const ListHeader("Style"),
+ const SizedBox(height: 8.0),
+ RadioListTile(
+ title: const Text("Flow"),
+ value: "flow",
+ groupValue: _currentStyle,
+ onChanged: _onStyleChanged,
+ activeColor: colorScheme.primary,
+ secondary: const Icon(Symbols.palette_rounded),
+ ),
+ RadioListTile(
+ title: const Text("AMOLED"),
+ value: "amoled",
+ groupValue: _currentStyle,
+ onChanged: _onStyleChanged,
+ activeColor: colorScheme.primary,
+ secondary: const Icon(Symbols.dark_mode_rounded),
+ ),
+ const SizedBox(height: 24.0),
+ const ListHeader("Categories"),
+ const SizedBox(height: 8.0),
+ SwitchListTile(
+ title: const Text("Show all categories"),
+ subtitle: const Text(
+ "When enabled, all categories with activity this month are shown",
+ ),
+ value: _showAll,
+ onChanged: (value) => _onSelectAll(value),
+ activeColor: colorScheme.primary,
+ secondary: const Icon(Symbols.select_all_rounded),
+ ),
+ if (!_showAll) ...[
+ const Divider(),
+ ..._allCategories.map((category) {
+ final isSelected = _selectedUuids.contains(category.uuid);
+ return CheckboxListTile(
+ title: Text(category.name),
+ value: isSelected,
+ onChanged: (value) =>
+ _onCategoryToggled(category.uuid, value ?? false),
+ activeColor: colorScheme.primary,
+ );
+ }),
+ ],
+ const SizedBox(height: 16.0),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/routes/preferences_page.dart b/lib/routes/preferences_page.dart
index cb39626a..caf107e4 100644
--- a/lib/routes/preferences_page.dart
+++ b/lib/routes/preferences_page.dart
@@ -239,6 +239,17 @@ class PreferencesPageState extends State {
onTap: () => _pushAndRefreshAfter("/preferences/changeVisuals"),
trailing: const LeChevron(),
),
+ ListTile(
+ title: const Text("Widget Settings"),
+ leading: const Icon(Symbols.widgets_rounded),
+ onTap: () => _pushAndRefreshAfter("/preferences/widget"),
+ subtitle: Text(
+ LocalPreferences().ynabWidgetStyle.value == "amoled"
+ ? "AMOLED"
+ : "Flow",
+ ),
+ trailing: const LeChevron(),
+ ),
const SizedBox(height: 24.0),
ListHeader("preferences.privacy".t(context)),
const SizedBox(height: 8.0),
diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart
index 12ca6085..91f0e84a 100644
--- a/lib/services/user_preferences.dart
+++ b/lib/services/user_preferences.dart
@@ -466,6 +466,7 @@ class UserPreferencesService {
ensurePrimaryAccountAvailability();
_updateButtonsWidgets(transactionButtonOrder);
WidgetSummarySync.sync();
+ WidgetSummarySync.syncYnabWidget();
})
.catchError((e) {
_log.warning("Failed to update widgets button order on init: $e");
diff --git a/lib/services/widget_summary_sync.dart b/lib/services/widget_summary_sync.dart
index 67156797..4a9c7144 100644
--- a/lib/services/widget_summary_sync.dart
+++ b/lib/services/widget_summary_sync.dart
@@ -2,14 +2,20 @@ import "dart:io";
import "package:flow/constants.dart";
import "package:flow/data/exchange_rates.dart";
+import "package:flow/data/flow_analytics.dart";
+import "package:flow/data/multi_currency_flow.dart";
import "package:flow/data/single_currency_flow.dart";
+import "package:flow/entity/category.dart";
import "package:flow/entity/transaction.dart";
import "package:flow/l10n/extensions.dart";
import "package:flow/l10n/named_enum.dart";
import "package:flow/objectbox.dart";
import "package:flow/objectbox/actions.dart";
+import "package:flow/prefs/local_preferences.dart";
import "package:flow/services/exchange_rates.dart";
import "package:flow/services/user_preferences.dart";
+import "package:flow/theme/color_themes/registry.dart";
+import "package:flow/theme/flow_color_scheme.dart";
import "package:home_widget/home_widget.dart";
import "package:logging/logging.dart";
import "package:moment_dart/moment_dart.dart";
@@ -74,4 +80,147 @@ class WidgetSummarySync {
_log.warning("Failed to sync summary widget: $e");
}
}
+
+ /// Syncs the YNAB-style category analytics widget.
+ ///
+ /// Reads pinned category UUIDs from [LocalPreferences], queries
+ /// month-to-date spending per category via [ObjectBox.flowByCategories],
+ /// and sends flat primitive strings to the Android widget via [HomeWidget].
+ static Future syncYnabWidget() async {
+ try {
+ final String primaryCurrency =
+ UserPreferencesService().primaryCurrency;
+ final ExchangeRates? rates =
+ ExchangeRatesService().getPrimaryCurrencyRates();
+
+ // Read pinned category UUIDs (comma-separated string, null-safe)
+ final String? rawUuids =
+ LocalPreferences().ynabWidgetCategoryUuids.value;
+ final List pinnedUuids = (rawUuids == null || rawUuids.isEmpty)
+ ? []
+ : rawUuids.split(",").where((s) => s.isNotEmpty).toList();
+
+ if (Platform.isIOS) {
+ await HomeWidget.setAppGroupId(iOSAppGroupId);
+ }
+
+ // If no categories are pinned, show all categories
+ final TimeRange range = TimeRange.thisMonth();
+ final FlowAnalytics analytics =
+ await ObjectBox().flowByCategories(range: range);
+
+ // Determine which UUIDs to display
+ List displayUuids;
+ if (pinnedUuids.isEmpty) {
+ // Show all categories with activity this month (scrollable)
+ displayUuids = analytics.flow.keys.toList();
+ } else {
+ displayUuids = pinnedUuids.toList();
+ }
+
+ final int count = displayUuids.length;
+ await HomeWidget.saveWidgetData("ynab_count", count.toString());
+
+ for (int i = 0; i < count; i++) {
+ final String uuid = displayUuids[i];
+ final MultiCurrencyFlow? categoryFlow =
+ analytics.flow[uuid];
+
+ final String categoryName =
+ categoryFlow?.associatedData?.name ?? "category.none".tr();
+
+ // Merge multi-currency flow into primary currency
+ final SingleCurrencyFlow merged = categoryFlow?.merge(
+ primaryCurrency,
+ rates,
+ ) ??
+ SingleCurrencyFlow(currency: primaryCurrency);
+
+ // Total flow = income + expense (expense is negative)
+ final double totalFlow = merged.flow;
+ final String spentRaw = totalFlow.toStringAsFixed(2);
+ final String spentFormatted =
+ merged.totalFlow.abs().formatMoney(compact: true);
+
+ // Raw display: signed numbers, no currency symbol (for YNAB AMOLED style)
+ // Negative for expenses (e.g. "-5500"), positive for income (e.g. "250")
+ final String rawFormatted = totalFlow.abs() < 0.01
+ ? "0"
+ : totalFlow >= 100 || totalFlow <= -100
+ ? totalFlow.toStringAsFixed(0)
+ : totalFlow.toStringAsFixed(2);
+
+ await HomeWidget.saveWidgetData("ynab_${i}_name", categoryName);
+ await HomeWidget.saveWidgetData("ynab_${i}_spent", spentRaw);
+ await HomeWidget.saveWidgetData("ynab_${i}_display", spentFormatted);
+ await HomeWidget.saveWidgetData("ynab_${i}_raw_display", rawFormatted);
+ }
+
+ // Clear stale slots (in case we previously had more categories)
+ for (int i = count; i < 20; i++) {
+ await HomeWidget.saveWidgetData("ynab_${i}_name", null);
+ await HomeWidget.saveWidgetData("ynab_${i}_spent", null);
+ await HomeWidget.saveWidgetData("ynab_${i}_display", null);
+ await HomeWidget.saveWidgetData("ynab_${i}_raw_display", null);
+ }
+
+ // Send style preference to native widget
+ final String? style = LocalPreferences().ynabWidgetStyle.value;
+ await HomeWidget.saveWidgetData("ynab_style", style ?? "flow");
+
+ // Bridge theme colors from the app's theme system to the widget
+ // This allows the Flow-style widget to match the app's selected theme
+ final String currentThemeName = UserPreferencesService().themeName;
+ final FlowColorScheme scheme = getTheme(currentThemeName);
+
+ await HomeWidget.saveWidgetData(
+ "theme_surface",
+ scheme.surface.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_onSurface",
+ scheme.onSurface.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_primary",
+ scheme.primary.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_secondary",
+ scheme.secondary.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_onSecondary",
+ (scheme.onSecondary ?? scheme.onSurface).toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_income",
+ scheme.customColors.income.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_expense",
+ scheme.customColors.expense.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_semi",
+ scheme.customColors.semi.toARGB32().toString(),
+ );
+ await HomeWidget.saveWidgetData(
+ "theme_isDark",
+ scheme.isDark.toString(),
+ );
+
+ await HomeWidget.updateWidget(
+ name: "FlowYnabBudgetWidget",
+ androidName: "YnabBudgetReceiver",
+ qualifiedAndroidName: "mn.flow.flow.glance.YnabBudgetReceiver",
+ );
+
+ _log.finest(
+ "Synced YNAB widget: count=$count",
+ );
+ } catch (e) {
+ _log.warning("Failed to sync YNAB widget: $e");
+ }
+ }
}
diff --git a/reformat_l10n_files.sh b/reformat_l10n_files.sh
old mode 100755
new mode 100644
diff --git a/regenerate_code.sh b/regenerate_code.sh
old mode 100755
new mode 100644
diff --git a/test/import/invalid-1.csv b/test/import/invalid-1.csv
index 1979ed19..f8be6305 100644
--- a/test/import/invalid-1.csv
+++ b/test/import/invalid-1.csv
@@ -1,22 +1,22 @@
-flow_title,flow_notes,flow_account_name,flow_amount,flow_date_of_transaction,flow_time_of_transaction_optional,flow_date_of_transaction_iso_8601,flow_category_optional
-iCloud+,,Golomt bank,-250.00,2024-02-24,13:58:24,,Online expenses
-Birthday gift for Paul,,Savings,-4245.12,02/20/2021,20 02,,Gifts
-Salary,Received it little early due to upcoming holidays,Golomt bank,680,2025.3.24,01:13 p.m.,,Paycheck
-Keychron K8,,Savings,-89.2,2025.3.26,21:00:00,,Shopping
-From Main to Savings,,Main,-250,,,2025-03-14T08:25:56.887Z,
-From Main to Savings,,Savings,250,,,2025-03-14T08:25:56.887Z,
-Gift for Stella,,Main,-99.01,,,2025-03-14T08:25:56.494Z,Gifts
-Paycheck (last month),,Main,680.98,,,2025-03-14T08:25:56.485Z,Paycheck
-Iced Mocha,,Main,-6.5,,,2025-03-13T08:25:56.475Z,Drinks & Beverages
-Iced Mocha,,Main,-6.5,,,2025-03-12T08:25:56.466Z,Drinks & Beverages
-Iced Mocha,,Main,-6.5,,,2025-03-11T08:25:56.457Z,Drinks & Beverages
-Netflix,,Main,-15.49,,,2025-03-11T08:25:56.447Z,Online subscriptions
-iCloud,,Main,-1.99,,,2025-03-11T08:25:56.437Z,Online subscriptions
-Initial balance,,Main,420.69,,,2025-03-10T08:25:56.419Z,
-Rent,,Savings,-1960,,,2025-03-09T08:25:56.512Z,Rent
-Savings initial balance,,Savings,69420,,,2025-03-09T08:25:56.503Z,
-A transfer,,Savings,-500,,,2025-03-09T08:25:56.503Z,
-This doesn't matter,doesn't matter,Main,500,,,2025-03-09T08:25:56.503Z,
-Ice cream,,Main,-6570,,,2025-03-15T07:25:56.503Z,Food
-Another transfer,,Savings,6541,,,2025-03-15T07:25:59.503Z,
+flow_title,flow_notes,flow_account_name,flow_amount,flow_date_of_transaction,flow_time_of_transaction_optional,flow_date_of_transaction_iso_8601,flow_category_optional
+iCloud+,,Golomt bank,-250.00,2024-02-24,13:58:24,,Online expenses
+Birthday gift for Paul,,Savings,-4245.12,02/20/2021,20 02,,Gifts
+Salary,Received it little early due to upcoming holidays,Golomt bank,680,2025.3.24,01:13 p.m.,,Paycheck
+Keychron K8,,Savings,-89.2,2025.3.26,21:00:00,,Shopping
+From Main to Savings,,Main,-250,,,2025-03-14T08:25:56.887Z,
+From Main to Savings,,Savings,250,,,2025-03-14T08:25:56.887Z,
+Gift for Stella,,Main,-99.01,,,2025-03-14T08:25:56.494Z,Gifts
+Paycheck (last month),,Main,680.98,,,2025-03-14T08:25:56.485Z,Paycheck
+Iced Mocha,,Main,-6.5,,,2025-03-13T08:25:56.475Z,Drinks & Beverages
+Iced Mocha,,Main,-6.5,,,2025-03-12T08:25:56.466Z,Drinks & Beverages
+Iced Mocha,,Main,-6.5,,,2025-03-11T08:25:56.457Z,Drinks & Beverages
+Netflix,,Main,-15.49,,,2025-03-11T08:25:56.447Z,Online subscriptions
+iCloud,,Main,-1.99,,,2025-03-11T08:25:56.437Z,Online subscriptions
+Initial balance,,Main,420.69,,,2025-03-10T08:25:56.419Z,
+Rent,,Savings,-1960,,,2025-03-09T08:25:56.512Z,Rent
+Savings initial balance,,Savings,69420,,,2025-03-09T08:25:56.503Z,
+A transfer,,Savings,-500,,,2025-03-09T08:25:56.503Z,
+This doesn't matter,doesn't matter,Main,500,,,2025-03-09T08:25:56.503Z,
+Ice cream,,Main,-6570,,,2025-03-15T07:25:56.503Z,Food
+Another transfer,,Savings,6541,,,2025-03-15T07:25:59.503Z,
Another transfer,,Golomt bank,-6541,,,2025-03-15T07:25:58.503Z,
\ No newline at end of file
diff --git a/test/import/valid-1.csv b/test/import/valid-1.csv
index 191cada4..4920693c 100644
--- a/test/import/valid-1.csv
+++ b/test/import/valid-1.csv
@@ -1,82 +1,82 @@
-flow_title,flow_notes,flow_account_name,flow_amount,flow_date_of_transaction,flow_time_of_transaction_optional,flow_date_of_transaction_iso_8601,flow_category_optional
-Apple music,,Golomt bank,-152.00,2022-02-24,23:59:59,,Online expenses
-iCloud+,,Golomt bank,-250.00,2024-02-24,13:58:24,,Online expenses
-Salary,Received it little early due to upcoming holidays,Golomt bank,680,2025.3.24,01:13 p.m.,,Paycheck
-Keychron K8,,Savings,-89.2,2025.3.26,21:00:00,,Shopping
-Iced Matcha Latte,,Golomt bank,-5.2,2025.3.26,11:54:59,,Drinks & Beverages
-Aoaoao,,Savings,-81.2,2022.12.31,12:59:59,,Shopping
-From Main to Savings,,Main,-250,,,2025-03-14T08:25:56.887Z,
-From Main to Savings,,Savings,250,,,2025-03-14T08:25:56.887Z,
-Gift for Stella,,Main,-99.01,,,2025-03-14T08:25:56.494Z,Gifts
-Paycheck (last month),,Main,680.98,,,2025-03-14T08:25:56.485Z,Paycheck
-Iced Mocha,,Main,-6.5,,,2025-03-13T08:25:56.475Z,Drinks & Beverages
-Iced Mocha,,Main,-6.5,,,2025-03-12T08:25:56.466Z,Drinks & Beverages
-Iced Mocha,,Main,-6.5,,,2025-03-11T08:25:56.457Z,Drinks & Beverages
-Netflix,,Main,-15.49,,,2025-03-11T08:25:56.447Z,Online subscriptions
-iCloud,,Main,-1.99,,,2025-03-11T08:25:56.437Z,Online subscriptions
-Initial balance,,Main,420.69,,,2025-03-10T08:25:56.419Z,
-Rent,,Savings,-1960,,,2025-03-09T08:25:56.512Z,Rent
-Savings initial balance,,Savings,69420,,,2025-03-09T08:25:56.503Z,
-A transfer,,Savings,-500,,,2025-03-09T08:25:56.503Z,
-This doesn't matter,doesn't matter,Main,500,,,2025-03-09T08:25:56.503Z,
-Ice cream,,Main,-6570,,,2025-03-15T07:25:56.503Z,Food
-Another transfer,,Savings,6541,,,2025-03-15T07:25:59.503Z,
-Another transfer,,Golomt bank,-6541,,,2025-03-15T07:25:58.503Z,
-Groceries,,Main,-125.67,,,2025-03-16T09:15:22.123Z,Food
-Dinner with friends,,Main,-78.50,,,2025-03-16T19:45:12.345Z,Food
-Gas station,,Main,-45.30,,,2025-03-17T14:22:33.456Z,Transportation
-Movie tickets,,Main,-24.99,,,2025-03-18T20:30:45.678Z,Entertainment
-Amazon purchase,,Main,-67.89,,,2025-03-19T11:28:37.890Z,Shopping
-Coffee shop,,Main,-4.75,,,2025-03-19T08:15:42.123Z,Drinks & Beverages
-Pharmacy,,Main,-32.47,,,2025-03-20T16:45:21.234Z,Health
-Monthly bonus,,Main,150.00,,,2025-03-21T08:30:56.345Z,Paycheck
-Uber ride,,Main,-18.25,,,2025-03-21T23:15:27.456Z,Transportation
-Book store,,Main,-29.99,,,2025-03-22T14:25:38.567Z,Entertainment
-Haircut,,Main,-35.00,,,2025-03-23T13:20:49.678Z,Personal care
-Spotify,,Main,-9.99,,,2025-03-24T00:01:50.789Z,Online subscriptions
-Transfer to Savings,,Main,-300.00,,,2025-03-24T09:45:51.890Z,
-Transfer from Main,,Savings,300.00,,,2025-03-24T09:45:51.890Z,
-Gym membership,,Main,-49.99,,,2025-03-25T07:30:52.901Z,Health
-Restaurant lunch,,Main,-22.50,,,2025-03-25T12:15:53.012Z,Food
-Electricity bill,,Main,-85.32,,,2025-03-26T15:40:54.123Z,Utilities
-Internet bill,,Main,-59.99,,,2025-03-26T15:45:55.234Z,Utilities
-Phone bill,,Main,-75.00,,,2025-03-26T15:50:56.345Z,Utilities
-Birthday gift,,Main,-45.75,,,2025-03-27T17:25:57.456Z,Gifts
-Online course,,Main,-199.00,,,2025-03-28T10:10:58.567Z,Education
-Bakery,,Main,-12.50,,,2025-03-28T08:35:59.678Z,Food
-Side gig income,,Main,250.00,,,2025-03-29T16:20:00.789Z,Other income
-Hardware store,,Main,-78.23,,,2025-03-30T14:15:01.890Z,Home
-Pet supplies,,Main,-62.47,,,2025-03-31T11:30:02.901Z,Pets
-Donation,,Main,-25.00,,,2025-04-01T09:45:03.012Z,Charity
-Tax refund,,Main,750.00,,,2025-04-02T13:20:04.123Z,Other income
-Car maintenance,,Main,-210.75,,,2025-04-03T15:35:05.234Z,Transportation
-Concert tickets,,Main,-85.00,,,2025-04-04T18:50:06.345Z,Entertainment
-Clothing store,,Main,-145.67,,,2025-04-05T12:25:07.456Z,Shopping
-Dividend payment,,Savings,37.80,,,2025-04-06T00:01:08.567Z,Investment
-Interest earned,,Savings,12.45,,,2025-04-06T00:05:09.678Z,Interest
-Plumber service,,Main,-175.00,,,2025-04-07T11:30:10.789Z,Home
-Office supplies,,Main,-28.99,,,2025-04-08T14:45:11.890Z,Work
-Parking fee,,Main,-15.00,,,2025-04-09T09:20:12.901Z,Transportation
-Video game purchase,,Main,-59.99,,,2025-04-10T19:15:13.012Z,Entertainment
-Dentist appointment,,Main,-125.00,,,2025-04-11T10:30:14.123Z,Health
-Freelance payment,,Main,350.00,,,2025-04-12T16:45:15.234Z,Other income
-Cash withdrawal,,Main,-100.00,,,2025-04-13T11:50:16.345Z,Cash
-Bookstore purchase,,Main,-42.99,2025-04-14,15:30:45,,Books
-Grocery shopping,,Main,-87.54,2025-04-15,18:25:12,,Food
-Monthly subscription,,Main,-12.99,2025-04-16,00:01:30,,Online subscriptions
-Bus ticket,,Main,-2.50,2025-04-16,08:15:22,,Transportation
-Lunch with colleagues,,Main,-28.75,2025-04-16,12:30:45,,Food
-Savings transfer,,Main,-200.00,2025-04-17,09:00:00,,
-Savings deposit,,Savings,200.00,2025-04-17,09:00:00,,
-Mobile app purchase,,Main,-4.99,2025-04-18,19:45:33,,Entertainment
-Coffee shop visit,,Main,-5.25,2025-04-19,10:15:00,,Drinks & Beverages
-Weekend groceries,,Main,-65.32,2025-04-20,11:20:15,,Food
-Home insurance,,Main,-42.50,2025-04-21,08:00:00,,Insurance
-Water bill,,Main,-38.75,2025-04-22,14:30:00,,Utilities
-Gift for mom,,Main,-75.00,2025-04-23,16:45:22,,Gifts
-Salary deposit,,Main,2450.00,2025-04-25,09:15:00,,Paycheck
-Furniture purchase,,Savings,-350.00,2025-04-26,13:20:45,,Home
-Pharmacy items,,Main,-18.99,2025-04-27,15:30:00,,Health
-Fast food lunch,,Main,-12.50,2025-04-28,12:45:15,,Food
-Train ticket,,Main,-22.50,2025-04-29,07:30:00,,Transportation
-Birthday money,,Main,50.00,2025-04-30,10:00:00,,Gifts
+flow_title,flow_notes,flow_account_name,flow_amount,flow_date_of_transaction,flow_time_of_transaction_optional,flow_date_of_transaction_iso_8601,flow_category_optional
+Apple music,,Golomt bank,-152.00,2022-02-24,23:59:59,,Online expenses
+iCloud+,,Golomt bank,-250.00,2024-02-24,13:58:24,,Online expenses
+Salary,Received it little early due to upcoming holidays,Golomt bank,680,2025.3.24,01:13 p.m.,,Paycheck
+Keychron K8,,Savings,-89.2,2025.3.26,21:00:00,,Shopping
+Iced Matcha Latte,,Golomt bank,-5.2,2025.3.26,11:54:59,,Drinks & Beverages
+Aoaoao,,Savings,-81.2,2022.12.31,12:59:59,,Shopping
+From Main to Savings,,Main,-250,,,2025-03-14T08:25:56.887Z,
+From Main to Savings,,Savings,250,,,2025-03-14T08:25:56.887Z,
+Gift for Stella,,Main,-99.01,,,2025-03-14T08:25:56.494Z,Gifts
+Paycheck (last month),,Main,680.98,,,2025-03-14T08:25:56.485Z,Paycheck
+Iced Mocha,,Main,-6.5,,,2025-03-13T08:25:56.475Z,Drinks & Beverages
+Iced Mocha,,Main,-6.5,,,2025-03-12T08:25:56.466Z,Drinks & Beverages
+Iced Mocha,,Main,-6.5,,,2025-03-11T08:25:56.457Z,Drinks & Beverages
+Netflix,,Main,-15.49,,,2025-03-11T08:25:56.447Z,Online subscriptions
+iCloud,,Main,-1.99,,,2025-03-11T08:25:56.437Z,Online subscriptions
+Initial balance,,Main,420.69,,,2025-03-10T08:25:56.419Z,
+Rent,,Savings,-1960,,,2025-03-09T08:25:56.512Z,Rent
+Savings initial balance,,Savings,69420,,,2025-03-09T08:25:56.503Z,
+A transfer,,Savings,-500,,,2025-03-09T08:25:56.503Z,
+This doesn't matter,doesn't matter,Main,500,,,2025-03-09T08:25:56.503Z,
+Ice cream,,Main,-6570,,,2025-03-15T07:25:56.503Z,Food
+Another transfer,,Savings,6541,,,2025-03-15T07:25:59.503Z,
+Another transfer,,Golomt bank,-6541,,,2025-03-15T07:25:58.503Z,
+Groceries,,Main,-125.67,,,2025-03-16T09:15:22.123Z,Food
+Dinner with friends,,Main,-78.50,,,2025-03-16T19:45:12.345Z,Food
+Gas station,,Main,-45.30,,,2025-03-17T14:22:33.456Z,Transportation
+Movie tickets,,Main,-24.99,,,2025-03-18T20:30:45.678Z,Entertainment
+Amazon purchase,,Main,-67.89,,,2025-03-19T11:28:37.890Z,Shopping
+Coffee shop,,Main,-4.75,,,2025-03-19T08:15:42.123Z,Drinks & Beverages
+Pharmacy,,Main,-32.47,,,2025-03-20T16:45:21.234Z,Health
+Monthly bonus,,Main,150.00,,,2025-03-21T08:30:56.345Z,Paycheck
+Uber ride,,Main,-18.25,,,2025-03-21T23:15:27.456Z,Transportation
+Book store,,Main,-29.99,,,2025-03-22T14:25:38.567Z,Entertainment
+Haircut,,Main,-35.00,,,2025-03-23T13:20:49.678Z,Personal care
+Spotify,,Main,-9.99,,,2025-03-24T00:01:50.789Z,Online subscriptions
+Transfer to Savings,,Main,-300.00,,,2025-03-24T09:45:51.890Z,
+Transfer from Main,,Savings,300.00,,,2025-03-24T09:45:51.890Z,
+Gym membership,,Main,-49.99,,,2025-03-25T07:30:52.901Z,Health
+Restaurant lunch,,Main,-22.50,,,2025-03-25T12:15:53.012Z,Food
+Electricity bill,,Main,-85.32,,,2025-03-26T15:40:54.123Z,Utilities
+Internet bill,,Main,-59.99,,,2025-03-26T15:45:55.234Z,Utilities
+Phone bill,,Main,-75.00,,,2025-03-26T15:50:56.345Z,Utilities
+Birthday gift,,Main,-45.75,,,2025-03-27T17:25:57.456Z,Gifts
+Online course,,Main,-199.00,,,2025-03-28T10:10:58.567Z,Education
+Bakery,,Main,-12.50,,,2025-03-28T08:35:59.678Z,Food
+Side gig income,,Main,250.00,,,2025-03-29T16:20:00.789Z,Other income
+Hardware store,,Main,-78.23,,,2025-03-30T14:15:01.890Z,Home
+Pet supplies,,Main,-62.47,,,2025-03-31T11:30:02.901Z,Pets
+Donation,,Main,-25.00,,,2025-04-01T09:45:03.012Z,Charity
+Tax refund,,Main,750.00,,,2025-04-02T13:20:04.123Z,Other income
+Car maintenance,,Main,-210.75,,,2025-04-03T15:35:05.234Z,Transportation
+Concert tickets,,Main,-85.00,,,2025-04-04T18:50:06.345Z,Entertainment
+Clothing store,,Main,-145.67,,,2025-04-05T12:25:07.456Z,Shopping
+Dividend payment,,Savings,37.80,,,2025-04-06T00:01:08.567Z,Investment
+Interest earned,,Savings,12.45,,,2025-04-06T00:05:09.678Z,Interest
+Plumber service,,Main,-175.00,,,2025-04-07T11:30:10.789Z,Home
+Office supplies,,Main,-28.99,,,2025-04-08T14:45:11.890Z,Work
+Parking fee,,Main,-15.00,,,2025-04-09T09:20:12.901Z,Transportation
+Video game purchase,,Main,-59.99,,,2025-04-10T19:15:13.012Z,Entertainment
+Dentist appointment,,Main,-125.00,,,2025-04-11T10:30:14.123Z,Health
+Freelance payment,,Main,350.00,,,2025-04-12T16:45:15.234Z,Other income
+Cash withdrawal,,Main,-100.00,,,2025-04-13T11:50:16.345Z,Cash
+Bookstore purchase,,Main,-42.99,2025-04-14,15:30:45,,Books
+Grocery shopping,,Main,-87.54,2025-04-15,18:25:12,,Food
+Monthly subscription,,Main,-12.99,2025-04-16,00:01:30,,Online subscriptions
+Bus ticket,,Main,-2.50,2025-04-16,08:15:22,,Transportation
+Lunch with colleagues,,Main,-28.75,2025-04-16,12:30:45,,Food
+Savings transfer,,Main,-200.00,2025-04-17,09:00:00,,
+Savings deposit,,Savings,200.00,2025-04-17,09:00:00,,
+Mobile app purchase,,Main,-4.99,2025-04-18,19:45:33,,Entertainment
+Coffee shop visit,,Main,-5.25,2025-04-19,10:15:00,,Drinks & Beverages
+Weekend groceries,,Main,-65.32,2025-04-20,11:20:15,,Food
+Home insurance,,Main,-42.50,2025-04-21,08:00:00,,Insurance
+Water bill,,Main,-38.75,2025-04-22,14:30:00,,Utilities
+Gift for mom,,Main,-75.00,2025-04-23,16:45:22,,Gifts
+Salary deposit,,Main,2450.00,2025-04-25,09:15:00,,Paycheck
+Furniture purchase,,Savings,-350.00,2025-04-26,13:20:45,,Home
+Pharmacy items,,Main,-18.99,2025-04-27,15:30:00,,Health
+Fast food lunch,,Main,-12.50,2025-04-28,12:45:15,,Food
+Train ticket,,Main,-22.50,2025-04-29,07:30:00,,Transportation
+Birthday money,,Main,50.00,2025-04-30,10:00:00,,Gifts
diff --git a/translate_l10n_files.sh b/translate_l10n_files.sh
old mode 100755
new mode 100644