diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt index 48688f5d..541d1c77 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPayload.kt @@ -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?, @@ -87,7 +97,18 @@ data class BudgetPayload( val budgets: List, 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. */ @@ -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 @@ -139,11 +160,15 @@ data class BudgetPayload( val budgets = ArrayList(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". diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt index bc1e6de5..3d1a607a 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetPinned.kt @@ -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. */ @@ -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) diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigActivity.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigActivity.kt index d0c0c579..7e4228d2 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigActivity.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigActivity.kt @@ -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() + /** + * Parallel to the radio group: index -> `Budget.uuid`, null for "auto". + * + * Uuids, not ObjectBox ids — see [BudgetWidgetConfigStore.Config.budgetUuid]. + */ + private val optionUuids = ArrayList() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -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)) @@ -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() @@ -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() @@ -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 @@ -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, ), ) @@ -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 @@ -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 = diff --git a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigStore.kt b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigStore.kt index 2f01a51c..cdd95eff 100644 --- a/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigStore.kt +++ b/android/app/src/main/kotlin/mn/flow/flow/glance/BudgetWidgetConfigStore.kt @@ -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() } /** @@ -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() } diff --git a/ios/Flow Widgets/BudgetPayload.swift b/ios/Flow Widgets/BudgetPayload.swift index 9e44ebc7..dc467c6e 100644 --- a/ios/Flow Widgets/BudgetPayload.swift +++ b/ios/Flow Widgets/BudgetPayload.swift @@ -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. @@ -128,6 +137,7 @@ struct BudgetItem: Codable, Identifiable { let hasMissingData: Bool init( + uuid: String, id: Int, name: String, spent: String?, @@ -144,6 +154,7 @@ struct BudgetItem: Codable, Identifiable { periodLabel: String?, hasMissingData: Bool ) { + self.uuid = uuid self.id = id self.name = name self.spent = spent @@ -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) @@ -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 } } @@ -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. /// diff --git a/ios/Flow Widgets/BudgetPinnedWidget.swift b/ios/Flow Widgets/BudgetPinnedWidget.swift index ed31eaa9..90f0197c 100644 --- a/ios/Flow Widgets/BudgetPinnedWidget.swift +++ b/ios/Flow Widgets/BudgetPinnedWidget.swift @@ -14,20 +14,22 @@ struct BudgetPinnedEntry: TimelineEntry { let date: Date let payload: BudgetPayload? /// `BudgetChoice.automaticId` means "whichever budget needs attention". - let selectedId: Int + /// Otherwise a `Budget.uuid`, which is what makes the pin survive a + /// backup/restore — see `BudgetChoice.id`. + let selectedUuid: String let hideAmounts: Bool /// `nil` means nothing to show — either no budgets at all, or the pinned /// budget has since been deleted. `isMissingPin` tells those apart. var budget: BudgetItem? { guard let payload else { return nil } - if selectedId == BudgetChoice.automaticId { return payload.worst } - return payload.budget(id: selectedId) + if selectedUuid == BudgetChoice.automaticId { return payload.worst } + return payload.budget(uuid: selectedUuid) } var isMissingPin: Bool { guard let payload, !payload.isEmpty else { return false } - return selectedId != BudgetChoice.automaticId && payload.budget(id: selectedId) == nil + return selectedUuid != BudgetChoice.automaticId && payload.budget(uuid: selectedUuid) == nil } var labels: BudgetLabels { @@ -43,7 +45,7 @@ struct BudgetPinnedProvider: AppIntentTimelineProvider { BudgetPinnedEntry( date: Date(), payload: nil, - selectedId: BudgetChoice.automaticId, + selectedUuid: BudgetChoice.automaticId, hideAmounts: false ) } @@ -55,7 +57,7 @@ struct BudgetPinnedProvider: AppIntentTimelineProvider { BudgetPinnedEntry( date: Date(), payload: BudgetPayloadStore.load(), - selectedId: configuration.selectedId, + selectedUuid: configuration.selectedUuid, hideAmounts: configuration.hideAmounts ) } @@ -264,6 +266,15 @@ struct FlowBudgetPinnedWidget: Widget { .description("Track a single budget, or whichever one needs attention.") } + /// Links by ObjectBox id even though the pin is stored by uuid: the id + /// comes from the budget just resolved out of the payload being rendered, + /// so it is current by construction, and the app routes budgets by id + /// everywhere else. + /// + /// The gap that leaves: a restore renumbers ids *and* re-syncs the payload, + /// but a tap in between carries an id from the stale one. If nothing owns + /// that id any more, `/budgets/:id` falls back to the list; if a different + /// budget has inherited it, the tap opens that one instead. private func destination(for entry: BudgetPinnedEntry) -> URL? { if let id = entry.budget?.id { return URL(string: "flow-mn:///budgets/\(id)") @@ -273,7 +284,7 @@ struct FlowBudgetPinnedWidget: Widget { } private let previewPinnedPayload = BudgetPayload( - version: 1, + version: 2, updatedAt: "2026-07-22T09:14:03.123Z", summary: BudgetSummary( budgetCount: 3, @@ -284,6 +295,7 @@ private let previewPinnedPayload = BudgetPayload( ), budgets: [ BudgetItem( + uuid: "5f2b1c74-0f1a-4c3e-9a7d-2b6e8c1d4a90", id: 7, name: "Хоол, ундаа", spent: "₮420мянга", @@ -318,13 +330,13 @@ private let previewPinnedPayload = BudgetPayload( BudgetPinnedEntry( date: .now, payload: previewPinnedPayload, - selectedId: BudgetChoice.automaticId, + selectedUuid: BudgetChoice.automaticId, hideAmounts: false ) BudgetPinnedEntry( date: .now, payload: previewPinnedPayload, - selectedId: 7, + selectedUuid: "5f2b1c74-0f1a-4c3e-9a7d-2b6e8c1d4a90", hideAmounts: true ) } @@ -335,7 +347,7 @@ private let previewPinnedPayload = BudgetPayload( BudgetPinnedEntry( date: .now, payload: previewPinnedPayload, - selectedId: 7, + selectedUuid: "5f2b1c74-0f1a-4c3e-9a7d-2b6e8c1d4a90", hideAmounts: false ) } diff --git a/ios/Flow Widgets/BudgetRollupWidget.swift b/ios/Flow Widgets/BudgetRollupWidget.swift index 90c29e4b..77e5cf29 100644 --- a/ios/Flow Widgets/BudgetRollupWidget.swift +++ b/ios/Flow Widgets/BudgetRollupWidget.swift @@ -204,7 +204,7 @@ struct FlowBudgetRollupWidget: Widget { BudgetRollupEntry( date: .now, payload: BudgetPayload( - version: 1, + version: 2, updatedAt: "2026-07-22T09:14:03.123Z", summary: BudgetSummary( budgetCount: 3, @@ -215,6 +215,7 @@ struct FlowBudgetRollupWidget: Widget { ), budgets: [ BudgetItem( + uuid: "c3a91e58-7d24-4b16-8f05-1e93a7c60b42", id: 4, name: "Хүнс", spent: "₮1.24сая", @@ -232,6 +233,7 @@ struct FlowBudgetRollupWidget: Widget { hasMissingData: false ), BudgetItem( + uuid: "5f2b1c74-0f1a-4c3e-9a7d-2b6e8c1d4a90", id: 7, name: "Түлш", spent: "₮380мянга", diff --git a/ios/Flow Widgets/BudgetWidgetIntents.swift b/ios/Flow Widgets/BudgetWidgetIntents.swift index 2bccf39e..087bb948 100644 --- a/ios/Flow Widgets/BudgetWidgetIntents.swift +++ b/ios/Flow Widgets/BudgetWidgetIntents.swift @@ -20,12 +20,18 @@ import WidgetKit /// separate toggle — means a user who picked "Groceries" keeps seeing /// Groceries. The widget never silently changes subject on them. struct BudgetChoice: AppEntity { - let id: Int + /// `Budget.uuid`, never the ObjectBox id. + /// + /// AppIntents persists this with the widget configuration, so it outlives + /// the payload it came from — including across a backup/restore, which + /// renumbers every ObjectBox id. Keyed by id, a widget pinned to "Eating + /// out" would come back pointing at whichever budget inherited that number. + let id: String let name: String - /// Sentinel id for "Any budget that needs attention". Real `Budget.id`s are - /// ObjectBox ids and always positive. - static let automaticId: Int = -1 + /// Sentinel for "Any budget that needs attention". Real ids are v4 uuids, + /// so this can never collide with one. + static let automaticId: String = "automatic" static var automatic: BudgetChoice { BudgetChoice(id: automaticId, name: String(localized: "Any budget that needs attention")) @@ -55,17 +61,17 @@ struct BudgetChoiceQuery: EntityQuery { func suggestedEntities() async throws -> [BudgetChoice] { var choices: [BudgetChoice] = [.automatic] if let payload = BudgetPayloadStore.load() { - choices.append(contentsOf: payload.budgets.map { BudgetChoice(id: $0.id, name: $0.name) }) + choices.append(contentsOf: payload.budgets.map { BudgetChoice(id: $0.uuid, name: $0.name) }) } return choices } - func entities(for identifiers: [Int]) async throws -> [BudgetChoice] { + func entities(for identifiers: [String]) async throws -> [BudgetChoice] { let payload = BudgetPayloadStore.load() return identifiers.map { identifier in if identifier == BudgetChoice.automaticId { return .automatic } - if let match = payload?.budgets.first(where: { $0.id == identifier }) { - return BudgetChoice(id: match.id, name: match.name) + if let match = payload?.budget(uuid: identifier) { + return BudgetChoice(id: match.uuid, name: match.name) } // The budget was deleted. Resolving to nil here would make the // configuration look unset; keeping the id alive lets the widget @@ -110,7 +116,15 @@ struct BudgetPinnedConfigurationIntent: WidgetConfigurationIntent { /// `nil` (never configured) behaves as automatic, so a freshly dropped /// widget shows something useful immediately. - var selectedId: Int { + /// + /// There is deliberately no migration for a configuration written while + /// budgets were keyed by ObjectBox id: that keying never shipped, so the + /// only devices holding one are ours. Where such a configuration lands is + /// unverified — AppIntents may fail to decode the old `Int` identifier and + /// arrive `nil`, or surface it as `"7"` / `"-1"`, which `entities(for:)` + /// keeps alive as the "budget is gone" state. Re-pick the budget on any + /// test device that shows it. + var selectedUuid: String { budget?.id ?? BudgetChoice.automaticId } } diff --git a/lib/services/budget_widget_sync.dart b/lib/services/budget_widget_sync.dart index 49779fcb..53a2c625 100644 --- a/lib/services/budget_widget_sync.dart +++ b/lib/services/budget_widget_sync.dart @@ -44,7 +44,12 @@ class BudgetWidgetSync { /// process that writes this, so an old extension can and will read a new /// payload. It checks this and falls back to its placeholder rather than /// mis-rendering fields it doesn't understand. - static const int payloadVersion = 1; + /// + /// - 1: initial shape. + /// - 2: added `uuid` to each budget. A v1 extension pins by ObjectBox id, + /// which does not survive backup/restore — better it show a placeholder + /// than confidently render the wrong budget. + static const int payloadVersion = 2; /// Builds the payload without touching any platform channel. /// @@ -117,6 +122,14 @@ class BudgetWidgetSync { } static Map _budgetJson(BudgetProgress progress) => { + // The only durable handle on a budget. `Budget.toJson` doesn't write `id`, + // so a restore reinserts every budget with `id = 0` and ObjectBox hands out + // fresh ids — "Eating out" can come back as a different number, and that + // number can already belong to something else. Anything a widget *persists* + // (the pinned choice) has to key on this. + "uuid": progress.budget.uuid, + // Still published, and still what deep links use: a link is built from the + // payload being rendered right now, so its id is current by construction. "id": progress.budget.id, "name": progress.budget.name, // Pre-formatted and compacted: the extension has no access to the user's diff --git a/pubspec.yaml b/pubspec.yaml index b187bd0c..11207372 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.24.0+353" +version: "0.24.0+354" environment: sdk: ">=3.10.0 <4.0.0" diff --git a/test/unit/budget_widget_payload_test.dart b/test/unit/budget_widget_payload_test.dart index 1f264f63..699f28c3 100644 --- a/test/unit/budget_widget_payload_test.dart +++ b/test/unit/budget_widget_payload_test.dart @@ -109,7 +109,7 @@ void main() { final Map payload = await BudgetWidgetSync.buildPayload(); expect(payload["version"], BudgetWidgetSync.payloadVersion); - expect(payload["version"], 1); + expect(payload["version"], 2); }); test("no budgets yields an empty list, not a missing key", () async { @@ -133,6 +133,8 @@ void main() { final Map budget = (payload["budgets"] as List).single as Map; + expect(budget["uuid"], isA()); + expect(budget["uuid"], isNotEmpty); expect(budget["id"], isA()); expect(budget["name"], "Groceries"); expect(budget["spent"], isA()); @@ -212,6 +214,43 @@ void main() { ); }); + test( + "a budget's uuid outlives the backup round-trip that renumbers its id", + () async { + final Account account = makeAccount(); + final Budget original = makeBudget("Groceries", 100.0); + spend(account, 40.0); + + final int idBeforeRestore = original.id; + final String uuid = original.uuid; + + final Map before = await BudgetWidgetSync.buildPayload(); + + expect( + ((before["budgets"] as List).single as Map)["uuid"], + uuid, + ); + + // What a restore actually does. `Budget.toJson` omits `id`, so the budget + // is reinserted and ObjectBox issues a fresh one — while `uuid` comes + // back untouched. + final Map exported = original.toJson(); + obx.box().removeAll(); + obx.box().put(Budget.fromJson(exported)); + + final Map after = await BudgetWidgetSync.buildPayload(); + final Map entry = + (after["budgets"] as List).single as Map; + + // The handle both widgets pin by. Unchanged, so a widget pinned to + // "Groceries" still shows Groceries after the user restores a backup. + expect(entry["uuid"], uuid); + // The handle they must never store: it moved. Pinning by this is how a + // widget ends up rendering whichever budget inherited the old number. + expect(entry["id"], isNot(idBeforeRestore)); + }, + ); + test("budgets arrive most-urgent first", () async { final Account account = makeAccount();