Feature development: Architecture, data, and reliability
Stitch Counter V2 is a local-first craft app on Google Play. These notes cover how I set up architecture, Room, zip backup, and UI state so new features do not turn into one-off fixes.
How I structure feature work
Problem
Business rules were ending up in every screen
A craft counter looks simple until you add a project library, photos, deletes that must clean up files, and phone plus tablet navigation. Without clear layers, schema tweaks and new screens start rewriting the same logic in three places.
Approach · App architecture
Thin screens over use cases and Room
What it does
Compose and ViewModels on top; small use-case classes for validation and orchestration; Room underneath with Flow-backed queries so lists refresh when the database changes. Hilt wires the DB, repository, and use cases once so feature code only pulls what it needs.
Why I built it this way
- Domain models and mappers sit between Room entities and the UI, so schema tweaks do not ripple through every screen
- Deletes run through use cases that remove image files and rows together so files and rows stay in sync
- Compose Destinations plus one root scaffold keeps tabs, rail, and sheets aligned on phones and tablets
Outcome
Most changes stay in one layer. Reviews stay smaller, and new features do not require re-threading the whole codebase.
Takeaway
The win is not “more architecture.” It is knowing where a bug or schema change lives before you open the IDE.
Problem
Project data has to survive restarts and new features
Stitch counts and project notes only matter if they are still there next week. The data model had to support a growing library of projects, counters, and related fields without turning every screen into a special case.
Approach · Data architecture
Room-backed project library with clear relationships
What it does
Create, edit, and organize projects with persistent local storage through Room. Preferences and active theme state live in DataStore so UI settings stay separate from project rows.
Why I built it this way
- Data persists across sessions; no cloud account required
- Relationships between projects, counters, and related fields stay explicit in the schema
- UI and persistence stay separated so list and detail screens can evolve without rewriting storage
Outcome
Users keep long-running projects locally. New features read and write through the same foundation instead of adding a second store.
Takeaway
For a local-first app, a boring durable schema matters more than fancy sync on day one.
Problem
Users need to move their library to a new phone
Privacy-first meant no cloud sync. Users still needed a way to move a full library (metadata plus images) to a new device without corruption or a silent half-restore.
Approach · Data safety
Zip backup and restore with validation
What it does
Export and restore full app data locally: zip metadata with embedded image bytes, typed failures, and clear success or error messaging in Settings. No cloud account required.
Details
- Validation before restore so invalid payloads fail safely instead of crashing mid-import
- Clear feedback for success and failure so users know whether the library landed
- Missing or malformed files handled so behavior stays predictable
Outcome
Users can export a zip and restore on another device. The app stays offline-first, and support has one file to ask about when something goes wrong.
Takeaway
Backup is a product feature for a local-first app. Invalid restores need the same attention as the happy-path export.
Problem
Big libraries and tabs need one source of truth for state
As the project list grows, scrolling and tab selection have to stay tied to real data and the navigation back stack. One-off UI flags drift; Flow queries and a root scaffold do not.
Approach · Performance & state
Lazy lists, controlled scrolling, navigation-derived selection
What it does
LazyColumn-based lists with controlled scrolling for large libraries, debounced autosave where rapid edits would otherwise thrash storage, and a root navigation shell that derives the selected tab from the NavController back stack for the bottom bar and rail.
Why I built it this way
- Lazy lists keep large libraries usable as projects accumulate
- Tab selection follows navigation instead of a separate flag
- Debounced autosave cuts down writes during fast editing
Outcome
Library browsing and tab switches stay predictable as the catalog grows. State comes from the database and navigation graph, not duplicated screen flags.
Takeaway
Lists observe Flow; chrome observes the back stack. That was less work than keeping screen flags in sync by hand.