Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
pacing, its recent periods, and every transaction counting towards it.
* Budgets overview surfaces which budgets are over or nearing their limit, and
what to do about it.
* Planned and pending transactions count towards a budget — money you've
scheduled is money the period is committed to. Progress bars draw that part
in a lighter shade, so you can still see what has actually cleared.
* Home screen widgets for budgets on iOS and Android, including a variant that
shows progress without revealing any amounts.
shows progress without revealing any amounts. Tapping one opens the budget
it's showing.
* Budgets are included in backups, and restore from any v2 backup.
* An in-app alert when a budget goes over, or gets close.

Expand Down
35 changes: 16 additions & 19 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,31 @@
# Contributing to flow

Thank you for stopping by here! There are many ways to make Flow better for
everyone. Here are few:
Thank you for stopping by! A few notes on how contributing works here:

Flow is free, and will stay that way — I'm continuing to develop it on my own.
I'm not taking feature requests or bug report/opinion submissions right now, so
there's no issue tracker to file into.

* [Report a bug](https://github.com/flow-mn/flow/issues/new/choose)
* [Propose a feature](https://github.com/flow-mn/flow/issues/new?assignees=&labels=&projects=&template=feature_request.yaml&title=%5BFEAT%5D+)
* [Contribute code](#developing)
* [Translating Flow](#translating) to your own language
* [Buy maintainer a coffee](https://buymeacoffee.com/sadespresso). Flow is a
free and open-source software, and will stay this way.

## Developing

NOTE: A quick discussion upfront can highlight any potential issues, streamline
the merge process, and ensure you're on the right track to avoid rework.

TIP: Look for issues with `ready` label to get started without any friction
You're welcome to submit PRs, but I highly recommend reaching out first —
Instagram ([@sadespresso](https://instagram.com/sadespresso)) or email
(<batmend@gege.mn>) — so we can coordinate before you put in the work.

1. Fork the repository
2. Pick an issue. If the fix/feature you're gonna work doesn't have an issue,
please create one first.
3. Let everyone know that you're working on it by commenting "I'm working on it"
4. Create a feature branch. For example, if you're working on
[#82](https://github.com/flow-mn/flow/issues/82), create a branch
`username/fix82` (based on `develop`). The name can be different, doesn't matter.
5. Make changes on the new branch
6. Ensure your code doesn't have any linter warnings, errors
2. Reach out first (see above) so we're aligned on the change
3. Create a feature branch off `develop`, e.g. `username/short-description`
(the name can be different, doesn't matter)
4. Make changes on the new branch
5. Ensure your code doesn't have any linter warnings, errors
(Your editor will tell you, or you can run `flutter analyze`)
7. Submit a PR to `develop` branch
8. If your feature involves UI changes, add a short video demonstrating the
6. Submit a PR to `develop` branch
7. If your feature involves UI changes, add a short video demonstrating the
implement change/feature

## Code guides
Expand All @@ -45,7 +42,7 @@ version name `next`)

When translating Flow to your language, the translation coverage must be 100%.
You can follow the same steps in [Developing](#developing), and you can safely
skip lints and tests (step 6 and 7).
skip the lint check (step 5).

It's highly recommended to copy [en_US.json](./assets/l10n/en.json) or
any other existing translations with full coverage, and work on top of it.
Expand Down
17 changes: 15 additions & 2 deletions android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,16 @@ data class BudgetEntry(
* otherwise mix digit styles between this and every other string here.
*/
val percentLabel: String,
/** Not clamped — an over-budget entry exceeds 1.0. Clamp at the call site. */
/**
* Not clamped — an over-budget entry exceeds 1.0. Clamp at the call site.
* Includes pending spend.
*/
val ratio: Double,
/**
* The part of [ratio] that has actually cleared. Bars fill solid to here and
* carry on as a lighter ghost tail out to [ratio].
*/
val confirmedRatio: Double,
/** Colour and branching only — [statusLabel] is what the user reads. */
val status: BudgetStatus,
/**
Expand Down Expand Up @@ -117,7 +125,7 @@ data class BudgetPayload(

companion object {
const val PAYLOAD_KEY = "budgetsPayload"
const val SUPPORTED_VERSION = 2
const val SUPPORTED_VERSION = 3

/**
* Returns null for every unusable input — key absent, blank, malformed, or
Expand Down Expand Up @@ -182,6 +190,11 @@ data class BudgetPayload(
// blank-hero-number guard rather than a supported code path.
percentLabel = entry.optStringOrNull("percentLabel") ?: "$percent%",
ratio = entry.optDouble("ratio", 0.0).let { if (it.isNaN()) 0.0 else it },
// Defaulting to `ratio` means "all of it cleared", so a payload
// missing the key draws one solid bar rather than an all-ghost one.
confirmedRatio = entry.optDouble("confirmedRatio", Double.NaN)
.let { if (it.isNaN()) entry.optDouble("ratio", 0.0) else it }
.let { if (it.isNaN()) 0.0 else it },
status = BudgetStatus.parse(entry.optStringOrNull("status")),
statusLabel = entry.optStringOrNull("statusLabel"),
daysLeft = entry.optInt("daysLeft", 0),
Expand Down
39 changes: 28 additions & 11 deletions android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,24 @@ private fun Content(
state.preferences.getString(BudgetPayload.PAYLOAD_KEY, null)
)

BudgetWidgetUi.Frame(context, padding = 12.dp) {
// A null budgetUuid means "auto"; a non-null one that no longer resolves
// means the user deleted the budget this widget was pinned to. Resolved out
// here rather than inside the frame because the tap destination depends on it.
val entry = if (payload == null) {
null
} else if (config.budgetUuid == null) {
payload.worst
} else {
payload.budgetByUuid(config.budgetUuid)
}

// Linking by id even though the pin is stored by uuid: this id comes off the
// payload being rendered right now, so it is current by construction, and the
// app routes budgets by id. With no budget resolved there is nothing specific
// to open, so fall back to the list.
val destination = if (entry == null) "/budgets" else "/budgets/${entry.id}"

BudgetWidgetUi.Frame(padding = 16.dp, destination = destination) {
if (payload == null || payload.budgets.isEmpty()) {
BudgetWidgetUi.EmptyState(
context = context,
Expand All @@ -77,14 +94,6 @@ private fun Content(
return@Frame
}

// A null budgetUuid means "auto"; a non-null one that no longer resolves
// means the user deleted the budget this widget was pinned to.
val entry = if (config.budgetUuid == null) {
payload.worst
} else {
payload.budgetByUuid(config.budgetUuid)
}

if (entry == null) {
BudgetWidgetUi.EmptyState(
context = context,
Expand All @@ -98,7 +107,9 @@ private fun Content(
return@Frame
}

val barWidth = LocalSize.current.width - 8.dp * 2 - 12.dp * 2 - 4.dp
// The frame's single 16dp inset, plus 4dp of slack so the bar stays off
// the rounded corner on launchers that round more aggressively.
val barWidth = LocalSize.current.width - 16.dp * 2 - 4.dp

Column(modifier = GlanceModifier.fillMaxSize()) {
Text(
Expand Down Expand Up @@ -138,7 +149,13 @@ private fun Content(
)

Spacer(modifier = GlanceModifier.height(6.dp))
BudgetWidgetUi.BudgetBar(barWidth, entry.ratio, entry.status, height = 8.dp)
BudgetWidgetUi.BudgetBar(
barWidth,
entry.ratio,
entry.status,
height = 8.dp,
confirmedRatio = entry.confirmedRatio,
)
Spacer(modifier = GlanceModifier.height(6.dp))

Text(
Expand Down
17 changes: 12 additions & 5 deletions android/app/src/main/kotlin/mn/flow/flow/glance/BudgetRollup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ private fun Content(
)
val worst = payload?.worst

BudgetWidgetUi.Frame(context, padding = 14.dp) {
// The overview, not the plain list: this widget *is* that page in
// miniature, so a tap should expand what it shows.
BudgetWidgetUi.Frame(padding = 16.dp, destination = "/stats/budgets") {
if (payload == null || payload.budgets.isEmpty()) {
BudgetWidgetUi.EmptyState(
context = context,
Expand All @@ -81,9 +83,9 @@ private fun Content(
return@Frame
}

// Frame padding (14dp) and the widget's own 8dp inset both eat into the
// bar; 4dp of slack keeps it off the rounded corner on tight launchers.
val barWidth = LocalSize.current.width - 8.dp * 2 - 14.dp * 2 - 4.dp
// The frame's single 16dp inset, plus 4dp of slack so the bar stays off
// the rounded corner on launchers that round more aggressively.
val barWidth = LocalSize.current.width - 16.dp * 2 - 4.dp

val overCount = payload.summary.overCount
val warningCount = payload.summary.warningCount
Expand Down Expand Up @@ -193,7 +195,12 @@ private fun WorstBudget(
}

Spacer(modifier = GlanceModifier.height(5.dp))
BudgetWidgetUi.BudgetBar(barWidth, entry.ratio, entry.status)
BudgetWidgetUi.BudgetBar(
barWidth,
entry.ratio,
entry.status,
confirmedRatio = entry.confirmedRatio,
)
Spacer(modifier = GlanceModifier.height(5.dp))

Row(
Expand Down
125 changes: 94 additions & 31 deletions android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
Expand All @@ -26,7 +29,6 @@ import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import mn.flow.flow.MainActivity
import mn.flow.flow.R

/**
Expand All @@ -49,39 +51,75 @@ object BudgetWidgetUi {
BudgetStatus.HEALTHY -> ColorProvider(R.color.income_green)
}

/** Every budget widget opens the app; matches the Summary widget. */
fun launchAppIntent(context: Context): Intent =
Intent(context, MainActivity::class.java).apply {
/**
* [statusColor] at ~35%, for the pending part of a bar. Pre-multiplied
* resources rather than an alpha modifier, which Glance doesn't have.
*/
fun ghostColor(status: BudgetStatus): ColorProvider = when (status) {
BudgetStatus.OVER -> ColorProvider(R.color.budget_ghost_expense)
BudgetStatus.WARNING -> ColorProvider(R.color.budget_ghost_warning)
BudgetStatus.HEALTHY -> ColorProvider(R.color.budget_ghost_income)
}

/**
* A deep link into the app, the way [FlowWidgetUtils.EntryButton] does it.
*
* A bare `Intent(context, MainActivity::class)` carries no action and no data,
* so it can only ever cold-open the home tab — which is what these widgets
* used to do. The manifest already accepts the `flow-mn` scheme.
*
* Pinned to our own package: `flow-mn` is a custom scheme, so any installed
* app may register it and become a resolution candidate for this tap. Without
* [Intent.setPackage] a widget tap could raise a chooser, or open a
* Flow-lookalike. [path] must be absolute — it lands after the scheme's empty
* authority, so a relative one would silently become the host and resolve to
* nothing.
*/
fun deepLinkIntent(context: Context, path: String): Intent {
require(path.startsWith("/")) { "Deep-link path must be absolute: $path" }

return Intent(Intent.ACTION_VIEW, "flow-mn://$path".toUri()).apply {
setPackage(context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}

/**
* The widget's outer chrome: launcher background, tap-to-open, and the 16dp
* surface card the rest of Flow's widgets sit on.
* The widget's outer chrome: one surface that *is* the widget, plus
* tap-to-open.
*
* Deliberately a single background rather than a card floating inside an
* inset: the launcher already draws a rounded, themed container behind every
* widget, so a second rounded card inside it reads as a border around a
* screenshot rather than as the widget itself. [appWidgetBackground] marks
* this box as *the* background so the launcher's own rounding and Material You
* theming apply to it, and the system radius keeps the corners identical to
* every other widget on the home screen instead of a hard-coded 16dp that
* only matches on some launchers.
*
* [destination] is the `flow-mn` path a tap opens — the pinned widget passes
* its own budget, the roll-up passes the overview it mirrors.
*/
@Composable
fun Frame(context: Context, padding: Dp, content: @Composable () -> Unit) {
fun Frame(
padding: Dp,
destination: String,
content: @Composable () -> Unit,
) {
Box(
modifier = GlanceModifier
.background(GlanceTheme.colors.widgetBackground)
.fillMaxSize()
.clickable(onClick = actionStartActivity(launchAppIntent(context))),
.appWidgetBackground()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(android.R.dimen.system_app_widget_background_radius)
.clickable(
onClick = actionStartActivity(
deepLinkIntent(LocalContext.current, destination),
),
)
.padding(padding),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.padding(8.dp),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surfaceVariant)
.cornerRadius(16.dp)
.padding(padding),
) {
content()
}
}
content()
}
}

Expand All @@ -91,10 +129,25 @@ object BudgetWidgetUi {
* the percent text carry that information instead.
*/
@Composable
fun BudgetBar(width: Dp, ratio: Double, status: BudgetStatus, height: Dp = 6.dp) {
val clamped = ratio.coerceIn(0.0, 1.0).toFloat()
fun BudgetBar(
width: Dp,
ratio: Double,
status: BudgetStatus,
height: Dp = 6.dp,
confirmedRatio: Double = ratio,
) {
val track = width.coerceAtLeast(0.dp)
val filled = track * clamped

// A sliver of colour reads as "barely started"; zero width reads as a
// rendering bug, so never draw less than a dot.
fun band(fraction: Double): Dp {
val clamped = fraction.coerceIn(0.0, 1.0).toFloat()
if (clamped <= 0f) return 0.dp
return minOf(track, maxOf(track * clamped, height))
}

val total = band(ratio)
val confirmed = band(minOf(confirmedRatio, ratio))

Box(
modifier = GlanceModifier
Expand All @@ -103,12 +156,22 @@ object BudgetWidgetUi {
.background(ColorProvider(R.color.budget_bar_track))
.cornerRadius(height / 2),
) {
if (clamped > 0f) {
// Full-length ghost with the solid fill on top of it, so the seam between
// them is a rounded cap nested inside a rounded cap. Glance has no alpha
// modifier, so the ghost is a pre-multiplied colour resource.
if (total > confirmed) {
Box(
modifier = GlanceModifier
.width(total)
.height(height)
.background(ghostColor(status))
.cornerRadius(height / 2),
) {}
}
if (confirmed > 0.dp) {
Box(
modifier = GlanceModifier
// A sliver of colour reads as "barely started"; zero width reads as
// a rendering bug, so never draw less than a dot.
.width(maxOf(filled, height))
.width(confirmed)
.height(height)
.background(statusColor(status))
.cornerRadius(height / 2),
Expand Down
Loading
Loading