diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..b5e8cfd4 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..6b15fac7 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/CHANGELOG.md b/CHANGELOG.md index 0647f30f..6278555b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.21.0 + +Special thanks to [@PawiX25](https://github.com/PawiX25) for the new widget! + +### New features + +* Added summary home screen widget showing monthly income and expenses by [@PawiX25](https://github.com/PawiX25) + +### Fixes + +* [Android] Two Entry Last now shows the correct buttons, fixed by [@PawiX25](https://github.com/PawiX25) + ## 0.20.0 ### New features diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a96522cd..20c7dc05 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -106,6 +106,16 @@ android:name="android.appwidget.provider" android:resource="@xml/four_entry_widget_info" /> + + + + + + + get() = HomeWidgetGlanceStateDefinition() + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + Content(context, currentState()) + } + } + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Composable +@Preview(widthDp = 200, heightDp = 100) +private fun Content(context: Context, currentState: HomeWidgetGlanceState) { + val income = currentState.preferences.getString("summaryIncome", null) ?: "---" + val expense = currentState.preferences.getString("summaryExpense", null) ?: "---" + val incomeLabel = currentState.preferences.getString("summaryIncomeLabel", null) ?: "Income" + val expenseLabel = currentState.preferences.getString("summaryExpenseLabel", null) ?: "Expense" + + Box( + modifier = GlanceModifier + .background(GlanceTheme.colors.widgetBackground) + .fillMaxSize() + .clickable( + onClick = actionStartActivity( + Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + ), + contentAlignment = Alignment.Center + ) { + Row( + modifier = GlanceModifier.padding(8.dp).fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = GlanceModifier.defaultWeight()) { + SummaryCard( + label = incomeLabel, + iconRes = R.drawable.ic_arrow_down_forward, + amount = income, + accentColor = ColorProvider(R.color.income_green), + ) + } + Spacer(modifier = GlanceModifier.width(8.dp)) + Box(modifier = GlanceModifier.defaultWeight()) { + SummaryCard( + label = expenseLabel, + iconRes = R.drawable.ic_arrow_up_forward, + amount = expense, + accentColor = ColorProvider(R.color.expense_red), + ) + } + } + } +} + +@Composable +private fun SummaryCard(label: String, iconRes: Int, amount: String, accentColor: ColorProvider) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .background(GlanceTheme.colors.surfaceVariant) + .cornerRadius(16.dp) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 13.sp, + ), + ) + Spacer(modifier = GlanceModifier.width(4.dp)) + Image( + provider = ImageProvider(iconRes), + contentDescription = null, + modifier = GlanceModifier.width(20.dp).height(20.dp), + colorFilter = ColorFilter.tint(accentColor), + ) + } + Spacer(modifier = GlanceModifier.height(4.dp)) + Text( + text = amount, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } + } +} diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/SummaryReceiver.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/SummaryReceiver.kt new file mode 100644 index 00000000..0d5f3a73 --- /dev/null +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/SummaryReceiver.kt @@ -0,0 +1,8 @@ +package mn.flow.flow.glance + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class SummaryReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = Summary() +} diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/TwoEntryLast.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/TwoEntryLast.kt index 9a7237cb..9e90cd4e 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/TwoEntryLast.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/TwoEntryLast.kt @@ -44,7 +44,7 @@ class TwoEntryLast : GlanceAppWidget() { @Composable @Preview(widthDp = 100, heightDp = 50) private fun Content(context: Context, currentState: HomeWidgetGlanceState) { - val buttonOrder = FlowWidgetUtils.getButtonOrder(currentState.preferences).subList(0, 2) + val buttonOrder = FlowWidgetUtils.getButtonOrder(currentState.preferences).takeLast(2) val size = LocalSize.current val buttonSize = min((size.width / 2 - 24.dp), (size.height - 16.dp)) diff --git a/android/app/src/main/res/drawable/ic_arrow_down_forward.xml b/android/app/src/main/res/drawable/ic_arrow_down_forward.xml new file mode 100644 index 00000000..c5c47d2b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_down_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_up_forward.xml b/android/app/src/main/res/drawable/ic_arrow_up_forward.xml new file mode 100644 index 00000000..602b927c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_up_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml new file mode 100644 index 00000000..afda8985 --- /dev/null +++ b/android/app/src/main/res/values-night/widget_colors.xml @@ -0,0 +1,5 @@ + + + #FF32CC70 + #FFFF4040 + diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml new file mode 100644 index 00000000..afda8985 --- /dev/null +++ b/android/app/src/main/res/values/widget_colors.xml @@ -0,0 +1,5 @@ + + + #FF32CC70 + #FFFF4040 + diff --git a/android/app/src/main/res/xml/summary_widget_info.xml b/android/app/src/main/res/xml/summary_widget_info.xml new file mode 100644 index 00000000..41b20736 --- /dev/null +++ b/android/app/src/main/res/xml/summary_widget_info.xml @@ -0,0 +1,10 @@ + + diff --git a/ios/Flow Widgets/LeBundle.swift b/ios/Flow Widgets/LeBundle.swift index 51abb0ab..c1547932 100644 --- a/ios/Flow Widgets/LeBundle.swift +++ b/ios/Flow Widgets/LeBundle.swift @@ -12,5 +12,6 @@ import SwiftUI struct LeBundle: WidgetBundle { var body: some Widget { FlowTwoEntryWidget() + FlowSummaryWidget() } } diff --git a/ios/Flow Widgets/SummaryWidget.swift b/ios/Flow Widgets/SummaryWidget.swift new file mode 100644 index 00000000..b9281e20 --- /dev/null +++ b/ios/Flow Widgets/SummaryWidget.swift @@ -0,0 +1,109 @@ +import SwiftUI +import WidgetKit + +struct SummaryWidgetEntry: TimelineEntry { + let date: Date + let income: String + let expense: String + let incomeLabel: String + let expenseLabel: String +} + +struct SummaryProvider: TimelineProvider { + typealias Entry = SummaryWidgetEntry + + func placeholder(in context: Context) -> SummaryWidgetEntry { + SummaryWidgetEntry(date: Date(), income: "---", expense: "---", incomeLabel: "Income", expenseLabel: "Expense") + } + + func getSnapshot(in context: Context, completion: @escaping (SummaryWidgetEntry) -> ()) { + let prefs = UserDefaults(suiteName: "group.mn.flow.flow") + let income = prefs?.string(forKey: "summaryIncome") ?? "---" + let expense = prefs?.string(forKey: "summaryExpense") ?? "---" + let incomeLabel = prefs?.string(forKey: "summaryIncomeLabel") ?? "Income" + let expenseLabel = prefs?.string(forKey: "summaryExpenseLabel") ?? "Expense" + let entry = SummaryWidgetEntry(date: Date(), income: income, expense: expense, incomeLabel: incomeLabel, expenseLabel: expenseLabel) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + getSnapshot(in: context) { (entry) in + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + } + } +} + +struct SummaryWidgetView: View { + var entry: SummaryWidgetEntry + + var body: some View { + HStack(spacing: 8) { + summaryCard( + label: entry.incomeLabel, + amount: entry.income, + icon: "Income", + color: .green + ) + summaryCard( + label: entry.expenseLabel, + amount: entry.expense, + icon: "Expense", + color: .red + ) + } + .padding(12) + } + + @ViewBuilder + func summaryCard(label: String, amount: String, icon: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Image(icon) + .resizable() + .renderingMode(.template) + .foregroundStyle(color) + .frame(width: 14, height: 14) + } + Text(amount) + .font(.system(.title3, design: .rounded, weight: .semibold)) + .foregroundStyle(.primary) + .minimumScaleFactor(0.5) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12)) + } +} + +struct FlowSummaryWidget: Widget { + let kind: String = "FlowSummaryWidget" + + var body: some WidgetConfiguration { + StaticConfiguration( + kind: kind, provider: SummaryProvider() + ) { entry in + SummaryWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .supportedFamilies([.systemSmall, .systemMedium]) + .configurationDisplayName("Flow Summary") + .description("View your monthly income and expenses at a glance.") + } +} + +#Preview(as: .systemSmall) { + FlowSummaryWidget() +} timeline: { + SummaryWidgetEntry(date: .now, income: "$3.82K", expense: "$1.24K", incomeLabel: "Income", expenseLabel: "Expense") +} + +#Preview(as: .systemMedium) { + FlowSummaryWidget() +} timeline: { + SummaryWidgetEntry(date: .now, income: "$3.82K", expense: "$1.24K", incomeLabel: "Income", expenseLabel: "Expense") +} diff --git a/lib/l10n/flow_localizations.dart b/lib/l10n/flow_localizations.dart index 740ea9db..d6047d34 100644 --- a/lib/l10n/flow_localizations.dart +++ b/lib/l10n/flow_localizations.dart @@ -1,6 +1,8 @@ +import "dart:async"; import "dart:convert"; import "package:flow/l10n/supported_languages.dart"; +import "package:flow/services/widget_summary_sync.dart"; import "package:flutter/services.dart"; import "package:flutter/widgets.dart"; import "package:logging/logging.dart"; @@ -136,6 +138,7 @@ class _FlowLocalizationDelegate : FlowLocalizations.supportedLocales[1], ); await localization.load(); + unawaited(WidgetSummarySync.sync().catchError((_) {})); return localization; } diff --git a/lib/main.dart b/lib/main.dart index 1bd654e8..ec88eb70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -42,6 +42,7 @@ import "package:flow/services/recurring_transactions.dart"; import "package:flow/services/sync.dart"; import "package:flow/services/transactions.dart"; import "package:flow/services/user_preferences.dart"; +import "package:flow/services/widget_summary_sync.dart"; import "package:flow/theme/color_themes/registry.dart"; import "package:flow/theme/flow_color_scheme.dart"; import "package:flow/theme/theme.dart"; @@ -149,6 +150,8 @@ void main() async { ); } + TransactionsService().addListener(() => WidgetSummarySync.sync()); + try { Moment.minValue = DateTime(0); Moment.maxValue = DateTime(4000); @@ -199,6 +202,9 @@ class FlowState extends State { UserPreferencesService().valueNotifier.addListener(_reloadTheme); UserPreferencesService().valueNotifier.addListener(_listenToShakes); + UserPreferencesService().valueNotifier.addListener(_syncWidgets); + + ExchangeRatesService().exchangeRatesCache.addListener(_syncWidgets); LocalPreferences().localeOverride.addListener(_reloadLocale); LocalPreferences().primaryCurrency.addListener(_refreshExchangeRates); @@ -251,6 +257,9 @@ class FlowState extends State { LocalPreferences().primaryCurrency.removeListener(_refreshExchangeRates); UserPreferencesService().valueNotifier.removeListener(_reloadTheme); UserPreferencesService().valueNotifier.removeListener(_listenToShakes); + UserPreferencesService().valueNotifier.removeListener(_syncWidgets); + + ExchangeRatesService().exchangeRatesCache.removeListener(_syncWidgets); TransactionsService().removeListener(_synchronizePlannedNotifications); @@ -426,6 +435,10 @@ class FlowState extends State { ); } + void _syncWidgets() { + WidgetSummarySync.sync(); + } + void _synchronizePlannedNotifications() { TransactionsService().synchronizeNotifications().catchError((error) { startupLog.severe("Failed to synchronize notifications", error); diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index c2bddc8a..f31eb8f0 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -62,11 +62,13 @@ class _StatsTabState extends State rates = ExchangeRatesService().getPrimaryCurrencyRates(); ExchangeRatesService().exchangeRatesCache.addListener(_updateRates); + UserPreferencesService().valueNotifier.addListener(_updateRates); } @override void dispose() { ExchangeRatesService().exchangeRatesCache.removeListener(_updateRates); + UserPreferencesService().valueNotifier.removeListener(_updateRates); super.dispose(); } diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index 5dcd8e81..12ca6085 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -16,6 +16,7 @@ import "package:flow/services/currency_registry.dart"; import "package:flow/services/integrations/eny.dart"; import "package:flow/services/notifications.dart"; import "package:flow/services/sync.dart"; +import "package:flow/services/widget_summary_sync.dart"; import "package:flow/theme/color_themes/registry.dart"; import "package:flutter/material.dart"; import "package:home_widget/home_widget.dart"; @@ -464,6 +465,7 @@ class UserPreferencesService { .then((_) { ensurePrimaryAccountAvailability(); _updateButtonsWidgets(transactionButtonOrder); + WidgetSummarySync.sync(); }) .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 new file mode 100644 index 00000000..7e200ef0 --- /dev/null +++ b/lib/services/widget_summary_sync.dart @@ -0,0 +1,74 @@ +import "dart:io"; + +import "package:flow/constants.dart"; +import "package:flow/data/exchange_rates.dart"; +import "package:flow/data/single_currency_flow.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/actions.dart"; +import "package:flow/services/exchange_rates.dart"; +import "package:flow/services/user_preferences.dart"; +import "package:home_widget/home_widget.dart"; +import "package:logging/logging.dart"; +import "package:moment_dart/moment_dart.dart"; + +final Logger _log = Logger("WidgetSummarySync"); + +class WidgetSummarySync { + static Future sync() async { + try { + final String primaryCurrency = + UserPreferencesService().primaryCurrency; + final ExchangeRates? rates = + ExchangeRatesService().getPrimaryCurrencyRates(); + + final TimeRange range = TimeRange.thisMonth(); + + final List transactions = await ObjectBox() + .transcationsByRange(range, includeTransfers: false); + + final now = DateTime.now(); + + final SingleCurrencyFlow flow = + SingleCurrencyFlow(currency: primaryCurrency) + ..addAll( + transactions + .where((t) => !t.transactionDate.isAfter(now)) + .where((t) => t.isPending != true) + .map((t) => t.money), + rates, + ); + + final String formattedIncome = + flow.totalIncome.formatMoney(compact: true); + final String formattedExpense = + flow.totalExpense.abs().formatMoney(compact: true); + + final String incomeLabel = TransactionType.income.localizedName; + final String expenseLabel = TransactionType.expense.localizedName; + + if (Platform.isIOS) { + await HomeWidget.setAppGroupId(iOSAppGroupId); + } + + await HomeWidget.saveWidgetData("summaryIncome", formattedIncome); + await HomeWidget.saveWidgetData("summaryExpense", formattedExpense); + await HomeWidget.saveWidgetData("summaryIncomeLabel", incomeLabel); + await HomeWidget.saveWidgetData("summaryExpenseLabel", expenseLabel); + + await HomeWidget.updateWidget( + name: "FlowSummaryWidget", + iOSName: "FlowSummaryWidget", + androidName: "SummaryReceiver", + qualifiedAndroidName: "mn.flow.flow.glance.SummaryReceiver", + ); + + _log.finest( + "Synced summary widget: income=$formattedIncome, expense=$formattedExpense", + ); + } catch (e) { + _log.warning("Failed to sync summary widget: $e"); + } + } +} diff --git a/lib/widgets/home/home/account/total_balance.dart b/lib/widgets/home/home/account/total_balance.dart index 15f33d24..9b193392 100644 --- a/lib/widgets/home/home/account/total_balance.dart +++ b/lib/widgets/home/home/account/total_balance.dart @@ -5,6 +5,7 @@ import "package:flow/objectbox/actions.dart"; import "package:flow/prefs/local_preferences.dart"; import "package:flow/services/exchange_rates.dart"; import "package:flow/services/transactions.dart"; +import "package:flow/services/user_preferences.dart"; import "package:flow/theme/theme.dart"; import "package:flow/widgets/general/money_text.dart"; import "package:flutter/material.dart"; @@ -25,6 +26,7 @@ class _TotalBalanceState extends State { void initState() { super.initState(); LocalPreferences().primaryCurrency.addListener(_refresh); + UserPreferencesService().valueNotifier.addListener(_refresh); ExchangeRatesService().exchangeRatesCache.addListener(_refresh); TransactionsService().addListener(_refresh); @@ -37,6 +39,7 @@ class _TotalBalanceState extends State { @override void dispose() { LocalPreferences().primaryCurrency.removeListener(_refresh); + UserPreferencesService().valueNotifier.removeListener(_refresh); ExchangeRatesService().exchangeRatesCache.removeListener(_refresh); TransactionsService().removeListener(_refresh); diff --git a/lib/widgets/home/stats/most_spending_category.dart b/lib/widgets/home/stats/most_spending_category.dart index fe9c4635..a158b585 100644 --- a/lib/widgets/home/stats/most_spending_category.dart +++ b/lib/widgets/home/stats/most_spending_category.dart @@ -47,6 +47,13 @@ class _MostSpendingCategoryState extends State { super.initState(); range = widget.range; fetch(); + UserPreferencesService().valueNotifier.addListener(fetch); + } + + @override + void dispose() { + UserPreferencesService().valueNotifier.removeListener(fetch); + super.dispose(); } @override diff --git a/pubspec.yaml b/pubspec.yaml index fa84f649..49be24f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.20.0+338" +version: "0.21.0+339" environment: sdk: ">=3.10.0 <4.0.0"