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