This guide maps Mothball's visible features to the code that implements them. It is intended for developers who need to understand a workflow before changing it. It complements Developer Documentation, which explains project boundaries, and Backup and Restore, which documents the backup format and restore policies in detail.
| Area | User capability | Primary UI area | Application or infrastructure entry point |
|---|---|---|---|
| Containers | Create, search, inspect, edit, and delete storage containers | UI/Features/Containers |
Container command and query handlers |
| Items | Create, search, inspect, edit, and delete items | UI/Features/Items |
Item command and query handlers |
| Inventory | Assign items to containers and manage quantities | Container details, item details, association pages | Inventory allocation and withdrawal services |
| Barcodes | Scan, display, assign, and find containers and item types | Details, create forms, lists, Shell, scanner page | Barcode assignment, lookup, and receipt services |
| Photos | Capture/select, resize, persist, display, and delete photos | Shared photo view model base | ImageService and photo services |
| Backup | Export JSON or ZIP and restore it using a merge policy | Settings | Backup workflow and restore planner |
| Settings | Select theme, backup format, advanced options, and signing | Settings | IApplicationSettings and backup services |
| Operations | View ongoing photo work after leaving a page | Background operations | Photo background operation tracker |
| Advertising | Display test or production banner and app-open ads on supported mobile platforms | Shared page base and app startup | AdMobSettings, BasePage, and MAUI AdMob setup |
| Navigation and errors | Move between feature pages and surface failures consistently | Shell and shared UI | Typed navigation requests and error presenter |
| Tags | Browse tags and open all matching items and containers | Tags catalogue and tag results pages | TagsListViewModel, TagResultsViewModel, and ITagRepository |
The Tags entry is available from the home page and the Shell flyout. The catalogue loads tags in case-insensitive display order and shows separate item and container assignment counts. Searching the catalogue is local to the loaded tag definitions; selecting a row opens the exact-tag results view.
The results view keeps the selected tag as an exact normalized criterion and offers an All, Items,
or Containers scope. Optional text search is applied to the selected aggregate type's existing name,
description, or notes query. Results from both aggregate types are merged and sorted by display name,
with the aggregate type as a stable tie-breaker. A result row only navigates to the existing item or
container details page, so quantity, edit, and delete actions retain their established context.
Tag usage counts are computed from assignment tables in SQLite and assignment rows in the JSON store. They are returned with the tag catalogue in one backend operation; the catalogue does not issue one query per tag. The mixed results page reuses the existing item and container query handlers, preserving their exact normalized tag matching and backend parity.
Containers represent physical places such as boxes, shelves, drawers, or cabinets. A container has a name, notes, and images. Item-to-container allocations are owned by the ItemInventory aggregate, not by Container itself; Container only exposes a read-only ItemTypeCount/TotalItemQuantity summary, hydrated by the repository layer from ItemInventory allocation data for display purposes.
- Create a container from the container list.
- Search containers by name or notes and optionally limit results to empty containers.
- Open details to see its items, quantities, images, notes, and distinct item-type count.
- Edit notes; persistence is debounced while the user types.
- Add an existing item or associate an item from its details screen.
- Delete a container through the container command handler.
- UI:
src/MothballMobile/UI/Features/Containers - Domain aggregate:
src/CoreApp.Domain/Entities/ContainerAggregate/Container.cs - Commands:
src/CoreApp.Application/Features/Containers/Commands - Queries:
src/CoreApp.Application/Features/Containers/Queries - Details presentation coordination:
ContainerDetailsItemsCoordinator - Search contract:
ContainerListSpecification
Container and item lists use PagedListViewModelBase<TSource, TViewModel>. Browsing and filtered search both load fixed-size pages.
initialize:
return immediately when the cached list matches the inventory revision
clear displayed items
request page 0
load next page:
stop when another load is already running
stop when the previous result was shorter than page size
request the current browse or search page
map each source result to a row view model
append mapped rows
increment page number
The final short page, or an empty page, marks the list as exhausted. Search text is trimmed, debounced, and retained as the active query while subsequent pages load. Clearing the query resets the collection and returns to paged browsing, including when a non-text filter remains active. The busy guard prevents overlapping requests from appending the same page twice.
ContainerListViewModel and ItemsListViewModel both add debounced search on top of PagedListViewModelBase through SearchablePagedListViewModelBase<TSource, TViewModel> (src/MothballMobile/UI/Shared/SearchablePagedListViewModelBase.cs). It owns the Query property, active query, and debounce wiring. A searchable list implements LoadPageAsync, SearchOperationName, and the normal mapping members. Filter changes re-run the shared search path so the active query and paging state remain consistent.
Every search request receives a monotonically increasing version. If a newer query or filter change arrives while an older request is running, the older result is discarded and the newest request is run again. This latest-request-wins rule protects the visible list from stale results when debounced text input and filters change close together. It complements cancellation: cancellation is used where the underlying operation supports it, while request versions remain the final guard for operations that cannot be interrupted.
List contents survive ordinary page appearances. IInventoryChangeTracker advances a process-local revision after successful inventory mutations and restores; a list reloads when its cached revision is stale. Pull-to-refresh always forces a reload regardless of the revision.
The SQLite repositories currently implement free-text search with case-insensitive LIKE predicates over the fields defined by each specification. The JSON repositories provide equivalent case-insensitive containment behavior. Any future SQLite FTS5 optimization must preserve the user-visible query contract or document an intentional change in token, phrase, prefix, and substring semantics. Structured tag filters should remain separate from free-text matching so exact tag selection can use relational indexes.
An integration probe in SqliteFts5SupportTests verifies whether the native SQLite provider exposes FTS5 on the current target. The probe is intentionally separate from production schema creation: FTS5 availability must be confirmed for every supported platform before adding virtual tables, backfill logic, synchronization triggers, and migration behavior.
Each page load emits a structured PagedListLoadMeasurement through IPagedListLoadDiagnostics. The log separates repository query time, synchronous row-population time, and total time, and identifies the list, filter/browse variant, page, page size, and result count. Query text is deliberately excluded. Image paths are resolved while each row view model is constructed, before the row is added, and are included in population time. MAUI image decoding and rendering happen later and are not included. Use these measurements to decide whether further work belongs in persistence, view-model population, or MAUI rendering.
Container details uses two-phase initialization. It publishes the container summary and photo paths first, allowing the dynamic-aspect-ratio carousel to render and size itself while the initial five-item query is in flight. Item rows are appended only after that query completes; a footer indicates that they are still loading. This keeps item-row creation and thumbnail rendering from blocking the first useful container header.
Items are catalogued things that may be stored in one or more containers. Their total inventory is derived from container allocations plus any unassigned quantity.
- Create an item with a name, description, and initial quantity.
- Search by name or description; filter by all, assigned, or unassigned items.
- Open details to edit metadata, view photos, inspect allocations, change total quantity, or delete the item.
- Open locations to see every container holding an item and its quantity there.
- Delete an item and its associated inventory state.
- UI:
src/MothballMobile/UI/Features/Items - Domain aggregate:
src/CoreApp.Domain/Entities/ItemAggregate/Item.cs - Inventory aggregate:
src/CoreApp.Domain/Entities/InventoryAggregate/ItemInventory.cs - Item commands and queries:
src/CoreApp.Application/Features/Items - Details use-case boundary:
ItemDetailsCoordinator - Details withdrawal workflow:
ItemInventoryWithdrawalCoordinator
ItemDetailsViewModel is presentation orchestration. Put new reusable item-detail use-case behavior in ItemDetailsCoordinator; keep prompt-driven state transitions in the withdrawal coordinator rather than adding more branches to the view model.
Containers and item types can have one optional native barcode value and symbology. The app stores decoded values, never source camera or gallery images. Barcode values are globally unique across containers and items; comparisons trim surrounding whitespace but remain case-sensitive. IInventoryQueryRepository.FindBarcodeAsync returns the typed owner for scan-to-find and collision checks, while IBarcodeAssignmentService permits an owner to retain, replace, or clear its own barcode but rejects another owner's value.
The separate IBarcodeRegistryService tracks generated Code 128 SKUs through reserved, assigned, and released states. New records generate an MB-{GUID:N} SKU by default unless the user disables generation or supplies an external code. Advanced Settings can reserve a bounded batch and share it as a print-ready PDF. A reserved SKU is claimed when entered or scanned during assignment; released SKUs remain permanently unavailable, preventing reprinting or accidental reuse. SQLite enforces normalized-value uniqueness with a database index, while JSON serializes registry updates through the operational store. Backup restore rebuilds assigned registry rows from restored record barcode fields and preserves outstanding reservations.
For the full user workflow and contributor reference, see Barcodes.
The scanner supports camera and gallery decoding. It is available from container and item lists, the Shell, create forms, and barcode detail editing. Scan-to-find opens the matching container or item details. In the item create flow, a scanned or typed barcode belonging to an existing item enters receipt mode: item metadata is locked, quantity defaults to one in simple mode, and saving delegates to IItemReceiptService. A receipt can remain unassigned, use the container context that opened the form, or scan a container barcode as its destination. The item/container association picker can likewise scan a container barcode and executes its normal available-quantity association path.
The create/receipt distinction is a short sequence with an important generation rule:
sequenceDiagram
actor User
participant Form as Add item/container form
participant Scanner as Barcode scanner
participant VM as Form view model
participant Lookup as Inventory barcode lookup
participant Save as Create or receipt handler
User->>Form: Tap scan
Form->>Scanner: Start camera or gallery scan
Scanner-->>Form: Decoded value and symbology
Form->>VM: Apply scanned barcode
VM->>VM: Preserve value and symbology
VM->>VM: Disable internal-code generation
User->>Form: Save
Form->>Lookup: Check barcode ownership
alt Existing item barcode
Lookup-->>VM: Existing item
VM->>Save: Receipt quantity for existing item
else New or unassigned barcode
Lookup-->>VM: Available barcode
VM->>Save: Create record with supplied barcode
end
Save-->>User: Updated details or receipt result
Barcode labels are generated as PDFs with SkiaSharp and shared through the MAUI IShare abstraction. Detail-page sharing creates one label. List sharing supports selected loaded rows and an explicit all-matching query that preserves the active search and filter; it does not infer that unloaded pages are selected.
An item can have allocations in multiple containers. Each allocation is a container ID plus a positive quantity. The app also supports unassigned quantity, so an item can exist before a storage location is known.
The inventory model keeps the invariant
- Add an existing unassigned item to a container.
- Associate an item with a selected container from item details.
- Increase or decrease a container allocation.
- Change an item total, withdrawing from assigned and then unassigned stock as needed.
- Consume an exact quantity from one explicitly selected container or from unassigned stock.
- View all locations and quantities for an item.
- Association workflow:
CoreApp.Application/Features/Containers/Association - Quantity changes:
CoreApp.Application/Features/Inventory/Allocation - Withdrawal planning:
CoreApp.Domain/Inventory/ItemInventoryWithdrawalPlanner.cs - Interactive withdrawal workflow:
ItemInventoryWithdrawalCoordinator - Source-specific consumption workflow:
ItemConsumptionCoordinator
Editing and consumption are deliberately separate operations. Editing the total retains the target-total workflow below, while editing a container allocation can move stock between assigned and unassigned states. Consumption permanently reduces both the selected source and the total. It never converts consumed assigned stock into unassigned stock and never carries a request into another source automatically.
In a general item context, consumption begins with a source picker. In a container context, the current container is offered first but must still be confirmed; declining that prompt opens the same general source picker, even when there is only one container allocation.
Editing a container's item quantity touches counts at two levels, and both must be refreshed from the result of the save rather than the value the user entered: ContainerItemQuantityService.SaveQuantityAsync returns an ItemInventoryUpdateResult (nested as Inventory on ContainerItemQuantityUpdateResult/ContainerDetailsQuantityUpdate, rather than duplicating its fields) with the item's recalculated total/assigned/unassigned quantities and removal state. ContainerDetailsItemsCoordinator.SaveQuantityAsync applies it to the edited row via ItemWithImagesViewModelBase.UpdateQuantities and refreshes the container header's item-type and total-item counts from the accompanying ContainerDetailsSummary, through the IContainerDetailsHeader seam so the coordinator does not depend on the concrete ContainerDetailsViewModel. Skipping either update leaves the tile or the header showing stale numbers after an edit.
ItemInventoryWithdrawalPlanner is a pure domain planner. It does not persist anything or display prompts. It validates inputs and returns the target inventory state that the coordinator can commit.
The withdrawal process answers two questions: which stock should be removed, and how should the user confirm that removal?
An item has a total quantity, quantities assigned to containers, and any remaining unassigned quantity. The inventory invariant is:
total quantity = assigned quantity + unassigned quantity
The process then follows these rules:
- Remove assigned stock first.
- Remove it from the specific containers selected by the user.
- If a container runs out, carry the leftover amount to the next selected container.
- Use unassigned stock only when it is needed or the user accepts the unassigned-stock prompt.
- Return the remaining quantities.
- Mark the item for deletion if nothing remains.
The adjustment session guides the user through these steps. The planner checks the choices and calculates the final result. The inventory aggregate applies that result.
In short:
remove assigned stock
track leftovers when a container runs out
use unassigned stock separately
validate the final quantities
save or delete the item
validate total, target total, allocations, and requested withdrawals
copy allocations into mutable remaining allocations
for each assigned withdrawal:
locate its container allocation
remove as much as possible from that allocation
carry any remainder to the next assigned withdrawal
reject the plan when the remainder cannot be assigned
ensure remaining assigned quantity does not exceed the target total
for each unassigned withdrawal:
remove up to the available unassigned quantity
stop when the total reaches zero
return remaining allocations, assigned quantity, unassigned quantity,
final total, and whether the item should be deleted
The interactive withdrawal flow can be summarized as an activity diagram:
flowchart TD
Start([Request lower total or consume stock]) --> Context{Container context?}
Context -->|Yes| Preferred[Offer current container first]
Context -->|No| SourcePicker[Open source picker]
Preferred --> Confirm{User confirms source?}
Confirm -->|No| SourcePicker
Confirm -->|Yes| Assigned
SourcePicker --> Assigned[Select assigned withdrawal]
Assigned --> Capacity{Enough in selected container?}
Capacity -->|No| Carry[Remove available amount\ncarry remainder forward]
Capacity -->|Yes| RemoveAssigned[Remove requested assigned amount]
Carry --> NextAssigned[Select next assigned source]
NextAssigned --> Capacity
RemoveAssigned --> Unassigned[Apply unassigned withdrawal if needed]
Unassigned --> Validate[Validate final inventory invariant]
Validate -->|Invalid| Reject([Reject plan and keep inventory unchanged])
Validate -->|Valid, total > 0| Save([Persist updated inventory])
Validate -->|Valid, total = 0| Delete([Persist deletion plan])
The planner remains pure; the coordinator owns prompts and the final persistence call.
The carried remainder is important: a requested withdrawal may span multiple locations, but it must be explicitly allocated across them. This avoids silently subtracting stock from an arbitrary container. Invalid allocations, negative quantities, and plans that cannot reach the requested total are rejected before persistence.
Decrease priority depends on where the edit starts. The general item list consumes available unassigned stock first because no container is in context. If the requested decrease exceeds unassigned stock, the remaining amount enters the interactive container-withdrawal workflow. Item details retains the container-aware assigned-first workflow described below, including its preferred container when one is present in navigation context.
The assigned-first planner calculates the minimum assigned withdrawal:
required assigned withdrawal =
min(current total - requested total, assigned quantity)
For example, if the current total is 10, the requested total is 8, and five units are assigned to containers, an assigned-first edit withdraws two assigned units. If the requested reduction is larger than all assigned stock, all assigned stock is withdrawn and the remainder comes from unassigned stock.
When a selected container does not contain enough stock, the planner removes what is available and carries the remainder forward:
Box contains: 3
Requested from Box: 5
Removed from Box: 3
Carried amount: 2
The next assigned withdrawal must be at least two. This is an interactive sequencing rule: the system does not silently choose another container to satisfy the remainder.
After assigned withdrawals, the session builds a temporary target total:
staged total = max(requested total, current total - assigned withdrawn)
This prevents the planner from claiming that the item has reached a lower total than the assigned withdrawals justify. Any difference between the staged total and the remaining assigned quantity is represented as unassigned quantity.
The planner then applies each unassigned withdrawal to the available unassigned quantity. It caps each withdrawal at what is available and stops when the total reaches zero. A zero final total produces a deletion plan rather than an item with zero quantity.
The same calculation can be viewed as a capacity-flow problem. Each container is a source node whose capacity is its available quantity, and unassigned stock is another source node. A withdrawal is the demand that must be supplied by those sources.
flowchart LR
W[Withdrawal demand] --> A[Assigned stock]
W --> U[Unassigned stock]
A --> C1[Container A]
A --> C2[Container B]
A --> C3[Container C]
This graph view is useful if the application later needs to optimize choices, such as preferring the fewest containers, the nearest containers, or containers with the earliest expiration dates. A min-cost flow algorithm could then select the cheapest valid distribution.
For the current workflow, however, a graph does not simplify the main interaction. The user explicitly selects containers, and an over-sized selection creates a carried remainder that the user must assign next. A normal flow algorithm would distribute the withdrawal automatically and would remove that confirmation step. The current design therefore remains intentionally split:
ItemInventorystores inventory invariants and applies the completed plan.ItemInventoryWithdrawalPlannervalidates capacities and calculates the result.ItemInventoryAdjustmentSessionmanages user choices and carried remainders.ItemInventoryWithdrawalCoordinatordisplays prompts and passes answers to the session.
This is best understood as an ordered capacity-allocation algorithm with an interactive state machine, rather than as a general graph algorithm.
For new withdrawal rules, change or extend the planner first and add scenario tests in ItemInventoryWithdrawalPlannerTests. The UI coordinator should only collect selections and commit the resulting plan.
Items and containers can have photos sourced from the camera or image picker. Photos are resized and stored locally, while image metadata is persisted through repository contracts.
- Select or capture a photo from item or container details.
- See staged progress for long-running photo work.
- Navigate away while processing continues.
- Open Background Operations to see active work.
- Delete an image reference and its stored file.
- Shared UI behavior:
UI/Shared/PhotoDetailsViewModelBase.cs - Image workflow:
CoreApp.Application/Features/Photos/ImageService.cs - Cleanup:
PhotoDeletionServiceand file-persistence services - Platform source access:
Infrastructure.Platform.Maui/Services - Global progress:
Infrastructure/BackgroundOperations/Photos
obtain a source stream from camera or picker
decode and resize the image
save the output file
write image metadata through the persistence contract
if metadata persistence fails:
remove the in-memory image reference
attempt to delete the newly saved file
rethrow the failure
The compensation step prevents an orphan file when database or JSON persistence fails after a successful file write. Progress is deliberately staged around load, transform, and save because the image library does not expose safe fine-grained pixel progress callbacks.
Search boxes and editable text should not trigger persistence or queries for every keystroke. Debouncer implements trailing-edge behavior: only the most recent request that remains quiet for the configured delay is executed.
on each request:
lock shared state
cancel and dispose the previous cancellation source
create a token linked to the caller token
wait for the configured delay
if canceled, stop
otherwise execute the latest action
when complete:
clear the shared token only if it is still this request's token
The identity check in cleanup prevents an older operation from clearing the cancellation token for a newer request. Disposal cancels any pending action. Use this for trailing search and delayed updates; do not use it for commands that must execute every time, such as a quantity adjustment.
View models wire the debounce the same way: a generated partial void On<Property>Changed hook (e.g. OnQueryChanged, OnSearchQueryChanged) calls debouncer.DebounceAsync(_ => MainThread.InvokeOnMainThreadAsync(SearchAsync)).FireAndForget(backgroundTasks, "..."). Prefer that hook over manually subscribing to PropertyChanged in the constructor — it is what ContainerListViewModel, ItemsListViewModel, and ContainerDetailsViewModel all do.
For a "confirm, then act" command (delete container/item/photo/backup, remove from container), use IPopupService.ConfirmAndRunAsync(definition, action) (src/MothballMobile/Infrastructure/Presentation/Popups/PopupServiceExtensions.cs) instead of hand-rolling if (!await popup.ConfirmAsync(...)) return;. It only fits when the action should run solely on confirmation; branches that run shared code regardless of the answer should keep using ConfirmAsync directly.
Relevant code:
src/MothballMobile/Infrastructure/Resilience/Debouncer.cssrc/MothballMobile/Infrastructure/Resilience/RetryService.cssrc/MothballMobile/UI/Shared/PagedListViewModelBase.cs
Mothball can export JSON metadata or a ZIP archive containing metadata and available photo files. Backups include containers, items, allocation relations, image references, version metadata, and integrity data.
- Select JSON or ZIP export in Settings.
- Export a dated JSON or ZIP backup to the app's
Backupsfolder. - Share a selected local backup through the platform share sheet.
- Restore a selected local backup or import a JSON/ZIP file from the device file system.
- Select a conflict policy before an import or restore.
- Optionally enable backup signing keys for HMAC verification.
- UI orchestration:
MothballMobile/Infrastructure/Backup/InventoryBackupWorkflowService.cs - Export:
CoreApp.Application/Features/Backup/Export - Archive handling:
CoreApp.Application/Features/Backup/Archive - Restore planner:
CoreApp.Application/Features/Backup/Restore/Planning - SQLite atomic implementation:
Infrastructure/Services/Restore/SqliteInventoryBackupRestoreService.cs - Detailed contract and policy reference: Backup and Restore
The Settings screen keeps file selection and confirmation in the UI, while InventoryBackupWorkflowService owns the reusable file workflow:
export:
obtain the optional signing secret when signing is enabled
export JSON or ZIP through the application exporter
save it as mothball-backup-{UTC timestamp}.json or .zip in Backups
share:
select a local backup file
resolve its app-data path
invoke the platform share sheet on the UI thread
import:
ask the user for a restore policy
read a selected local backup or a picker-provided external file
dispatch JSON to RestoreJsonAsync or ZIP bytes to RestoreZipAsync
display the restore result or a user-facing failure
External file import intentionally reuses the same restore methods as app-local backups. File selection is not a second restore implementation. This keeps integrity validation, signature lookup, merge policy handling, and backend behavior identical regardless of where the file originated.
The file workflow and restore dispatch are easier to review as a sequence:
sequenceDiagram
actor User
participant Settings as Settings view model
participant Workflow as InventoryBackupWorkflowService
participant Files as File handler / picker
participant Exporter as Backup exporter
participant Restore as JSON or ZIP restore service
participant Repositories as Repository contracts
alt Export
User->>Settings: Choose JSON or ZIP export
Settings->>Workflow: Export selected format
Workflow->>Exporter: Build signed or unsigned payload
Exporter->>Repositories: Read inventory data
Repositories-->>Exporter: Inventory snapshot
Exporter-->>Workflow: JSON or ZIP bytes
Workflow->>Files: Save backup locally
Files-->>User: Backup available to share
else Import or restore
User->>Settings: Choose file and conflict policy
Settings->>Files: Read local file or open picker
Files-->>Settings: JSON text or ZIP bytes
Settings->>Workflow: Restore selected format and policy
alt JSON backup
Workflow->>Restore: Restore JSON payload
else ZIP backup
Workflow->>Restore: Restore ZIP metadata and photos
end
Restore->>Repositories: Apply planned changes
Repositories-->>Restore: Restore result counters
Restore-->>Settings: Success or failure
Settings-->>User: Show restore result
end
Restore separates planning from execution. The planner receives backup data and a snapshot of existing state, then emits inserts, updates, deletes, and skip counters. This gives the generic and SQLite restore services the same policy decisions.
parse payload and validate payload/schema versions
verify required checksum and optional HMAC signature
load existing containers, items, relations, and image references
for backup containers and items:
insert missing roots
update existing metadata only when the policy permits it
if policy deletes missing roots:
schedule existing roots absent from the backup for deletion
restrict known child owners to surviving roots
normalize children:
discard non-positive relations
discard relations or images whose owners do not exist
apply child strategy:
additive: add missing quantities and image references
exact: insert, set, or delete relations and images to match backup
return the plan and result counters
AddOnly is non-destructive. AddAndUpsertMetadata also updates root metadata. FullSync deletes roots not present in the backup but leaves surviving children additive. StrictFullSync uses exact child reconciliation as well. See Backup and Restore before changing these semantics; they are compatibility-sensitive.
SQLite execution applies the plan in one transaction. The backend-agnostic implementation executes through application repository contracts, allowing the JSON backend to reuse the same plan.
The JSON backend is an operational store rather than a single mutable JSON file. It keeps two data slots and two manifest files so a partial write does not destroy the last known-good state.
commit:
write a complete new state to the inactive slot
verify the slot is complete
write a next-generation manifest pointing to that slot
alternate manifest files on successive generations
startup recovery:
read both manifest candidates
reject unreadable or structurally invalid manifests
verify each candidate's current and previous slots
select the highest-generation usable manifest
use its current slot when complete
otherwise synthesize a rollback to its previous complete slot
This is a small two-phase protocol: state data becomes complete before a manifest makes it active. If power loss or an exception interrupts a commit, startup can choose an older valid manifest or fall back to the prior slot. The implementation does not try to repair arbitrary corrupt data; it prefers a verified complete state.
Relevant code:
src/Infrastructure/Services/JsonStore/JsonInventoryStore.cssrc/Infrastructure/Services/JsonStore/JsonStoreManifestManager.cssrc/Infrastructure/Services/JsonStore/JsonInventoryStore.State.cssrc/Infrastructure/Services/JsonStore/JsonInventoryStore.Storage.cs- Detailed operational format: JSON Operational Store
When changing JSON state shape, update row models, repository behavior, slot-completeness validation, and tests for normal commits plus recovery paths. Verify equivalent externally observable behavior in SQLite where the application contract is shared.
Settings currently cover theme preference, backup format, advanced mode, and backup signing. They are surfaced from SettingsViewModel and stored through IApplicationSettings.
Startup is coordinated through IAppStartupOrchestrator. It initializes the selected persistence backend and any startup work before the app shows the main shell. Startup failures use a retryable startup error experience rather than allowing a partially initialized app to proceed.
Relevant code:
src/MothballMobile/UI/Features/Settingssrc/MothballMobile/Infrastructure/Settingssrc/MothballMobile/Infrastructure/Startupsrc/MothballMobile/App.xaml.cs
Development builds may seed demo data. The seeder identifies its own containers with a fixed marker token, so normal user-created containers remain untouched. See Seeding.
Mobile builds on iOS and Android initialize the AdMob plugin at app startup. BasePage wraps ordinary page content in a two-row layout and reserves the lower row for a banner. A development placeholder remains visible until an ad loads and returns if the ad fails to load, so page layout remains stable during ad lifecycle changes.
application startup:
initialize AdMob on supported platforms
load AdMob settings
use Google's test IDs in Debug
use validated packaged production IDs in Release
page load / handler creation:
wrap page content once with a banner host
create a banner from configured banner ID
hide the placeholder after a successful load
show the placeholder after a load failure
AdMobSettings uses test IDs in Debug and requires valid packaged app-open and banner IDs for iOS/Android Release builds. Other targets receive empty settings and do not add the banner. See AdMob Configuration for the required Release files and CI setup.
View models navigate through INavigationService. Callers construct an INavigationRequest record rather than a Dictionary<string, object>. The navigation service converts the request to a dictionary only at the MAUI Shell boundary, which keeps route keys and parameter serialization out of feature code.
view model creates a typed request record
-> INavigationService converts it to Shell parameters
-> Shell navigates to the route
-> destination reads the resolved route parameters
BaseViewModel.RunCommandAsync is the shared command envelope. It marks the view model busy, clears stale errors, runs the action, captures a user-displayable failure message, emits ErrorOccurred, rethrows for command semantics, and finally restores the busy state. BasePage forwards the event to the singleton IAppErrorPresenter, and AppShell displays a dismissible banner.
Relevant code:
src/MothballMobile/Infrastructure/Navigationsrc/MothballMobile/UI/Shared/BaseViewModel.cssrc/MothballMobile/UI/Shared/BasePage.cssrc/MothballMobile/Infrastructure/Presentation/Errorssrc/MothballMobile/AppShell.xaml
When adding a parameterized route, define a request record, add serialization tests, and preserve Shell conversion as an infrastructure concern. Do not introduce direct Shell.Current calls in view models.
SQLite is the default backend. The JSON operational store can be selected with MOTHBALL_PERSISTENCE_BACKEND=Json (or JsonOperationalStore). Both implement the application repository contracts.
For a data feature that must work in both modes:
- Put invariants in Domain when they are format independent.
- Add the application contract or use-case behavior in Application.
- Implement equivalent SQLite and JSON behavior.
- Include the data in backup/export and restore if it is portable.
- Add focused behavior tests and backend-parity coverage.
Do not expose SQLite row models, JSON row models, MAUI APIs, or Shell dictionaries across these boundaries. The project uses those restrictions to keep its feature algorithms testable outside the mobile runtime.
| Concern | Primary test location |
|---|---|
| Withdrawal validation and invariants | tests/UnitTests/CoreApp/Features/Inventory/ItemInventoryWithdrawalPlannerTests.cs |
| Backup planning and integrity | tests/UnitTests/CoreApp/Features/Backup/InventoryBackupRestorePlannerTests.cs |
| JSON commit and recovery | tests/IntegrationTests/Infrastructure/Persistence/JsonOperationalStoreTests.cs |
| Backend parity | tests/IntegrationTests/Infrastructure/Persistence/BackendParityTests.cs |
| Navigation request serialization | tests/UnitTests/MothballMobile/Infrastructure/Navigation/NavigationRequestTests.cs |
| Shared command error state | tests/UnitTests/MothballMobile/UI/Shared/BaseViewModelTests.cs |
| Error presentation | tests/UnitTests/MothballMobile/Infrastructure/Presentation/Errors/AppErrorPresenterTests.cs |
| ZIP archive restore | tests/UnitTests/CoreApp/Features/Backup/InventoryBackupZipRestoreServiceTests.cs |
Run the relevant focused tests during an algorithm change, then run the full project suite:
dotnet test Mothball.Tests.slnf -v minimal