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
29 changes: 27 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 @@ -29,6 +29,16 @@ enum class BudgetStatus {
}

data class BudgetEntry(
/**
* `Budget.uuid` — the only handle that survives backup/restore, and so the
* only one [BudgetWidgetConfigStore] may persist.
*
* The app's export omits ObjectBox ids, so a restore reinserts every budget
* and renumbers it: a widget pinned by [id] would come back pointing at
* whichever budget inherited that number.
*/
val uuid: String,
/** Current as of this payload only. Never store it — see [uuid]. */
val id: Long,
val name: String,
val spent: String?,
Expand Down Expand Up @@ -87,7 +97,18 @@ data class BudgetPayload(
val budgets: List<BudgetEntry>,
val labels: BudgetLabels,
) {
fun budgetById(id: Long?): BudgetEntry? =
/**
* The lookup for anything that was *stored* — i.e. the pinned widget's
* choice, which has to survive a restore renumbering every budget.
*/
fun budgetByUuid(uuid: String?): BudgetEntry? =
if (uuid.isNullOrEmpty()) null else budgets.firstOrNull { it.uuid == uuid }

/**
* Only valid within one payload: [BudgetSummary.worstId] is an id from this
* same snapshot, so it can't have drifted out from under the list beside it.
*/
private fun budgetById(id: Long?): BudgetEntry? =
if (id == null) null else budgets.firstOrNull { it.id == id }

/** The single most urgent budget, or null when there are none. */
Expand All @@ -96,7 +117,7 @@ data class BudgetPayload(

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

/**
* Returns null for every unusable input — key absent, blank, malformed, or
Expand Down Expand Up @@ -139,11 +160,15 @@ data class BudgetPayload(
val budgets = ArrayList<BudgetEntry>(json.length())
for (i in 0 until json.length()) {
val entry = json.optJSONObject(i) ?: continue
// Both are required: an entry with no uuid can't be pinned, and one
// with no id can't be linked to.
val uuid = entry.optStringOrNull("uuid") ?: continue
val id = entry.optLongOrNull("id") ?: continue
val percent = entry.optInt("percent", 0)

budgets.add(
BudgetEntry(
uuid = uuid,
id = id,
// The one string with no sensible fallback: a nameless budget is
// better shown blank than shown somebody else's word for "budget".
Expand Down
14 changes: 7 additions & 7 deletions android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ import es.antonborri.home_widget.HomeWidgetGlanceStateDefinition
/**
* 2x2 pinned budget: one budget, one bar, one number.
*
* The budget is chosen in the configuration activity — either a specific id or
* "any budget that needs attention", which resolves to `summary.worstId` at
* render time. The distinction matters: a widget that silently changes subject
* The budget is chosen in the configuration activity — either a specific
* budget, stored by uuid, or "any budget that needs attention", which resolves
* to `summary.worstId` at render time. The distinction matters: a widget that silently changes subject
* on someone who pinned "Groceries" trains distrust of every red bar on the
* home screen.
*/
Expand Down Expand Up @@ -77,19 +77,19 @@ private fun Content(
return@Frame
}

// A null budgetId means "auto"; a non-null one that no longer resolves
// 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.budgetId == null) {
val entry = if (config.budgetUuid == null) {
payload.worst
} else {
payload.budgetById(config.budgetId)
payload.budgetByUuid(config.budgetUuid)
}

if (entry == null) {
BudgetWidgetUi.EmptyState(
context = context,
title = BudgetWidgetLabels.title(context, payload),
message = if (config.budgetId == null) {
message = if (config.budgetUuid == null) {
BudgetWidgetLabels.empty(context, payload)
} else {
BudgetWidgetLabels.missingBudget(context, payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ abstract class BudgetWidgetConfigActivity : Activity() {
private var hideAmountsSwitch: Switch? = null
private var budgetGroup: RadioGroup? = null

/** Parallel to the radio group: index -> budget id, null for "auto". */
private val optionIds = ArrayList<Long?>()
/**
* Parallel to the radio group: index -> `Budget.uuid`, null for "auto".
*
* Uuids, not ObjectBox ids — see [BudgetWidgetConfigStore.Config.budgetUuid].
*/
private val optionUuids = ArrayList<String?>()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down Expand Up @@ -110,7 +114,7 @@ abstract class BudgetWidgetConfigActivity : Activity() {

if (showsBudgetPicker) {
root.addView(sectionHeader(R.string.budget_widget_config_budget))
root.addView(buildBudgetPicker(payload, existing.budgetId))
root.addView(buildBudgetPicker(payload, existing.budgetUuid))
}

root.addView(sectionHeader(R.string.budget_widget_config_privacy))
Expand Down Expand Up @@ -156,14 +160,14 @@ abstract class BudgetWidgetConfigActivity : Activity() {
setPadding(0, dp(24), 0, dp(8))
}

private fun buildBudgetPicker(payload: BudgetPayload?, selectedId: Long?): View {
private fun buildBudgetPicker(payload: BudgetPayload?, selectedUuid: String?): View {
val group = RadioGroup(this).apply { orientation = LinearLayout.VERTICAL }
budgetGroup = group
optionIds.clear()
optionUuids.clear()

// Always first, and always available: resolves to `summary.worstId` at
// render time rather than being baked in here.
optionIds.add(null)
optionUuids.add(null)
group.addView(
RadioButton(this).apply {
id = View.generateViewId()
Expand All @@ -174,7 +178,7 @@ abstract class BudgetWidgetConfigActivity : Activity() {

val budgets = payload?.budgets.orEmpty()
for (budget in budgets) {
optionIds.add(budget.id)
optionUuids.add(budget.uuid)
group.addView(
RadioButton(this).apply {
id = View.generateViewId()
Expand All @@ -197,7 +201,7 @@ abstract class BudgetWidgetConfigActivity : Activity() {

// A previously pinned budget that has since been deleted falls back to
// auto rather than leaving nothing selected.
val selectedIndex = optionIds.indexOf(selectedId).takeIf { it >= 0 } ?: 0
val selectedIndex = optionUuids.indexOf(selectedUuid).takeIf { it >= 0 } ?: 0
(group.getChildAt(selectedIndex) as? RadioButton)?.isChecked = true

return group
Expand All @@ -223,14 +227,14 @@ abstract class BudgetWidgetConfigActivity : Activity() {
}

private fun save() {
val budgetId = if (showsBudgetPicker) selectedBudgetId() else null
val budgetUuid = if (showsBudgetPicker) selectedBudgetUuid() else null

BudgetWidgetConfigStore.write(
this,
appWidgetId,
BudgetWidgetConfigStore.Config(
hideAmounts = hideAmountsSwitch?.isChecked == true,
budgetId = budgetId,
budgetUuid = budgetUuid,
),
)

Expand All @@ -251,7 +255,7 @@ abstract class BudgetWidgetConfigActivity : Activity() {
finish()
}

private fun selectedBudgetId(): Long? {
private fun selectedBudgetUuid(): String? {
val group = budgetGroup ?: return null
val checkedId = group.checkedRadioButtonId
if (checkedId == View.NO_ID) return null
Expand All @@ -260,7 +264,7 @@ abstract class BudgetWidgetConfigActivity : Activity() {
.firstOrNull { group.getChildAt(it).id == checkedId }
?: return null

return optionIds.getOrNull(index)
return optionUuids.getOrNull(index)
}

private fun dp(value: Int): Int =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,49 @@ import android.content.Context
object BudgetWidgetConfigStore {
private const val PREFS = "mn.flow.flow.budget_widgets"
private const val KEY_HIDE_AMOUNTS = "hideAmounts_"
private const val KEY_BUDGET_ID = "budgetId_"

/** Sentinel for "follow whichever budget needs attention". */
private const val AUTO_WORST = -1L
private const val KEY_BUDGET_UUID = "budgetUuid_"

data class Config(
val hideAmounts: Boolean,
/** null means auto — resolve to `summary.worstId` at render time. */
val budgetId: Long?,
/**
* `Budget.uuid`, or null for auto — resolve to `summary.worstId` at render
* time.
*
* A uuid rather than an ObjectBox id because this outlives the payload it
* came from. The app's export omits ids, so restoring a backup renumbers
* every budget; a stored id would then resolve to a *different* budget and
* the widget would confidently render the wrong one.
*/
val budgetUuid: String?,
)

val default = Config(hideAmounts = false, budgetId = null)
val default = Config(hideAmounts = false, budgetUuid = null)

fun read(context: Context, appWidgetId: Int): Config {
if (appWidgetId <= 0) return default

val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val budgetId = prefs.getLong(KEY_BUDGET_ID + appWidgetId, AUTO_WORST)

return Config(
hideAmounts = prefs.getBoolean(KEY_HIDE_AMOUNTS + appWidgetId, false),
budgetId = if (budgetId == AUTO_WORST) null else budgetId,
// Absent means auto, so no sentinel value is needed.
budgetUuid = prefs.getString(KEY_BUDGET_UUID + appWidgetId, null),
)
}

fun write(context: Context, appWidgetId: Int, config: Config) {
if (appWidgetId <= 0) return

context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_HIDE_AMOUNTS + appWidgetId, config.hideAmounts)
.putLong(KEY_BUDGET_ID + appWidgetId, config.budgetId ?: AUTO_WORST)
.commit()

if (config.budgetUuid == null) {
editor.remove(KEY_BUDGET_UUID + appWidgetId)
} else {
editor.putString(KEY_BUDGET_UUID + appWidgetId, config.budgetUuid)
}

editor.commit()
}

/**
Expand All @@ -55,7 +65,7 @@ object BudgetWidgetConfigStore {
fun clear(context: Context, appWidgetIds: IntArray) {
val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
for (id in appWidgetIds) {
editor.remove(KEY_HIDE_AMOUNTS + id).remove(KEY_BUDGET_ID + id)
editor.remove(KEY_HIDE_AMOUNTS + id).remove(KEY_BUDGET_UUID + id)
}
editor.apply()
}
Expand Down
30 changes: 27 additions & 3 deletions ios/Flow Widgets/BudgetPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,16 @@ enum BudgetStatus: String, Codable {
}

struct BudgetItem: Codable, Identifiable {
/// `Budget.id` — stable, this is what the pinned widget stores.
/// `Budget.uuid` — the only handle that survives backup/restore, and so the
/// only thing the pinned widget is allowed to *store*.
///
/// The app's export omits ObjectBox ids, so a restore reinserts every
/// budget and renumbers it: "Eating out" comes back as a different `id`,
/// and that `id` may already belong to a different budget.
let uuid: String
/// `Budget.id` as of *this* payload. Safe to build a deep link from, since
/// the link is made from the same snapshot being rendered. Never persist
/// it — that is what `uuid` is for.
let id: Int
let name: String
// Pre-formatted, compacted money. Never rendered when "Hide amounts" is on.
Expand Down Expand Up @@ -128,6 +137,7 @@ struct BudgetItem: Codable, Identifiable {
let hasMissingData: Bool

init(
uuid: String,
id: Int,
name: String,
spent: String?,
Expand All @@ -144,6 +154,7 @@ struct BudgetItem: Codable, Identifiable {
periodLabel: String?,
hasMissingData: Bool
) {
self.uuid = uuid
self.id = id
self.name = name
self.spent = spent
Expand All @@ -163,6 +174,10 @@ struct BudgetItem: Codable, Identifiable {

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Both are required: an entry with no uuid can't be pinned, and one
// with no id can't be linked to. Failing the decode drops the whole
// payload to the placeholder, which beats a half-usable budget list.
uuid = try container.decode(String.self, forKey: .uuid)
id = try container.decode(Int.self, forKey: .id)
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
spent = try container.decodeIfPresent(String.self, forKey: .spent)
Expand Down Expand Up @@ -280,7 +295,16 @@ struct BudgetLabels: Codable {
}

extension BudgetPayload {
func budget(id: Int?) -> BudgetItem? {
/// The lookup for anything that was *stored* — i.e. a pinned widget's
/// choice, which has to survive a restore renumbering every budget.
func budget(uuid: String?) -> BudgetItem? {
guard let uuid, !uuid.isEmpty else { return nil }
return budgets.first { $0.uuid == uuid }
}

/// Only valid within one payload: `summary.worstId` is an id from this same
/// snapshot, so it can't have drifted out from under the list beside it.
private func budget(id: Int?) -> BudgetItem? {
guard let id else { return nil }
return budgets.first { $0.id == id }
}
Expand All @@ -301,7 +325,7 @@ enum BudgetPayloadStore {
static let appGroupId = "group.mn.flow.flow"
static let payloadKey = "budgetsPayload"
/// Bump only together with `BudgetWidgetSync.payloadVersion` on the Dart side.
static let supportedVersion = 1
static let supportedVersion = 2

/// One shared read for both widgets and for the budget picker's entity query.
///
Expand Down
Loading
Loading