Skip to content

Pyggo Developer Guide

Pyggo logo

This guide is the technical source of truth for the codebase. It is written for someone who has never seen the project before: it starts with the vocabulary and conventions, then works through the layers from models up to the UI, then the subsystems (sync, Wear OS), then testing, building, and CI. CONTRIBUTING.md covers the gates a change has to pass; AGENTS.md holds the agent-facing quick reference.

Contents

Code conventions

These apply to everyone working in the tree, human or agent. CONTRIBUTING.md covers the gates a change has to pass; this section covers how the code itself is expected to read.

Order things alphabetically

Unless a different order is clearly better, order alphabetically. Functions and methods within a class, top-level declarations within a file, imports, exports, fields, named parameters, enum values, map literal entries, switch cases, and list constants. Alphabetical order is not better than a good order — it is better than an arbitrary one, which is what any file ends up with once several people have appended to it. The point is that you can find a member by name without reading the whole file, and that a diff shows the change instead of the churn.

Order by a different rule when there is a real one, and it will usually be obvious:

  • Lifecycle and framework order. initState, didChangeDependencies, build, dispose go in the order Flutter calls them.
  • Fields, then constructors, then methods. The existing shape of the models wins over alphabetizing every member into one flat list; alphabetize within each of those groups. A model's fields, constructor parameters, copyWith parameters, and toJson entries are one cluster — keep them in the same order so the four views of the model agree verbatim.
  • Sequence. Steps of a pipeline, phases of a sync, migration versions — reordering these to satisfy the alphabet makes the file harder to read, not easier.
  • Domain order. ValueList.brand before ValueList.type happens to be alphabetical, but weekdays, unit ladders, and severity levels are not; keep the meaningful order. Grouping constants under // section comments by domain (as JsonKeys does) is a real order too — alphabetize the entries within each section.
  • Related members that only make sense together. A private field and its accessors, paired helpers such as _getX/_setX, a public entry point ahead of the private helpers it calls, or a switch whose cases only work in one order may stay adjacent. Alphabetize the members within each cluster — paired helpers sort by the name they share — and the clusters among themselves when nothing else dictates their order.

When you override the alphabet, the reason should be visible from the code. If it takes a comment to explain the ordering, write the comment.

The directives_ordering lint enforces the import half of this and runs as part of flutter analyze, so CI catches it. Everything else is on review.

Naming

  • Follow the official Dart style guide.
  • lowerCamelCase for variables, fields, and parameters; UpperCamelCase for types and classes.
  • Keep names descriptive and unabbreviated. The rule of thumb: if someone would say the abbreviation out loud in person — repo, args, info — it is fine. The other exceptions are common domain abbreviations — id, url, csv, json, ui, l10n, db — when they stand for their standard concept.
  • Single-letter names are never descriptive enough: write error not e in catch clauses, index (or better, dayIndex, legIndex) not i in loops, and trip/entry/item not t/e/i in closure parameters. Import prefixes are the exception — they follow their package's own convention (p for package:path, pw for package:pdf, md for Markdown helpers).
  • Shell and Python helpers under tool/ and ci/ use snake_case.

Everything else

  • Prefer const constructors wherever they are available.
  • Prefer readability over cleverness. This codebase is read far more often than it is written, and its hardest bugs are in sync, where a clever line costs hours.
  • Keep code consistent — match the conventions of the code around you. When a convention is wrong or missing, fix the document in the same change rather than diverging quietly.
  • Comment the why, not just the what. The why is the part that is most often missing — a non-obvious trade-off, an invariant, or the bug a change is preventing — so lead with it, but it is better to have a comment and not need it than to need one and not have it. Comment liberally: intent, invariants, and non-obvious behavior all earn a comment; a line that only restates the code below it earns one less often, but when in doubt, write the comment.
  • The app is offline-first. Handle the offline case explicitly rather than assuming a request succeeds.
  • Guard BuildContext use after an await with a context.mounted check.
  • Use context.watch for UI rebuilds and context.read for one-shot actions.
  • Keep UI code in screens/, widgets/, and dialogs/; business logic in services/ and providers/.
  • Catch Object, not Exception, at async boundaries. Several failure modes here are Error subtypes — Git2DartError from libgit2, TypeError from a hard cast on malformed remote JSON — and an on Exception guard lets them escape as unhandled async errors, leaving the operation silently doing nothing while the UI still shows the old state. on Object catch is the broad form the avoid_catching_errors lint allows; catching a specific Error type on purpose (a TypeError from JSON casts, a Git2DartError) needs // ignore: avoid_catching_errors with a comment saying why that catch is deliberate. Narrower catches stay valid for genuinely narrow scopes — on FormatException around a DateTime.parse — the rule is about boundaries, not about outlawing precise catches.
  • All code should have test coverage where practical, and code changes come with tests. Add or update tests when changing code; new features ship with tests alongside the code. The three gates in CONTRIBUTING.md run locally before pushing.
  • New features come with both user and developer documentation: update docs/user_guide.md (and README.md/CHANGELOG.md when visible to users) and this guide in the same change as the code, never as a follow-up.
  • Open external URLs with launchExternalUrl in lib/core/url_safety.dart: it allow-lists the scheme (trip links and URL custom values arrive from the sync remote), launches, and reports failure in a snackbar. Never gate launchUrl on canLaunchUrl — it returns false for URLs that launch fine under Android 11+ package visibility and inside Flatpak/Snap, which is how the URL field's open button once ended up silently dead. canLaunchUrl is also not a safety check; file:// is perfectly launchable.

User interface

  • Keep it intuitive and uncluttered.
  • Prefer icon-only buttons unless the meaning would be unclear without a label.
  • Stay consistent across screens: the same button order, placement, and icons for the same actions.
  • Make dialogs with multi-column content or multiple action buttons responsive. Use dialogConstraints(context) and dialogInsetPadding from lib/widgets/responsive_dialog.dart so the dialog fills the full width on narrow screens and stays capped at 560 dp (or another sensible max) on larger screens. The journal editor and other especially large dialogs can use a higher cap.

Terminology glossary

The terms below are the canonical user-facing vocabulary. UI strings, the user guide, comments, and new code should all use them. Where a model or storage name intentionally differs, the internal name is listed so the mapping is explicit instead of discovered by accident.

User-facing wording wins. When UI text and an internal name disagree, rename toward the UI — unless the name is a serialized JSON key, an external file format, or a documented compatibility alias. Those stay stable and are marked internal below.

Core entities

Term Meaning Internal names
Gear The owned-inventory tab and the domain of things the user has. Gear tab, gear storage key
Gear item One owned item in Gear. GearItem
Type A gear item's kind ("Tent", "Headlamp"); managed under Lists & fields > Dropdown lists > Type names. GearItem.type, ValueList.type. genericName survives only as a CSV/import alias for type — never in UI or comments.
Category The grouping a gear item belongs to ("Shelter", "Kitchen"). GearCategory, GearItem.category
Pack List A reusable packing list; the tab label is Pack Lists. PackingList, PackingListEntry
Packing list Lower-case prose for a pack list, including a trip's per-leg list. PackingList
Trip A dated outing with one or more legs. Trip
Leg / trip leg A leg of a trip with its own dates and pack list. The primary leg is the earliest by start date. TripLeg, primaryLeg
Day / trip day One calendar day of a trip, shown as a day tile. TripDay, TripDayTile
Pack entry One row on a trip leg's pack list — a gear item plus quantity and pack state. PackEntry; TripLeg.packEntries is authoritative, Trip.entries is a compatibility projection
Journal / journal entry The notes log on gear and trips; one dated note is a journal entry. JournalEntry; its text field is note (Markdown)
Attachment A file attached to gear, a trip, or a pack entry. Images are attachments rendered inline. ManualAttachment, attachments, images
Saved view A named, reusable set of quick filters. SavedView
Quick filter An ad-hoc filter row on a list. QuickFilter, QuickFilterType
Gear template A prefill template for new gear items. GearTemplate
Field A built-in or user-defined property, managed under Lists & fields > Field management. FieldDefinition, FieldSchema, FieldEntity; values live on the model or in customValues
Dropdown list A shared list of options for select fields. ValueList, AppLists, customValueLists

Pack locations and pack state

Term Meaning Internal names
Pack location Where a pack entry sits: a container, a compartment, Packed Loose, or Unorganized. packLocation helpers
Container A gear item with compartments that other items pack into. containerGearId, GearItem.compartments
Compartment A named space inside a container. PackEntry.compartment
Packed Loose Packed directly in the bag rather than in a container. Capitalize both words. packedLoose (serialized key stays)
Unorganized Not packed loose and not in a container. —
Pack location preference The remembered pack location for a gear item, scoped to a packing list — and therefore to a trip type through its default packing list. PackLocationPreference
Packed / unpacked Whether the item is physically in the bag. PackEntry.packed, packAll/unpackAll
Prepped / not prepped Whether the item's prep task (charge, wash, …) is done. PackEntry.prepped, prep, prepLeadTimeDays, "Prep by"/"Prep deadline" labels
Used Whether the item was used on the trip. PackEntry.used, markItemUsed/unmarkItemUsed

Trips

Term Meaning Internal names
Overview Free-text trip description on the trip's Overview card. Trip.overview. The legacy details JSON key and Trip.details still decode for old data — never add new "details" UI.
Itinerary The trip's day-by-day plan. itinerary
Trip type / trip conditions / trip weather The categorizing dropdown lists for trips. tripType, tripConditions, tripWeather
Pack list layout How a trip's pack list is grouped and sorted. The custom-field option is labelled Group field; the packing list editor calls the same control Group by. layoutPreset key, TripLayoutPreset, groupByField
Show base weight Toggle to include container/base weight in totals. showBaseWeight
Featured trip The trip pinned on the Home tab ("Today" or "Last trip"). currentOrLastTrip, lastOpenedTripId
Lock trip gear / gear snapshot Freezes the gear list on a trip so later gear edits don't rewrite history. gearSnapshot, lockTripGear
After trip report The per-leg wrap-up report. AfterTripReportScreen

Weather

Term Meaning Internal names
Forecast Live forecast for near-future dates. WeatherSource.forecast
Historical actual Recorded weather for past dates. WeatherSource.historicalActual
Typical Climatology range for far-future dates ("typical" lows/highs). WeatherSource.historicalClimatology

Settings and actions

The settings sections are Data, Units, Display & behavior, Lists & fields, Templates & views, Sync, Logging, About. Name sub-screens by their labels — Lists & fields > Dropdown lists > Type names, Display & behavior > List details — not invented paths like "Settings > List management".

Action wording:

  • Restore defaults resets managed lists, fields, and categories — not "Reset to defaults".
  • Add gear adds gear to a trip, leg, or list — including on Wear OS.
  • Pack-entry actions are Mark used / Unmark used, Prep / Unprep, Pack into, Repack into compartment, Save as default pack location, Edit quantity, and Remove from trip.
  • Import / Export / Share as .trip; .trip is the archive format.

Internal-only vocabulary

These are real terms but belong in code and sync internals, not user-facing text: tombstone, baseline (sync_state.json), three-way merge, updatedAt, containerGearId, packedLoose, customValues, layoutPreset, sourcePackingListId. Wear OS surfaces call the device a watch.

Architecture

Pyggo is a standard Flutter application with a small service-oriented architecture. The repository layout:

  • lib/core/ – shared utilities: Result<T, E> (a sealed Success/Failure type used instead of exception-based control flow where the caller must decide how to handle errors), JSON parsing helpers, pack-weight math, and weather formatting.
  • lib/models/ – plain Dart models with JSON serialization and copyWith helpers. models.dart is the barrel export.
  • lib/data/ – generated icon maps (*.g.dart files produced by tool/generate.dart).
  • lib/services/ – pure-Dart business logic and I/O: storage, sync, import/export, weather, the wear bridge.
  • lib/providers/ – AppState, the ChangeNotifier that coordinates UI and persistence.
  • lib/screens/, lib/widgets/, lib/dialogs/ – the UI layer. Shared settings screens live under lib/screens/settings/.
  • lib/wearos/ – the Wear OS companion app (watch-only UI and state). The phone app never imports this directory.
  • test/ – flutter_test unit and widget tests.
  • tool/ – build and generator helpers (build.sh, generate.dart, signing scripts).
  • ci/ – the CI builder image definition (Dockerfile).
  • linux/packaging/, flatpak/, snap/ – platform packaging definitions.
  • docs/ – this guide, the user guide, and design notes under docs/design/.

The main layers, top to bottom:

  • Core (lib/core/) – shared utilities. Result<T, E> in lib/core/result.dart is a sealed type with Success and Failure branches that replaces exception-based control flow for operations where the caller must decide how to handle errors. json_parsing.dart uses Result for date and enum parsing.
  • Models (lib/models/) – plain Dart classes with JSON serialization and copyWith helpers.
  • Services (lib/services/) – pure-Dart business logic and I/O.
  • StorageService – reads/writes JSON files locally and returns Result<T, String> from every load and save. It uses path_provider to store them in the application support directory. The load methods use synchronous file reads so they stay well-behaved under FakeAsync in widget tests. Callers in AppState and SyncService unwrap results with .getOrElse(), .valueOrNull, and .onFailure(), logging failures and falling back to safe defaults when needed.
  • WeatherProvider (lib/services/weather_provider.dart) – the interface for all weather sources. WeatherProvider exposes maxForecastDays and a fetchWeatherForTrip method that accepts an existing Weather snapshot so historical actual and climatology days can be reused. It also exposes attributionText and attributionUrl so each provider can supply its own credit and licence link, which the UI displays in the trip list, trip header, and forecast dialog. WeatherService is the default Open-Meteo implementation; other providers can implement the same interface without touching the UI.
  • WeatherService – geocodes a location and routes each trip date to the right Open-Meteo endpoint: the forecast API for today through the provider's forecast horizon, the archive API for past days, and the historical-forecast API for far-future days. Forecast requests use start_date and end_date only; forecast_days is not sent alongside explicit dates. The historical-forecast response is aggregated into range fields (minTempCMax, maxTempCMax, precipitationProbabilityMax) and tagged with WeatherSource.historicalClimatology. Hourly data is attached to each DailyForecast for live forecast and historical actual days; climatology days remain daily-only because they are built from five years of historical-forecast data. The _summarizeForecast helper builds a trip-level summary: for historical actual days it produces a concrete precipitation descriptor through WeatherFormatting instead of a percentage range; for forecast and climatology days it keeps the low/high ranges and the precipitation chance range.
  • WeatherCondition (lib/core/weather_condition.dart) – maps Open-Meteo WMO weather codes to lower-case labels and Material icons, and decides whether a code is a precipitation type.
  • WeatherFormatting (lib/core/weather_formatting.dart) – centralizes temperature range, precipitation chance, and weather summary formatting for the trip list, trip header, day-by-day forecast tiles, and journal weather. Historical actual days get concrete precipitation labels (for example, "Some rain", "Heavy rain", or "Sunny") instead of percentage chances.
  • WeatherNarrative (lib/core/weather_narrative.dart) – turns a DailyForecast (and its hourly data) into a short, time-of-day sentence such as "Rain this afternoon, turning into clear skies this evening". Amounts are formatted in millimeters or inches based on the device locale.
  • ExportService – produces CSV strings and PDF byte arrays for gear and trip lists.
  • LoggingService – a persistent, daily-rotated application log. One file per day is appended under <app support>/logs/yyyy-MM-dd.log; files seven days or older are pruned at startup and each file is capped near 2 MB (the oldest half of the lines is dropped when the cap is crossed). A 500-line in-memory buffer backs the live view; availableDates(), readDay(), and fileForDay() serve the per-day viewer, and log()/debug()/info()/warning()/error() accept an optional tag, error, and stackTrace. The service never throws — a full or unmounted volume degrades to the in-memory buffer — and FlutterError.onError/PlatformDispatcher.onError in main() route uncaught errors into it. The legacy single app.log is migrated into its last-modified day's file on first launch. LoggingService.instance is mutable for tests; LoggingService.testing builds a ready instance rooted at a temp directory.
  • ConnectivityService – reports network interface state and probes the configured server before sync attempts.
  • ConflictResolver – performs three-way merge on gear, trips, fields, and synced settings using per-entity/field updatedAt timestamps. String list properties (such as brandNames and tripTypes) are deduplicated case-insensitively after the merge. See Sync and conflict resolution for the full semantics.
  • SyncService – coordinates a configured SyncProvider, tracks per-file dirty state, detects offline/online transitions, and reports change summaries and unresolved conflicts. Sync results now carry separate uploadSummary and downloadSummary counts so the log and UI can show direction-aware messages (for example, Uploaded 2 gear item(s) to remote, downloaded 1 trip(s) from remote). When a provider is enabled, PyggoApp runs a sync automatically on launch. Sync provider settings are persisted when the user leaves Settings > Sync, switches providers, or runs a manual sync, so typing in the URL, token, or key fields does not trigger validation on every keystroke.
  • Providers (lib/providers/) – AppState extends ChangeNotifier and is the single source of truth for UI. It also mixes in WidgetsBindingObserver so it can refresh weather when the app is resumed. Mutations such as addValue, addGearCategory, addGear, and updateGear trim names and reject empty or case-insensitive duplicates. addGear and addTrip return bool to indicate whether the save succeeded, so callers can show feedback instead of silently navigating away. After every init, storage reload, and CSV import, _deduplicateListsAndCategories() normalizes any duplicate list values and categories, keeping the oldest updatedAt category and remapping affected gear/trip snapshots. _handleSaveResult centralizes Result unwrapping for all save operations, setting AppState.lastError and notifying listeners on failure; PyggoApp listens and surfaces the error in a SnackBar.
  • Screens / Widgets – the UI layer. OnboardingScreen is shown on first launch until AppSettings.hasCompletedOnboarding is true; DuplicateConfirmationDialog warns when saving a gear item whose name matches an existing item. ResponsiveScaffold switches between NavigationRail and BottomNavigationBar based on screen width.

Data model

GearItem

The main inventory model. It contains a single categoryId that maps to a GearCategory. Dated notes and warnings are stored in journal as a list of JournalEntry objects, and ManualAttachment references are stored in the attachments list. linkedItemIds stores one-way references to other GearItems by ID; the AppState.addGear and AppState.updateGear methods take an optional syncLinks flag that, when true, mirrors those references so both items link to each other. iconOverride is an optional NamedIcon that overrides the type icon in lists and trip pack lists.

Most built-in GearItem fields are now stored in customValues and exposed through getters/setters. The truly core top-level fields are id, name, quantity, compartments, linkedItemIds, packLocationPreferences, broughtByTrip, usedByTrip, iconOverride, and updatedAt. The packLocationPreferences map is keyed by PackingList.id and stores a PackLocationPreference containing containerGearId, compartment, packedLoose, and lastUsedAt; it is used to default pack locations when an item is added or copied into a trip. The broughtByTrip map is keyed by tripId and stores the DateTime an item was brought on that trip; it is kept separate from the user-maintained lastBrought list in customValues so that removing a gear from a trip (or deleting the trip) can remove the contributed date while preserving manually entered history. AppState.updateTrip also updates all broughtByTrip entries for that trip when the trip's startDate changes, so editing a trip date retroactively updates the recorded lastBrought dates. The usedByTrip map is keyed by tripId and stores the DateTime an item was used on that trip; it is kept separate from the user-maintained lastUsed list in customValues so that removing a gear from a trip (or deleting the trip) can remove the contributed use while preserving manually entered history. A gear item can only have one lastUsed contribution per trip, and re-recording a use for the same trip overwrites the previous date. The trip detail screen toggles the Mark used action to Unmark used once a use has been recorded, and tapping Unmark used removes the contribution immediately. Both lastBrought and lastUsed are core FieldDefinitions, so they cannot be deleted in the field manager (only hidden) and are read through built-in getters; FieldValueService.getValue therefore returns the full lastBrought and lastUsed unions (manual dates plus trip map values). The gear edit screen uses BroughtDateListField for lastBrought and UsedDateListField for lastUsed to render one chip per trip contribution, labelled with the date and the trip name; tapping a trip chip pushes TripDetailScreen. lastUsed trip chips are also deletable, since a use is a manual action the user may want to undo. The customValues map stores scalar and rich-list fields such as weightGrams, brand, description, categoryId, tripConditions, lastBrought, lastUsed, purchaseDate, purchasePrice, url, notes, attachments, journal, archived, excludedFromWeight, images, prep, and prepLeadTime. fromJson migrates legacy top-level JSON values into customValues when an older save file is loaded. The weightGrams setter and copyWith round to one decimal place in grams. The constructor and fromJson also round imported or legacy weights. When a new gear item is created and the user's tripConditions or tripWeather list contains the literal "all", the corresponding field defaults to ["all"].

PackingList

PackingList is a reusable gear template that can be imported into any trip leg. It has id, name, description, tripType (default backpacking), layoutPreset, an optional groupByField, updatedAt, and a list of PackingListEntry objects, each of which records gearId and quantity. Packing lists are managed from the Pack Lists tab via PackingListListScreen and PackingListEditScreen. The edit screen lets users rename the list, set a description, change its trip type, group and sort entries, add gear from the gear chooser, remove gear, and edit quantities. It also shows a total-weight line, a search field, a Group by dropdown, and quick-filter chips for category, type, and consumable. The search field appears before the Group by dropdown. Each gear item is rendered with the shared GearListTile (lib/widgets/gear_list_tile.dart): tapping the gear icon opens GearEditScreen; tapping the rest of the tile opens GearDetailDialog. The right-hand action icons (edit quantity, set default pack location, remove) stack horizontally on wide screens and vertically on narrow screens using shared constants from lib/widgets/tile_action_constants.dart.

The packing-list editor groups and sorts entries using the same TripLayoutPreset values and custom-field logic as trips. The available presets are alphabetical, byCategory, byContainer, byCustomField, and byWeight; byPackedStatus and byPrepStatus fall back to alphabetical because packing lists do not store packed or prepped state. Group headers are rendered with ListTile titles for category, pack location (using packLocationPreferences[list.id]), and custom fields. layoutPreset and groupByField are serialized to packing_lists.json and restored by StorageService.importAllJson and sync.

The Add gear flow filters out gear already in the list before opening GearSelectionDialog, prompts for a quantity only when gear.quantity > 1 (showing the maximum available in the hint), and then prompts to add any linked gear that is not already in the list. The Set default pack location icon opens a pack-into dialog restricted to non-archived gear items that have compartments, excluding the item being packed. Packed Loose remains an option for no-container packing. Changes are auto-saved when the user navigates back via PopScope; the explicit check action saves and requires a name for new lists. AppState.addPackingList and AppState.updatePackingList persist the packing list and trigger unawaited(_saveGear()) so pack location preferences are written.

Trip

A trip is a container for one or more TripLegs (via legIds), a list of TripDays, and trip-level metadata. At runtime the primary leg's TripLeg.packEntries is the authoritative source of truth for pack entries; Trip.entries is a compatibility projection for serialization only — _saveTrips re-derives it from the primary leg on every save (and TripShareService.exportTrip does the same for .trip archives), so mutation paths never write it by hand and consumers that still read Trip.entries always see current data. A trip whose last leg was deleted has its mirror cleared explicitly, since there is no leg to project from. Each PackEntry records:

  • gearId
  • packed
  • packedLoose
  • prepped
  • quantity
  • containerGearId
  • compartment
  • sourcePackingListId
  • updatedAt

Trips have a Weather snapshot, a top-level overview field (Markdown text that replaces the legacy details field), and a customValues map that stores location, startDate, endDate, showBaseWeight, attachments (a list of ManualAttachment references), journal (a list of JournalEntry objects), and images. Legacy details and links are migrated to overview on import; linkedTripIds and links are no longer exposed in the UI, but the model retains them for backward compatibility. Weather and DailyForecast now carry a WeatherSource enum and range fields (lowCMax, highCMax, precipitationChanceMax, minTempCMax, maxTempCMax, precipitationProbabilityMax) so the UI can distinguish live forecasts from historical actual and typical/climatology data. The core top-level fields are id, name, overview, tripType, weather, legIds, entries, quickFilters, layoutPreset, and updatedAt; gearLockedAt, gearAutoLocked, gearAutoLockSuppressedEndDate, and gearSnapshot hold the gear lock state. fromJson migrates legacy top-level values for the non-core fields into customValues. The TripDetailScreen renders the overview as Markdown via MarkdownBody, shows a compact combined image/attachment section via CompactAttachmentsSection, lists the trip's legs, shows the trip journal in a reusable, collapsible JournalSection, and exposes prep list, after trip report, pack/unpack all, export, copy, lock, delete, and edit actions from the top app bar (collapsing into a More actions menu as the screen narrows). JournalSection is also used on the GearEditScreen.

Trips can also have their gear locked. Trip.gearLockedAt stores the lock time, Trip.gearAutoLocked indicates whether the lock was applied automatically, and Trip.gearSnapshot is a Map<String, GearItem> that holds a deep copy of every referenced GearItem (including linked items and container items). Trip.isGearLocked reports gearLockedAt != null. Trip.lockGear(allGear, {autoLocked}) builds the snapshot recursively and sets the lock metadata; Trip.unlockGear() clears them — and, when the lock being removed was automatic, records the current endDate in gearAutoLockSuppressedEndDate so the auto-lock pass does not immediately re-lock the trip. Lock state is persisted in trips.json and is included in round-trip serialization.

AppSettings has autoLockTripGear (bool) and autoLockTripGearDays (int, default 7). AppState applies auto-locks on app load and whenever those settings change by scanning trips whose endDate is older than now - autoLockTripGearDays days. AppState._applyAutoLocks skips a trip whose gearAutoLockSuppressedEndDate still matches its current endDate — that is how "unlocked after being auto-locked" trips stay unlocked. Changing the end date makes the trip eligible again; a later pass locks it and sets gearAutoLocked once more.

UI code resolves gear through AppState.gearForTrip, gearForPackEntry, and gearForContainer so that weights, categories, icons, and container names come from the snapshot when a trip is locked and from the live master gear when it is not. Pack state (packed, packedLoose, prepped, quantity, container, compartment, notes, and images) remains editable while a trip is locked. The lock/unlock action and locked-state indicator live in the TripDetailScreen app bar; unlocking shows a warning that the gear will revert to live master gear. Locked trips are also marked with a lock icon in TripListScreen.

TripLeg and TripDay

TripLeg is the runtime owner of a trip's pack entries. Each TripLeg has tripId, startDate, endDate, an optional tripType, an optional location, packEntries, a dayIds list, and updatedAt. Leg and day dates are local calendar days stored as UTC midnights (dateOnlyUtc); when a timestamp such as updatedAt or DateTime.now().toUtc() supplies a fallback date, convert it with .toLocal() first — its UTC components can be a different day in timezones behind UTC. A trip always has at least one primary leg; AppState creates one on addTrip if the trip has no legs, and Trip.entries is re-projected from it on every save. The primary leg is the earliest leg by startDate; AppState._legsForTrip returns all of a trip's legs sorted by start date, and AppState._primaryLegForTrip returns the first. TripDay represents one calendar day of a leg. It has legId, date, itinerary, attachments, images, an optional weather snapshot, and updatedAt. AppState keeps days in sync with the leg's start/end dates. New days are seeded with copies of the trip's attachments and images at creation, after which they are independent per-day data — trip edits do not propagate into existing days, and day edits never write back to the trip. Days have no location of their own; the displayed location is derived from the leg's location (or the trip's location when the leg has none).

AppState.importPackingListIntoLeg(leg, packingList) copies a packing list into a leg. New gear gets a PackEntry with the quantity from the packing list and the pack location from GearItem.packLocationPreferences[packingList.id]. Gear already in the leg is merged: the quantity becomes max(existing, imported), the pack location is updated when the imported preference differs, and packed is reset to false only when the location changes. prepped is left alone. merge: false skips gear that is already present.

AppState.addTripLeg(trip, {startDate, endDate, tripType, location, packingLists}) creates a new leg, imports the supplied packing lists, applies auto-unpack at the leg boundary from the previous leg, adds the leg to the trip, and creates the matching TripDay entries. Trip.entries reflects the trip's current primary leg via the save-time projection.

AppState.updateTripLeg(leg, {startDate, endDate, tripType, location}) updates the leg's dates, trip type, and/or location, resyncs its days, and persists the change.

AppState.deleteTripLeg(leg) removes the leg and its days, removes the leg from its trip, and records tombstones for both the leg and its days. If the deleted leg was the primary leg, the Trip.entries projection moves to the new primary leg (or clears when no leg remains).

AppState.saveLegAsPackingList(leg, {target}) creates a new PackingList from the leg's current pack entries, or overwrites an existing target. Each gear appears once, using the leg's current quantity, and the gear's packLocationPreferences map is updated for the new packing list id so the saved locations can be reused on future imports.

AppState.clearLeg(leg) removes every PackEntry from the leg and updates the trip and leg updatedAt timestamps.

AppState.updateTripDayItinerary(day, itinerary) sets a TripDay's itinerary markdown, converting an empty string to null, updates the day and owning leg updatedAt timestamps, and persists trip_days.json.

AppState.updateTripDay(day, {attachments, images, itinerary}) updates any subset of a TripDay's editable fields. An empty itinerary value is converted to null, and the day and owning leg updatedAt timestamps are updated before persistence.

AppState.addGearToLeg(trip, leg, {gearId, quantity}) adds a PackEntry directly to a chosen TripLeg. It uses packLocationFor for the leg's packing list and respects the missing-container prompt.

Auto-unpack at leg boundaries

When a new leg starts after an existing leg, AppState._applyAutoUnpackAtLegBoundary runs after the packing lists are imported. For each pack entry in the new leg, it looks for a pack entry in the previous leg with the same gearId and the same pack location (containerGearId, compartment, and packedLoose). If a matching entry is found, packed and prepped are copied to the new leg. If the location differs, or the gear was not in the previous leg, the new entry stays packed: false so the user must repack it in the new location.

When the user packs an item into a later leg, PackEntryOps.unpackEarlierLegs clears packed on entries for the same gear in all earlier legs that ended before the new leg starts. This is triggered from packEntryInto, setPackEntryPacked, setPackEntriesPacked, and setPackEntriesPackedAndPrepped, and runs on both the phone and the watch. packSingleEntry in lib/widgets/pack_entry_tile.dart also shows a confirmation dialog when packing into a leg whose start date is in the future, because the item should still be packed in the earlier leg.

Weight calculations

Trip, container, compartment, and category weights all delegate to packEntryWeightGrams, packEntryBaseWeightGrams, and packEntryContributesWeight in lib/core/pack_weight.dart. A pack entry contributes zero weight when its own gear item has excludedFromWeight set, or when any container in its pack-location chain has excludedFromWeight. The chain walk is recursive and tracks a seen set to guard against cycles, so malformed or hand-edited data cannot hang the app. packEntryBaseWeightGrams also excludes consumable items, matching the existing base-weight convention.

AppState and ExportService both call the pack-weight helpers with a resolver callback so they can use gearForTrip/gearForContainer for live or locked snapshots. This keeps the exclusion logic in one place and ensures the UI, filters, chips, and exports all produce the same totals. AppState.totalWeightForEntries and AppState.baseWeightForEntries aggregate the per-entry helpers over an entry list — screens use them for trip and compartment totals rather than folding packEntryWeightGrams inline.

PackEntry also uses customValues for pack-state notes and images; all other pack-state fields (gearId, packed, packedLoose, prepped, quantity, containerGearId, and compartment) remain top-level. fromJson migrates legacy top-level notes and images values into customValues.

packedLoose distinguishes two previously collapsed "no container" states: an entry with containerGearId == null && packedLoose == false is Unorganized (no location decided yet), while containerGearId == null && packedLoose == true is Packed Loose (deliberately placed with no container). The constructor, copyWith, and fromJson enforce the invariant that a non-null containerGearId always forces packedLoose to false; if a JSON value or constructor call ever has both, the container wins.

Pack location preferences

GearItem.packLocationPreferences stores default pack locations keyed by PackingList.id. Each value is a PackLocationPreference that records containerGearId, compartment, packedLoose, and lastUsedAt. GearItem.packLocationFor(packingListId, containers, {excludedContainerGearId}) chooses the best default for a trip:

  1. The packing-list-specific preference, if its container exists in the trip and the compartment still exists on that container.
  2. The most recently used (lastUsedAt) preference that is valid for any container currently in the trip.
  3. null if no preference is valid, which leaves the item unorganized.

An item is never packed into itself; excludedContainerGearId prevents that. PackLocationPreference normalizes its fields in the constructor so packedLoose is only true when containerGearId is null, and compartment is null when there is no container.

AppState.addGearToTrip and WearAppState.addGearToTrip call packLocationFor when building the initial PackEntry for each added item. AppState.copyGearFromTrip uses the source pack location when the same container (or one being copied in the same batch) still has the requested compartment; otherwise it falls back to packLocationFor for the target trip.

AppState.packEntryInto and WearAppState.packEntryInto call GearItem.updatePackLocationPreference after a successful pack or repack only when AppSettings.autoSavePackLocationPreferences is true. The default is true so existing behavior is preserved; disabling the setting lets users pack freely without overwriting saved preferences. The manual counterpart is AppState.savePackLocationPreference(tripId, entry), which writes the current pack location to the gear's preference map regardless of the auto-save setting. The UI exposes this as Save as default pack location in _EntryActions.

TripDetailScreen._addGearToTrip calls AppState.missingPreferredContainers after the user selects gear and before it is added. If any selected item's primary packing-list preference references a container not in the trip, a dialog offers to add those containers first. If the user declines, the gear is still added and left unorganized.

GearEditScreen includes a PackLocationPreferencesEditor widget. It shows one card for each packing list id already in the gear's packLocationPreferences map. Each card uses DropdownButtonFormField with a ValueKey derived from the current value so changes reset the field state correctly. Users can change the location type (container, packed loose, unorganized), pick the container and compartment, delete a mapping, or add a mapping for an unmapped packing list. Only packing lists that already have preferences are shown by default; the Add packing list mapping button presents all known packing lists from AppState.packingLists.

Renaming or merging a trip type in AppState._replaceListReferences updates the tripType value on matching PackingLists, but it does not rename packLocationPreferences keys on GearItems because those keys are packing list ids. Deleting a trip type changes affected Trips and PackingLists to the default backpacking trip type; gear preferences are not removed.

Per-day weather

TripDay can hold its own Weather override, which survives updateTrip and trip-level weather refreshes — nothing pushes trip.weather into day.weather anymore. The UI drives weather refreshes through AppState.refreshAllWeatherForTrip(tripId, {force}). This fetches the trip-wide forecast and then refreshes every day whose effective location (leg location, then trip location) differs from the trip's own location. Days whose effective location matches the trip use the new trip-wide forecast and have any stale day override cleared. AppState.refreshDayWeather(dayId, {force}) still fetches a single-day forecast using the leg's or trip's location as a fallback; AppState.setDayWeather(day, weather) sets a manual override — passing null removes it. AppState.weatherForDay(day) returns the day override when present and otherwise falls back to the matching DailyForecast from the trip's weather, building a one-day Weather snapshot whose top-level summary fields are copied from that day so the UI shows only that day's weather.

Trip day tiles and overview

TripDayTile in lib/widgets/trip_day_tile.dart is an ExpansionTile that displays one TripDay. The collapsed state shows the date, a Today chip when the day matches the current date, a one-line weather summary from AppState.weatherForDay, and pack and prep state icons for the leg that contains that day. The one-line summary shows the day's low/high and precipitation; far-future climatology days show a low/high range, while live forecasts and historical-actual days show a single low and high. The pack icon is a backpack that is filled when all entries in the leg are packed (or the leg is empty) and outlined otherwise; the prep icon is filled when all required prep items are prepped (or no items need prep) and outlined otherwise. Tapping either icon pushes LegPackListScreen for the day’s owning leg. The expanded state shows a DayForecastTile with only that day's forecast, including a narrative and daily precipitation, and an expand arrow that reveals the hourly forecast. The DayForecastTile is created with showTemperature: false because the collapsed header already shows the low/high. The weather attribution and a refresh action that calls AppState.refreshAllWeatherForTrip sit next to the forecast tile. Below the weather is a read-only Location line (derived from the leg or trip), the Itinerary rendered as Markdown (MarkdownBody with the GitHub-flavored extension set and link launching through parseLaunchableUrl, matching TripOverviewCard) and edited through EditMarkdownDialog, image and attachment chips, and the compact attachment row. TripDayTile reports expansion changes through onExpansionChanged so TripDetailScreen can track which leg is currently active.

EditMarkdownDialog in lib/dialogs/edit_markdown_dialog.dart is a markdown editor that reuses the existing Quill conventions: it creates a QuillController from the input markdown via JournalMarkdown.toDocument, presents a QuillEditor.basic with a QuillSimpleToolbar, and on save returns the markdown from JournalMarkdown.fromDocument. It supports the same PopScope auto-save behavior as EditJournalEntryDialog.

TripDetailScreen places the Overview card directly below the trip header, then a compact image/attachment row, a collapsible Journal section, the Legs section, and the TripDayTile sliver built from AppState.tripDaysForTrip(trip) sorted by date. The pack list and its controls (total weight, quick filters, and search) appear after the day tiles and reflect the currently active leg: the only leg for single-leg trips, or the leg that owns an expanded day tile for multi-leg trips. TripDetailScreen builds a packTrip copy (trip.copyWith(entries: activeLeg.packEntries)) for pack-list display and calculations so that container chains and weight totals use the active leg, while Trip mutations such as quick-filter changes always use the original trip. The overview is rendered with flutter_markdown_plus and MarkdownBody.onTapLink so links are tappable and open in the default browser via launchExternalUrl; link text is styled with the theme's primary color to match the edit icon. The edit action is an IconButton with an edit icon and an Edit overview / Add overview tooltip.

Home tab and navigation

MainScreen in lib/screens/main_screen.dart is the app shell. It uses ResponsiveScaffold to show a NavigationRail on wide screens and a NavigationBar on narrow ones, with tabs in the order Gear, Pack Lists, Home, Trips, and Journal. Settings is opened from the settings icon in the top app bar. The app launches with the Home tab selected (initialSelectedIndex: 2).

HomeScreen in lib/screens/home_screen.dart is the Home tab body. It shows a loading indicator while AppState is initializing, an EmptyState with a Plan a trip action when no trips exist, or the featured trip dashboard when a trip is available. The featured trip is chosen by AppState.currentOrLastTrip(): a trip whose leg or day range includes today is shown as Today, otherwise the most recently opened trip is shown as Last trip, with a final fallback to the most recently updated trip. When several trips contain today, the most recently opened one wins. The dashboard shows a TripHeaderTile (lib/widgets/trip_header_tile.dart), a TripOverviewCard (lib/widgets/trip_overview_card.dart), a TripJournalSection (lib/widgets/trip_journal_section.dart), and a TripDayTile for the current day when the featured trip is active. The today tile is expanded by default. HomeScreen also calls AppState.ensureTripDay in didChangeDependencies so a missing TripDay for today is recreated on the Home tab. Tapping the weather row fetches or refreshes weather for the whole trip and, when weather is present, opens a compact forecast dialog. A map icon opens the full TripDetailScreen and a + icon pushes TripEditScreen. Previous/next-day chevrons flank them, stepping a _selectedDate through AppState.tripDayRange (leg ranges first, trip dates as fallback) so the day tile can show any trip day — AppState.legForDate resolves the covering leg and ensureTripDay materializes the browsed day on tap. A chevron is disabled when no further day exists; from outside the range (a past or future trip) the first step snaps to the nearest edge.

AppState.currentOrLastTrip() in lib/providers/app_state.dart returns the current or last trip, and AppState.recordLastOpenedTrip(tripId) stores tripId in AppSettings.lastOpenedTripId. AppState.isTripCurrent checks whether today falls within any leg's date range (falling back to the trip's startDate/endDate when no legs exist), so leg-date edits keep the Home tab current even when the trip-level dates are stale. TripDetailScreen records the opened trip in a post-frame callback so the Home tab stays up to date. AppSettings.lastOpenedTripId is serialized in settings.json, tracked in AppState.updateSettings, and restored on launch.

ManualAttachment

ManualAttachment references an external file (PDF, image, etc.). It now carries an optional title field for image captions. The title is shown under thumbnails and can be edited from the image detail dialog. ManualAttachment.toJson/fromJson persist the title when present.

Attachment files are reference-counted at commit time rather than deleted eagerly. ImageAttachmentsSection and TripAttachmentsSection only update the staged attachment list — deleting the file there would break the reference if the surrounding edit was cancelled. AppState computes the dropped references on every commit point (updateGear, updateTrip, updateTripDay, deleteGear, deleteGearItems, deleteTrip, deleteTripLeg) and deletes a file only when no persisted entity still references its path — copyTrip shares the source trip's files, and a locked trip's gear snapshot keeps a deleted item's files alive.

NamedIcon

NamedIcon stores an icon by source (material or iconoir) and name plus an optional Color value. AppSettings.typeIcons maps a type name to a NamedIcon, and GearItem.iconOverride overrides that for a specific gear item. The style is applied in GearListTile, _GearSelectionTile, _PackEntryTile, and the Wear OS tile builders with the override taking priority, then the type style, then a default backpack icon. NamedIcon still reads the legacy iconCode, fontFamily, and fontPackage keys so old synced data and local settings continue to render.

Material icons are rendered with the Flutter Icon widget. Iconoir icons are rendered with the official iconoir_flutter SVG widgets via NamedIconView and the AppIconBuilder helpers in lib/data/all_icons.g.dart.

The _ListValueStyleDialog in lib/dialogs/list_manager_dialog.dart includes an All icons button that opens IconPickerDialog. IconPickerDialog shows Material and Iconoir icons organized into combined categories (Action, Image, Maps, etc.) with a Recent category in the category filter for the user's most frequently chosen icons. AppState.recordIconUse updates AppSettings.recentIconNames when an icon is selected.

lib/data/material_icons.g.dart exports materialIcons, materialIconNames, materialIconsByCategory, materialIconsByCodePoint, and materialIconCategoryLabels. It is generated by tool/generate.dart material using the Flutter SDK's icons.dart and Google's icon metadata for categorization.

lib/data/iconoir_icons.g.dart exports iconoirIcons, iconoirIconNames, iconoirIconsByCategory, iconoirIconsByCodePoint, and iconoirIconCategoryLabels. It is generated by tool/generate.dart iconoir from the official iconoir_flutter package exports and the Iconoir iconoir.com/icons.csv category metadata. Iconoir categories are mapped onto the same category taxonomy used for Material icons. Legacy code points are preserved when the generated file already exists, so saved user data keeps resolving after an update.

lib/data/all_icons.g.dart combines both sets into allIcons, allIconKeys, allIconsByCategory, allIconCategoryLabels, and iconDisplayName. IconPickerDialog imports this combined file; Iconoir keys are prefixed with iconoir: so they are unique, and iconDisplayName strips the prefix for search and sorting.

SavedView

SavedView stores a named filter configuration. It has a type (SavedViewType.gear or SavedViewType.trip) and a filtersJson map. toGearFilters() and toTripFilters() convert the JSON back to the appropriate filter object. The type field is written to JSON; older views without a type default to gear for backwards compatibility. AppSettings.defaultSavedViewId and defaultTripSavedViewId hold the IDs applied automatically when the gear or trip list opens.

TripFilters

TripFilters filters the trip list by query text, trip type, location substring, and start-date range. TripFilters.apply() returns a sorted list of matching trips. TripListScreen manages TripFilters state and shows a filter dialog (TripFiltersDialog); saved views are handled by TripSavedViewsDialog.

Trip pack list

TripDetailScreen in lib/screens/trip_detail_screen.dart shows a TripHeaderTile header, a TripOverviewCard (when set), a compact image/attachment row, a collapsible Journal section, a collapsible Legs card, a sliver of TripDayTile day tiles, and then the pack list for the currently active leg. The Legs card starts collapsed and expands when tapped. Each leg row shows its date range, location, and edit and delete icons; tapping a row opens LegPackListScreen for that leg, while the edit icon opens a dialog to change the leg's start/end dates and location. On single-leg trips the active leg is always the only leg, so the pack list is always visible; on multi-leg trips the pack list is only shown when at least one day tile is expanded, and it reflects the leg that owns the expanded day. LegPackListScreen in lib/screens/leg_pack_list_screen.dart renders the pack list for any specific TripLeg, reusing the same selection, pack/unpack, prep, mark used, quantity, pack-into, save pack location, export, save-as-packing-list, and clear-leg logic. Each TripDayTile header shows pack and prep state icons for its leg; tapping either icon opens LegPackListScreen for that leg. Tapping a day tile expands it and reports the expansion to TripDetailScreen so the pack list can switch to that leg. Expanded day tiles use CompactAttachmentsSection for images and arbitrary files, show a read-only location derived from the leg or trip, expose a weather refresh, and show an itinerary editor.

Sorting uses comparePackEntries in lib/widgets/pack_list_helpers.dart, which first respects AppSettings.packedItemBehavior (moveToBottom or doNothing) and then the trip's layoutPreset. When AppSettings.showPackListHeaders is true, the list inserts header rows derived from the current preset by packEntryGroupHeader; headers are omitted for alphabetical and byWeight. Packed text is crossed out only when AppSettings.crossOutPackedItems is true. New trips receive a default QuickFilterType.packedStatus quick filter with Any, Packed, and Unpacked instances.

LegPackListScreen mirrors the inline trip pack-list experience, including total and base weight in _buildWeightSummary, a search field, and quick-filter chips for container, packed/prep status, category, and consumable. The sorting, grouping, search, and quick-filter logic is shared with TripDetailScreen and PackingListEditScreen through pack_list_helpers.dart (pure helpers such as comparePackEntries, packEntryMatchesSearch, packEntryMatchesQuickFilter, instanceFiltersForType, packFilterMetrics, and packLocationBreakdown) and pack_list_chips.dart (PackContainerChips, PackQuickFilterRow, PackListSearchField).

Pack-entry mutations are leg-aware and shared: PackEntryOps (lib/core/pack_entry_ops.dart) is a mixin used by both AppState and WearAppState. It resolves the owning TripLeg for a PackEntry with legForPackEntry and ensureLegForPackEntry, then updates that leg's packEntries. Trip.entries is only synchronized when the affected leg is the primary leg (syncTripEntriesFromLegIfPrimary). addGearToLeg adds gear directly to a chosen leg. packEntryInto, setPackEntryPacked, setPackEntryPrepped, removeEntry, and removePackEntries all target the entry's owning leg; setPackEntryQuantity, savePackLocationPreference(s), and packAllEntries/packSingleEntry remain phone-only on AppState. The two states differ only in the mixin hooks: ensurePrimaryLegForTrip (the phone syncs days for a created leg; the watch adopts trip.legIds.first), persistPackEntryState (unawaited(_saveX) vs _saveSafely), and storePackingList.

When the layout is byContainer, the "Unorganized" group always sorts first, followed by the "Packed Loose" group, then the remaining container/compartment groups alphabetically. This applies to both the pack list sort and the container weight chip order. The phone pack-location quick-filter row now includes a separate Packed Loose chip in addition to Unorganized; the packEntryMatchesQuickFilter and quickFiltersEqual helpers take the new QuickFilter.packedLoose field into account so the two no-container states do not collapse.

Group header rows (PackGroupHeaderTile) show right-justified pack all (Icons.check_box) and unpack all (Icons.check_box_outline_blank) icons. Tapping pack all calls packAllEntries, which walks through the entries in that group and uses packSingleEntry to prompt for prep and a pack location exactly as a single PackEntryTile checkbox would; unpack all calls unpackAllEntries, which sets packed = false for every entry in the group via AppState.setPackEntriesPacked in a single save. The top app bar's More actions menu adds the same Pack all and Unpack all actions for the whole trip.

Bulk selection

Long-pressing a pack-list _PackEntryTile toggles _TripDetailScreenState into selection mode. _selectedEntries is a Set<PackEntry> that stores exact PackEntry identity (not just gearId) so split quantities and duplicate entries stay distinct. Tapping an entry while selecting adds or removes that exact entry from the set. A _buildSelectionBar is inserted as a SliverToBoxAdapter above the list; it shows the selected count, a clear selection action, select all / deselect all, and context-aware bulk-action buttons (Pack, Unpack, Prep, Unprep, Pack into, Mark used / Unmark used, Save as default pack location, and Remove from trip). The Mark used / Unmark used button shows Mark used when any selected gear is not yet marked used on this trip and opens a date picker; already-used gear in the same selection is skipped rather than overwritten. When every selected gear is already marked used, the button becomes Unmark used and removes the trip use from each.

Select all uses the filtered displayEntries list (the entries matching the current quick filters and search), not merely the widgets currently in the viewport. Group headers swap their Pack all / Unpack all icons for Select all / Unselect all while selection mode is active, scoped to the entries under that header.

Bulk actions are only available in the normal pack list. Switching to the Prep list or After trip report clears _selectedEntries, and those views keep their existing single-entry actions.

The bulk action handlers delegate to batch methods on AppState to avoid repeated saves and to preserve exact entry identity:

  • setPackEntriesPacked / setPackEntriesPrepped update a list of exact PackEntry objects in one save.
  • setPackEntriesPackedAndPrepped packs a list and, for a subset that require prep, marks them prepped in a single operation.
  • removePackEntries removes the exact selected entries from a trip in one save.
  • markItemsUsed deduplicates gearIds and records the same usedAt timestamp for each unique gear.
  • packEntryInto is called per selected entry with the container/compartment chosen by _chooseBulkPackLocation; the same destination is reused for all selected entries.
  • savePackLocationPreferences writes each selected entry's current pack location to its gear's packLocationPreferences map, keyed by the entry's sourcePackingListId or the trip's default packing list.

All exact-entry helpers use identical(existingEntry, entry) to find the original in the owning leg's packEntries, because provider updates replace entries with copies.

A search field below the quick filters lets the user filter the pack list by gear name, type, brand, or description. The search combines with existing quick filters and prep/after-trip logic.

The gear item icon in each _PackEntryTile is an IconButton that opens GearEditScreen for that item, so the user can edit gear directly from the pack list without navigating through the gear tab. On narrow screens the packed checkbox and gear icon button stack vertically in a Column; on wide screens they sit side by side in a Row.

_EntryActions renders the trailing action buttons for each pack entry. On wide screens (compact == false), all actions are direct IconButtons in order: mark/unmark used, prep, pack into, edit notes & images, edit quantity, remove from trip. On narrow screens, "edit notes & images", "edit quantity", and "remove from trip" fold into a kebab PopupMenuButton (in that order); mark/unmark used, prep, and pack into always stay visible as direct buttons. The used icon is Icons.check_circle_outline when the gear has no usedByTrip entry for this trip and Icons.check_circle with the tooltip Unmark used when it does; tapping it removes the trip's use. If the kebab would contain only one item, that item becomes a direct button instead (same screen space, one fewer tap). The kebab disappears entirely when empty. On narrow screens the visible buttons are stacked in a Column and use a smaller IconButton constraint/padding so the trailing actions take less horizontal room. The pack-into backpack icon is Icons.backpack_outlined (empty) when the item is unorganized and Icons.backpack (filled) when it has a pack location (either packed loose or inside a container). The pack-into button is always visible when containers exist, for both organized and unorganized items.

Tapping edit quantity opens a number dialog capped by the gear's inventory quantity minus any other pack entries of the same gear. If the trip already holds the full inventory, confirming the current quantity shows a snackbar instead of silently doing nothing. When the new quantity is larger, a second dialog always asks whether the extra units stay in the same location or go to a new one. Choosing "same" calls AppState.setPackEntryQuantity with the source's own containerGearId, compartment, and packedLoose so the existing pack entry is updated in place and any duplicate at that location is consolidated. Choosing "new" reuses _choosePackLocation to pick a container, compartment, and count (or "Packed Loose" for a deliberate no-container location); if the chosen target is actually the same location, the flow falls back to the in-place same-location update.

GearSelectionDialog no longer shows a snackbar on confirm — the snackbar was attached to the dialog's own ScaffoldMessenger, which was destroyed when the dialog closed, causing it to disappear instantly. The snackbar is still used for in-dialog actions (select all, clear, toggle bidirectional).

_PackEntryTile and GearListTile build their item summaries from the configured field-id strings stored in AppSettings.gearListSummaryFields and tripListSummaryFields. The ListSummaryField class in lib/models/list_summary_field.dart defines the supported ids, their labels, and the order in which they are presented in the picker. The gear and trip lists are configurable separately in Settings > Display & behavior > List details via lib/dialogs/list_summary_fields_dialog.dart; the item name is always shown as the title. Summaries are rendered as a Column so the tile height grows automatically.

Tile highlighting is handled by the reusable TileHighlight enum and resolveTileHighlight helper in lib/widgets/tile_highlight.dart. Warning items (GearItem.hasWarning) get TileHighlight.warning (errorContainer / onErrorContainer), and items with a prep value that are not yet prepped get TileHighlight.needsPrep (secondaryContainer / onSecondaryContainer). Warning takes precedence over prep. In prep list mode, overdue un-prepped items are forced to TileHighlight.warning, and the normal needsPrep highlight is suppressed so the list does not turn entirely secondary-colored.

The top app bar in TripDetailScreen is built by _buildAppBarActions with a fixed priority order (Prep list, After trip report, Unpack all, Pack all, Export trip, Copy gear, Lock/unlock gear, Delete trip, Edit trip). As MediaQuery width shrinks, the leftmost actions move into a PopupMenuButton (More actions), keeping Edit and Delete visible the longest. The menu item labels are shown with an icon and Expanded text so long labels such as Copy gear from another trip do not overflow the PopupMenuItem width. The Pack all and Unpack all actions target the currently active leg and are disabled when no leg is active.

Prep list toggles a filtered view of the active leg. When prep list is active, _TripDetailScreenState filters displayEntries to gear with a non-empty prep value, applies a dedicated _compareEntriesForPrepList sort (prepped items at the bottom when PackedItemBehavior.moveToBottom, then descending prepLeadTime, then name), and suppresses group headers. The leading checkbox in _PackEntryTile switches from packed to prepped, the dedicated trailing prep icon and menu items are hidden, and the prep condition and prep deadline are appended to the item name in a single title line (<name> • prep: <condition> • Prep by: <due date>). Prep by: is only included when a prep lead time is set. In prep-list mode these two fields are removed from the configured tripListSummaryFields summary so they do not appear twice. If no leg is active when prep list is enabled, the primary leg is automatically activated so its pack list appears. The After trip report pushes a new route and aggregates packed items across every leg. Each gear appears once with its total quantity across legs and the full date range it was used. The screen uses PackableGearTile and exposes Mark used and Add journal entry icons directly on each tile, both of which update the master GearItem rather than any specific leg.

_showForecastDialog is a stateful AlertDialog built with StatefulBuilder so its Refresh button can call AppState.refreshTripWeather and rebuild the dialog with the updated forecast or current conditions. The forecast list uses a shared DayForecastTile per day: the header shows the date on the left and the weather icon on the right; the full narrative wraps below, followed by the low/high temperature and the day's precipitation. DayForecastTile defaults to showTemperature: true so the forecast dialog still displays the low/high for each day. Far-future "Typical" days are labeled up front and keep the history icon. Tapping the header or arrow toggles the hourly list. The dialog and tile horizontal padding shrink on screens narrower than 600 dp. Each hour has a leading weather icon and, when precipitation is expected, a chance line like "22% chance of rain". Hourly precipitation amounts are formatted in millimeters or inches based on the device locale.

Tapping Mark used in the After trip report or in the regular pack list opens a _DateTimePickerDialog that picks a date, defaults to the trip end date (or DateTime.now() if the trip has not ended), and includes a Now shortcut. The selected date is normalized to UTC midnight and passed to AppState.markItemUsed as usedAt; there is no undo snackbar. Once a gear is marked used for the trip, the same action becomes Unmark used and removes the trip's contribution. The TripDetailScreen and AfterTripReportScreen tile actions both call AppState.markItemUsed and AppState.unmarkItemUsed via toggleGearItemUsed so the two entry points behave identically.

EditJournalEntryDialog in lib/dialogs/edit_journal_entry_dialog.dart is a spacious Dialog with a QuillSimpleToolbar and a QuillEditor from flutter_quill. When a journal entry is loaded, its Markdown note is converted to a Quill Document with markdown_quill, and on close the Document is converted back to Markdown. The visible editor is a true WYSIWYG surface with bold, italic, headings, bullet lists, checkbox lists, and links. The dialog is wrapped in a PopScope with canPop: false; when the user triggers an implicit close (system back, tap outside, Escape), onPopInvokedWithResult calls the existing save logic so the entry is auto-saved. An empty note still returns null and is discarded. The app bar no longer has a leading close button; instead, a bottom row has Cancel (X) and Save (disk) buttons. Cancel returns null, while Save returns the (note, date, isWarning, resolved) record and closes the dialog. A QuillController listener calls setState so the current content is always available when closing. The placeholder uses a theme-aware DefaultStyles color. FlutterQuillLocalizations.delegate is registered in lib/app.dart.

ExportService

ExportService.exportTripPdf accepts a WeightUnit and a formatWeight callback so weights in the generated PDF are rendered in the user's selected unit. Packed and prepped columns are rendered as boxes using _buildPdfCheckbox instead of text brackets.

ImportService

ImportService (lib/services/import_service.dart) parses CSV gear lists into GearItems and new GearCategorys. importGearCsv is the entry point; detectMapping produces a suggested field-to-column mapping from the header row, and parseHeaders returns the raw header strings for the UI's column-mapping dialog.

  • Header detection. detectMapping normalizes each header cell by lowercasing and stripping non-alphanumeric characters, then matches it against headerSynonyms (e.g. qty/count/quantity → quantity; generic/genericname/itemtype/type → type). Each column is claimed by at most one field. Non-core custom FieldDefinitions are matched by their name, key, or raw field.key, prefixed in the mapping as custom:<key> so they cannot collide with built-in fields.
  • Dropdown canonicalization. _canonicalizeDropdownValue and _canonicalizeDropdownValues match incoming single- and multi-select values against the existing dropdown lists case-insensitively, returning the existing casing so imported values reuse existing entries (e.g. Tent reuses tent). When no existing match is found, the first casing seen is remembered in a per-field newByLower map so subsequent values with the same letters normalize consistently within a single import. This applies to brand, type, storageLocation, prep, compartments, tripConditions, and tripWeather.
  • Multi-value field separators. _stringList splits multi-value fields (compartments, tripConditions, tripWeather) on either , or ;. Date lists (lastBrought, lastUsed) and journalWarnings split on ; only via _dateList and _parseJournalWarnings.
  • Weight conversion. _convertWeightToGrams converts the parsed weight to grams using the WeightUnit selected in the import dialog, then rounds to one decimal place in grams. Weights are always stored as grams internally.
  • Numeric parsing. _double delegates to FieldValueService.parseNumber with the chosen DecimalSeparator (auto, dot, or comma), so weights, purchase prices, and custom number/currency/weight fields all honor the CSV decimal separator setting. auto heuristically detects whether a comma is a decimal or a thousands separator based on the surrounding digits.
  • Date parsing. _date and _dateFromString try DateTime.parse first, then a cascade of DateFormat patterns (yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy, yyyy/MM/dd, M/d/yyyy, MMMM d, yyyy, MMM d, yyyy) via parseLoose. _dateList splits on ;, parses each entry, and runs normalizeDateList to collapse duplicate days.
  • Journal warnings. _parseJournalWarnings splits the journalWarnings column on ; and creates a JournalEntry per warning with isWarning: true and date: DateTime.now().
  • Default "all" conditions and weather. When a CSV row leaves tripConditions or tripWeather empty and the user's settings contain the literal value "all", the import sets that field to ["all"]. If "all" is not present in the user's list, the field remains empty.
  • Categories. Existing categories are deduplicated by name (lowercased), keeping the oldest by updatedAt; a category name from the CSV reuses an existing match or creates a new GearCategory tracked in newCategoriesByName. New categories are returned in CsvImportResult.newCategories for AppState to add.
  • Custom fields. _applyCustomFieldValues writes non-core custom FieldDefinition values through FieldValueService.coerceValue and FieldValueService.encodeCustomValue, so custom scalar and rich-list fields import the same way built-in fields do.
  • Validation. If mapping is empty or has no name key, importGearCsv returns an empty result with skippedRows set to rows.length - 1; the UI keeps the import button disabled until a Name column is mapped and the file parses. Rows with an empty name are skipped and counted in skippedRows. Line endings are normalized to LF before parsing so CRLF files parse correctly.

TripShareService

TripShareService (lib/services/trip_share_service.dart) builds and parses the .trip archive format used to share a single trip between Pyggo installations.

  • Archive payload. A .trip file is a ZIP archive with a trip.json manifest and a top-level attachments/ directory. trip.json contains version, exportedAt, includePackingLists, trip, tripLegs, tripDays, the optional packingLists array, and a gearNames map keyed by the original gear ids. All ids in the payload are generated by the source device.
  • Pack state stripping. exportTrip creates PackEntry copies with packed, prepped, and packedLoose reset to a safe default, and strips Trip.gearSnapshot, gearLockedAt, gearAutoLocked, and gearAutoLockSuppressedEndDate so the recipient does not receive a locked gear snapshot or pack state. importTrip clears the same fields again so an archive produced by an older or hand-edited build cannot smuggle lock state in.
  • Attachment handling. exportTrip receives a map of attachment bytes keyed by the relative ManualAttachment.filePath; each attachment is written into the archive as attachments/<filePath>. During importTrip, every attachment is saved back through StorageService and its filePath is rewritten to the new local relative path.
  • Safety. importTrip validates the archive version, skips non-file ZIP entries, and rejects paths that do not start with attachments/, contain .., or contain an empty path component.
  • AppState integration. AppState.exportTripArchive collects the legs, days, referenced packing lists, gear names, and attachment bytes for a trip and delegates to TripShareService.exportTrip. AppState.importTripArchive runs the full local import: it resolves gear by id first, then by active name, then by archived name; remaps packing-list ids; rewrites attachment paths; remaps leg, day, and trip ids; and persists the new entities through the normal save paths.

FieldValueService

FieldValueService is the central read/write/coercion layer for FieldDefinitions on GearItem, Trip, and PackEntry — the only entities the field system supports (FieldEntity.packingList once existed as dead infrastructure: PackingList has no customValues, and persisted packing-list definitions are filtered out in FieldSchema.fromJson). getValue reads a value either from a top-level core field or from the entity's customValues map (for non-core fields), then coerces it through coerceValue(value, field.type) so dates stored as ISO strings are returned as DateTime objects and rich list values are returned as typed model objects. setValue writes the coerced value back: core values go through entity setters, non-core values are stored in customValues. _setCustomValue serializes DateTime values to ISO strings, Duration values to seconds, and rich list entries (ManualAttachment, JournalEntry, TripLink) to their JSON maps. The supported FieldTypes include the scalar types (text, multilineText, number, boolean, date, singleSelect, multiSelect, url, currency, weight, duration) and rich list types (dateList, attachmentList, journalList, imageList, linkList). FieldValueService.areCompatibleForTranspose allows any string-like types and any numeric-like types to be transposed between one another; rich list types must match exactly.

coerceValue does not trim text, multilineText, or url values. Trimming on every setValue call would cause the parent widget to rebuild with the trimmed value and overwrite the TextEditingController mid-typing, which breaks typing spaces (the field appears to select-all and erase on the next keystroke). Trimming happens at save time instead — GearEditScreen._save trims _gearItem.name, and TripEditScreen._save trims _workingTrip.name before calling addGear/addTrip.

Rich-list field editing

FieldInputWidget in lib/widgets/field_input_widget.dart supports rich-list FieldTypes. When inlineRichLists is true (used by GearEditScreen and TripEditScreen), it renders the appropriate section widget directly in the form:

  • attachmentList → TripAttachmentsSection
  • imageList → ImageAttachmentsSection
  • journalList → JournalSection with EditJournalEntryDialog
  • linkList → TripLinksSection (retained for compatibility, but the linkList and linkedTripIds fields are hidden from the TripEditScreen)

When inlineRichLists is false (used by compact contexts such as BulkEditDialog and GearFiltersDialog), the widget opens RichListFieldDialog (lib/dialogs/rich_list_field_dialog.dart) instead. Both paths return the updated typed list to FieldInputWidget.onChanged, which is then serialized by FieldValueService.

Edit screens

GearEditScreen and TripEditScreen build their forms from FieldDefinitions instead of hardcoded widgets. _buildFields loads the visible definitions for the entity from AppState, filters them with FieldValueService.isVisible, and lays them out in a Wrap of ConstrainedBox tiles. Both screens also have a defensive fallback that inserts the default name field definition if it's missing from the schema, so the name field is always visible and editable. Wide fields (multilineText, attachment/image/journal/link lists, and dateList) span the full row; scalar and dropdown fields default to a 220 dp tile. FieldInputWidget reads the current value with FieldValueService.getValue and writes it back with FieldValueService.setValue. Text fields (text, multilineText, url) do not trim on every keystroke — trimming happens only on save, so spaces are preserved while editing.

GearEditScreen accepts an optional prefill parameter in addition to item. When item is provided, the screen edits that item; when prefill is provided instead, the screen creates a new item seeded with a deep copy of the prefilled values (used by the "Copy to new item" button). The copy is made via toJson()/fromJson() with the id set to null so a fresh UUID is generated, and all fields, custom values, images, attachments, journal, linked items, compartments, and icon override are copied as-is.

EditableSingleSelect renders as an inline combobox (RawAutocomplete) that lets the user type to filter options. The dropdown includes "— none —" and a "+ Add" entry; the "+ Add" entry is always shown while filtering and is labelled with the typed query (+ Add "…"), consuming that text directly when tapped — or selecting the existing option when the query matches one case-insensitively. With an empty field it opens a name-entry dialog instead. EditableMultiSelect opens a _MultiSelectDialog whose search field doubles as the add input: a "+ Add" entry trails the filtered checkbox list and consumes the search query the same way. Both widgets' onAdd callbacks also set the new value on the entity via FieldValueService.setValue, so adding a new option (e.g. a new trip location) immediately selects it instead of silently doing nothing.

A few fields still need custom wiring:

  • GearEditScreen special-cases categoryId (shows category names but stores category IDs) and linkedItemIds (opens a GearSelectionDialog for picking linked gear).
  • TripEditScreen special-cases layoutPreset (renders a labelled DropdownButtonFormField with user-friendly labels and, when byCustomField is selected, shows a second dropdown for groupByField).

FieldInputWidget also performs entity-specific formatting and parsing: weight is stored in grams but displayed in the user's selected weight unit; currency shows the user's currency symbol; duration is a day count mapped to a Duration; and number for quantity is integer-only. Weight fields round to one decimal place in grams and trim display precision (one decimal for grams, two for other units) when the field loses focus. A snackbar explains the rounding only when the rounded, formatted value differs from what the user typed and the rounded value would change the model, so entering a more precise form of the same value (e.g. 1.120 oz when the stored value is 1.12 oz) does not produce a snackbar.

For numeric fields (number, weight, currency, duration), FieldInputWidget.didUpdateWidget is careful not to overwrite the controller while the user is typing a valid intermediate decimal. Values such as 1., .5, 1.0, and a leading or trailing sign are kept in the text field even though they parse to a different string after _trimTrailingZeros or unit conversion. The controller is only rewritten when the typed text is invalid, when the model value changes for a complete number (for example a unit conversion), or when the current text no longer parses to the same value as the model. This is handled by _shouldKeepNumericText and the _decimalInProgress regular expression in lib/widgets/field_input_widget.dart.

Value lists

Known shared lists (ValueList.brand, type, prep, storageLocation, tripConditions, tripType, and tripWeather) are stored directly in AppSettings. Custom FieldOptionsSource.valueList names are stored in AppSettings.customValueLists (JSON key customValueLists). AppState.optionsForField resolves both: it first checks the built-in ValueList enum, then falls back to customValueLists by name. AppState.addFieldOption adds new values to the appropriate backing list. For FieldOptionsSourceKind.tripLocations and other derived sources, there is no backing list to update — the caller's onAdd callback is responsible for setting the value on the entity via FieldValueService.setValue. Settings > Lists & fields > Dropdown lists surfaces any custom value list referenced by a non-core FieldDefinition and the CustomListManagerDialog in lib/dialogs/custom_list_manager_dialog.dart lets users add and delete values; deletions clear the value from gear, trips, and pack entries via FieldValueService.

Persistence format

StorageService stores the following JSON files in the application support directory. The first group is also the cloud-sync set — the same eight files SyncService uploads and downloads and WearDataLayerService pushes to the watch:

  • gear.json – gear, a list of GearItem objects, plus deleted, a list of Tombstone records (id and deletedAt).
  • trips.json – trips, a list of Trip objects, plus the same deleted list.
  • packing_lists.json – packingLists, a list of PackingList objects, plus deleted.
  • trip_legs.json – tripLegs, a list of TripLeg objects, plus deleted.
  • trip_days.json – tripDays, a list of TripDay objects, plus deleted.
  • lists.json – AppLists containing shared dropdown values, categories, and per-field updatedAt timestamps.
  • settings.json – AppSettings containing device-local settings plus synced settings (weight unit, temperature unit, CSV delimiter, CSV decimal separator, currency symbol, weather auto-update interval, custom themes) and their per-field updatedAt timestamps. Only the whitelisted properties are written to the remote copy — see _syncableSettingsJson in SyncService; everything else on AppSettings stays on the device.
  • fields.json – FieldSchema containing all FieldDefinitions and a schemaVersion that tracks structural changes to the serialized format. Version 1 (implicit, pre-versioning) stored duration defaults as whole-day counts; version 2 stores them in seconds. FieldDefinition._decodeDefault uses the schema version to migrate old duration defaults deterministically instead of relying on a heuristic that could misread a legitimate sub-day duration in seconds as days.

Local-only files, never uploaded or sent to the watch:

  • templates.json – templates, a list of GearTemplate objects, plus deleted tombstones.
  • views.json – views, a list of SavedView objects.
  • sync_state.json – the sync baseline (SyncState): per-file entity baselines, property baselines, and remote metadata. Local-only by design — see Baseline lost, stale, or partially populated.
  • migrations/ – one-shot marker files for already-run migrations.
  • app.log (in the log directory) – the persisted application log shown under Settings > Logging.
  • git_known_hosts.json – the pinned SSH host-key fingerprints for Git sync; see Extending sync providers.

A one-time migration, StorageService.migrateTripsAndPackingLists(), runs on startup (guarded by migrations/trip_packing_list_split.json). It creates a default PackingList per trip type, rewrites GearItem.packLocationPreferences from trip-type keys to packing-list-id keys, splits each Trip into a TripLeg and one TripDay per calendar day, and preserves the old details/links content in Trip.overview while clearing Trip.linkedTripIds and Trip.links. The marker file ensures the migration only runs once.

Deletion is recorded by a tombstone rather than by the entity's absence. The sync baseline in sync_state.json cannot carry it: _updateEntityBaseline rebuilds entityBaselines from the surviving items on every sync, so a deleted id leaves the baseline on the sync that applies the deletion, and _loadSyncState degrades an unreadable file to an empty SyncState. Under a baseline-derived scheme either of those turns a deletion back into a local-only add and the item is resurrected on every device. A tombstone travels in the synced file, so it survives a lost, stale, or partial baseline — and a restore, since exportAllJson carries deletedGear and deletedTrips too.

Tombstones never expire. A retention window would bound the file, but it reintroduces the bug tombstones exist to fix: a device offline for longer than the window still holds the item, stops receiving the tombstone, and re-uploads it — and because any window has to outlast every plausible offline period to be safe, the safe window is "forever". The growth this gives up on is small: a record is an id and an ISO timestamp, so ten thousand deletions cost well under a megabyte across the life of an install. If the deleted list ever does need bounding, cap it by count rather than by age — dropping the oldest records degrades predictably, where dropping by calendar date silently resurrects data on whichever device happened to be away.

An entity deleted by a build that predates tombstones has no record at all, so it still fails to propagate; guessing from its absence would make a fresh install look like a mass delete.

importAllJson reassigns a fresh uuid to any imported gear, packing list, trip, trip leg, or trip day whose id is tombstoned — locally, or in the backup's own deletedGear, deletedPackingLists, deletedTrips, deletedTripLegs, or deletedTripDays lists — rather than dropping the tombstone. Dropping it locally would not stop the next sync from re-deleting the item: the tombstone may also live on other devices, and the backup's own tombstone list is written back as-is. A fresh id makes the restored item a brand-new entity that no tombstone targets, so it persists and propagates. Those tombstones remain valid for the old id (e.g. an out-of-date device still holding the original item still gets it deleted). A tombstone that exists only locally is not preserved: saveGear, savePackingLists, saveTrips, saveTripLegs, and saveTripDays replace the deleted list wholesale, and a restore replaces local state rather than merging into it — merging the two lists would also stop an explicitly empty deletedGear in the backup from meaning what it says. Only the deletion record is lost, not the protection it gives the restored item: the remapping above reads the local tombstones before the write, so the item still gets a fresh id. Where the record still has somewhere to propagate to, it comes back from the sync remote on the next run — the local file is not the only copy. With sync off there is no remote to restore it from, and equally no other device holding the deleted item, so nothing observable is lost.

GearTemplate.id is final, so an imported template whose id is tombstoned is replaced by a copyWith that carries the same content and a new id. All cross-references to a remapped id are rewritten so no relationships are silently broken: gear linkedItemIds; gear packLocationPreferences keys; trip PackEntry.gearId and containerGearId; trip PackEntry.sourcePackingListId; trip linkedTripIds; trip legIds; trip gearSnapshot (rekeyed, inner GearItem.id updated); trip leg tripId; trip leg dayIds; trip day legId; and gear template references. GearItem.id, PackingList.id, Trip.id, TripLeg.id, and TripDay.id are mutable (not final) to allow this. updatedAt is preserved on remapped items — the content is unchanged, only the identity changed, and a new id uploads as a local add regardless of timestamp so no conflict is possible.

All JSON files are pretty-printed for human readability. Legacy categories.json files are not migrated; existing local data starts fresh from lists.json. Attachments are stored as binary files in a manuals/ sub-directory and can be opened in the platform's default app via open_filex.

State management

AppState holds in-memory lists of GearItem, GearCategory, PackingList, Trip, TripLeg, and TripDay. All mutations go through AppState, which then calls StorageService.save*() and notifyListeners(). The UI uses context.watch<AppState>() to rebuild.

AppState.resetFieldDefinitionsToDefaults(FieldEntity) re-adds missing default field definitions for an entity and resets isCore, type, optionsSource, sortOrder, and other non-user properties of existing defaults to the values shipped in FieldSchema.defaultDefinitionsFor(). It preserves user renames, visibility, default values, conditions, and custom fields, but any custom or non-default fields are renumbered so they appear after all default fields while keeping their existing relative order. FieldManagerDialog exposes this as a Restore defaults button with a confirmation. FieldSchema.defaultDefinitionsFor() and FieldSchema.withDefaults() now return independent copies of the static default definitions to avoid mutating the shared default lists at runtime.

AppState._ensureCoreFieldDefinitions() runs automatically on app startup (in init()) and after each _reloadFromStorage call (e.g. after sync). It repairs the essential properties (isCore, type, key, entity, optionsSource, unit, isRequired, isGroupable) of existing core default field definitions, replaces any custom-id field that has the same key as a core default, and re-adds any core default field definitions that are missing from the loaded schema (e.g. due to a sync conflict or manual edit). User customizations such as name, isVisible, sortOrder, defaultValue, condition, appliesTo, and options are preserved, so intentionally hidden core fields remain hidden. Only fields marked isCore: true in the default schema are restored — non-core defaults may have been intentionally deleted by the user. The repair matches by both id and entity to avoid cross-entity collisions when the same id is used for different defaults (e.g. name, quantity, images). The schema is saved only when a change occurs.

AppState.completeOnboarding() sets AppSettings.hasCompletedOnboarding and persists the change, causing PyggoApp to switch from OnboardingScreen to MainScreen. When the user taps Get started, OnboardingScreen opens a UnitSetupDialog with defaults from LocaleDefaults (metric units everywhere except US/Liberia/Myanmar, which use ounces and Fahrenheit, and a currency symbol from NumberFormat.simpleCurrency); the chosen units and symbol are saved before onboarding is marked complete. lastError is a short error message set by _handleSaveResult when any save operation fails; PyggoApp shows it in a SnackBar and then calls clearError(). GearEditScreen checks for a duplicate gear name before saving and shows DuplicateConfirmationDialog when a match is found.

AppState observes app lifecycle transitions via WidgetsBindingObserver and calls _refreshStaleWeatherIfNeeded() when the app is resumed. _refreshStaleWeatherIfNeeded() also runs once after init() finishes loading. The refresh checks AppSettings.weatherAutoUpdateHours; if the value is 0 the refresh is skipped. Otherwise, it iterates over trips that have a location and overlap the provider's forecast window, and whose cached Weather.updatedAt is older than the configured interval, then calls refreshTripWeather(tripId) for each candidate. Errors during auto-refresh are logged but not shown. refreshTripWeather supports a force flag for new trips and for location/date changes; force clears the existing Weather snapshot before fetching a fresh one. Past and far-future days are never re-fetched once they have been cached.

Extending sync providers

Add a new class that implements SyncProvider:

class MySyncProvider extends SyncProvider {
  @override
  String get name => 'My Provider';

  @override
  String get type => 'my-provider';

  @override
  bool get isConfigured => /* true when configured */;

  @override
  Future<bool> configure(SyncProviderConfig config) async { /* ... */ }

  @override
  Future<List<RemoteFileInfo>> listDirectory(String path) async { /* ... */ }

  @override
  Future<Uint8List?> downloadFile(String path) async { /* ... */ }

  @override
  Future<bool> uploadFile(String path, Uint8List bytes, {String? contentType}) async { /* ... */ }

  @override
  Future<bool> createDirectory(String path) async { /* ... */ }

  /// Optional: flush staged changes in a single batch (used by Git for commit/push).
  /// [fileDirections] maps relative file paths to the sync direction for that file.
  Future<bool> flush({Map<String, SyncDirection> fileDirections = const {}}) async => true;
}

SyncProvider lives in lib/services/sync_provider.dart. Register new providers in SyncService.availableProviders and expose their configuration UI in lib/screens/settings/sync_settings_screen.dart using SyncProviderConfig to pass the target directory and provider-specific values. GitSyncProvider is a concrete example in lib/services/git_sync_provider.dart; it uses git2dart directly, rewrites SSH remotes to ssh://user@host:port/path so libgit2 uses the configured port, manages all libgit2 object lifetimes explicitly, and runs every libgit2 network/object operation on a worker isolate using Isolate.run so the UI isolate never blocks. Shared Repository handles were removed; each operation opens the repository in the worker and frees it before returning.

Git sync validates SSH keys before accepting them. SshKeyCapabilities uses dart:ffi to call libssh2_session_supported_algs and discover the public-key/signature algorithms the bundled libssh2 supports on the current device. GitSyncProvider.configure parses the user's public key (and, if needed, the OpenSSH private key) to extract the key algorithm, compares it to the supported set, and throws UnsupportedSshKeyTypeException if it is not supported. lib/screens/settings/sync_settings_screen.dart catches this exception and shows a SnackBar listing the supported algorithms. SyncService catches the same exception during provider initialization so an already-stored unsupported key does not crash app startup.

Git sync verifies remote identity. TLS certificates follow libgit2's validation against the system trust store (on Android a CA bundle is extracted and passed to Libgit2.setSSLCertLocations). SSH host keys, which libgit2 cannot validate itself, are pinned trust-on-first-use: the SHA-256 fingerprint of the first key seen for a host is stored in git_known_hosts.json under the app storage directory, and later connections must present the same key. If a server's key legitimately changes, delete that file and sync again to re-pin.

libgit2 object lifetimes

libgit2 objects are manually managed native handles; freeing them in the wrong order (for example a Repository freed while a child Index, Tree, Reference, or Config is still alive) causes double-free/use-after-free segfaults in free(). The provider therefore follows strict rules: every operation opens its own Repository inside the worker isolate and frees it before returning (no shared repository handles), and every child object (Index, Config, Reference, Commit, Tree, Blob, Remote, Signature) is freed in a finally block before its parent. Keep to these rules when extending the provider.

SyncService stores provider credentials in flutter_secure_storage by default. _saveCredentials writes the credentials and immediately reads them back to verify the secret round-trips. If the read-back fails, it deletes that provider's entry and rewrites the secret, verifying again. Only if the second attempt also fails does _saveCredentials return false, which triggers the plaintext fallback path on Linux or the in-memory-only path on other platforms.

On Linux, if secure storage cannot persist the credentials, SyncService throws PlaintextStorageException and lib/screens/settings/sync_settings_screen.dart shows PlaintextCredentialsWarningDialog. When the user accepts, SyncSettings.usePlaintextCredentials and plaintextWarningAcknowledged are set to true, the credentials are written to settings.json, and StorageService enforces 0600 file permissions. If permission enforcement fails, a warning is logged and surfaced through SyncService.syncWarningMessage so PyggoApp can show a SnackBar. On every app start and settings change, SyncService re-verifies these permissions while plaintext credentials are in use.

On startup, when usePlaintextCredentials is true, SyncService probes secure storage and, if it is now available, migrates the credentials, clears the plaintext copy, and exposes credentialMigrationMessage for the UI to show as a snackbar. The migration attempt is logged at INFO and any failure at WARNING. If the migration fails, the credentials remain in plaintext and a warning is logged, but syncWarningMessage is not surfaced again once the user has already acknowledged the plaintext fallback (SyncSettings.plaintextWarningAcknowledged).

Sync and conflict resolution

ConflictResolver (lib/services/conflict_resolver.dart) performs a three-way merge of each synced file: local, remote, and the baseline recorded in sync_state.json from the last successful sync. The resolver uses per-entity updatedAt values for gear.json, trips.json, packing_lists.json, trip_legs.json, and trip_days.json; per-property propertyUpdatedAt values for lists.json and settings.json; and per-item updatedAt values inside list properties such as categories and customThemes.

The intended outcome for every case is written below. These are the invariants the merge must preserve; any change to ConflictResolver or SyncService must not violate them. The randomized convergence test in test/sync_orchestration_test.dart exercises these cases across two simulated devices.

Entity-level merge (gear, trips, packing lists, trip legs, and trip days)

Each id is reduced to one of three states per side — live, deleted, or absent — and the two sides are then combined. A side holding both an item and a tombstone is resolved by recency, so a re-created id reads as live rather than as a contradiction.

Case Local state Remote state Outcome
add/add (same id) live live The newer updatedAt wins. On a timestamp tie, local wins and a SyncConflict is recorded when the values differ.
edit/edit live (changed) live (changed) The newer updatedAt wins. On a tie with differing values, local wins and a conflict is recorded.
only-one-side changed live (changed) live (unchanged) The changed side wins outright.
neither changed live live Local is kept (falling back to remote when local is absent).
delete/unchanged deleted live (unchanged) Deletion wins. The tombstone propagates.
delete/modify deleted live (changed) Resolved by recency on the shared time axis: deletedAt vs updatedAt, later wins. If the surviving copy was edited since the baseline, a SyncConflict is recorded; if it was not touched, the deletion is an ordinary propagation and no conflict is surfaced.
delete/delete deleted deleted The later tombstone is kept so both devices agree on the record.
deleted on one side, never seen on the other deleted absent The tombstone is kept and uploaded so the other device learns about it.
re-created after deletion live (after tombstone) deleted The edit is newer than the deletion: the entity is re-created and the tombstone is dropped so it cannot kill the item again on a later merge.

Absent vs empty

A file whose key is missing is not the same as a file whose key is present but empty.

  • Local absent, remote present: the remote file is downloaded and merged normally.
  • Local present, remote absent: the local file is uploaded (it is a new file the remote has not seen).
  • Remote file present but missing a list key (e.g. gear.json with no gear array): the merge fails rather than treating the absence as an empty list, because reading an absent key as empty would let a corrupt or hand-edited remote file delete all local data.
  • Empty list ("gear": []): treated as a legitimate empty list, not as a missing key. This is the distinction that lets a user delete every entry from a synced list without it being restored from defaults.

Baseline lost, stale, or partially populated

sync_state.json is local-only and never synced. _loadSyncState degrades an unreadable file to an empty SyncState. When the baseline is empty or missing for an id, the merge treats the baseline time as the epoch (1970-01-01), which means any local or remote item with a real updatedAt reads as "changed" and wins over the baseline. This is what prevents a fresh install (no baseline) from looking like a mass delete — every item on both sides is "changed" and kept.

A stale baseline (one that records an updatedAt older than both sides) behaves the same way: both sides read as changed, and the newer one wins. A partially populated baseline (some ids present, others missing) is handled per-id, so the missing ids fall back to the epoch while the present ids use their recorded time.

Tombstone retention

Tombstones never expire. A retention window would reintroduce the bug tombstones exist to fix: a device offline for longer than the window still holds the item, stops receiving the tombstone, and re-uploads it. The growth this gives up on is small — a record is an id and an ISO timestamp — so the safe window is "forever". If the deleted list ever does need bounding, cap it by count rather than by age: dropping the oldest records degrades predictably, where dropping by calendar date silently resurrects data on whichever device happened to be away.

Property-level merge (lists, settings)

lists.json and settings.json merge per-property using propertyUpdatedAt timestamps with the same _choose logic as entities: only-one-side-changed wins outright, both-changed takes the newer, ties favor local with a recorded conflict. String list properties (such as brandNames and tripTypes) are deduplicated case-insensitively after the merge so two devices that added the same value in different casings converge to one entry.

Convergence

The merge is designed so that two devices starting from the same baseline, making arbitrary edits, and syncing through a shared remote converge to the same state. No non-deleted operation is silently lost: an edit always carries a fresh updatedAt, so it always reads as "changed" against any baseline older than itself, and it wins over a stale remote copy. A deletion carries a deletedAt on the same axis, so it wins over an older edit and loses to a newer one.

Error handling and provider atomicity

SyncService.performSync runs each file's sync step inside its own try/catch. FormatException and TypeError are converted to a per-file SyncResult failure, so a corrupt gear.json cannot abort the entire run or leave later files unsynced. _decodeRemoteObject wraps JSON parse errors with the file name, and _formatSyncError strips Dart exception prefixes before the message reaches the UI. SyncResult.isTransient distinguishes network/connection/timeout failures from data errors; only non-transient data errors are surfaced through AppState.lastError as a snackbar.

Providers are expected to upload each file atomically. WebDavSyncProvider.uploadFile writes the bytes to a temporary *.tmp path and then issues a server-side MOVE with Overwrite: T, deleting the temp file if the move fails. GitSyncProvider.uploadFile writes a *.tmp sibling and renames it into place. These patterns prevent a partial write from corrupting the final file that a later download would merge.

Wear OS companion

Pyggo ships a slimmed-down Wear OS companion app from the same Flutter project. It is built as an Android wear build flavor with a dedicated entry point (lib/main_wearos.dart) and a separate source set under android/app/src/wear/. The phone app is the phone flavor with the existing lib/main.dart entry point.

Build flavors

android/app/build.gradle.kts defines a flavorDimensions of "platform" with four productFlavors:

  • fdroid — the F-Droid phone build: the same app as phone but with no proprietary Google Play services dependencies (see The F-Droid flavor).
  • phone — the direct-install phone/tablet APK, built for arm64-v8a and armeabi-v7a only and with native libraries compressed (extractNativeLibs="true").
  • phoneall — the universal phone AAB kept for future Play Store / bundletool delivery. It keeps all supported ABIs (including x86_64) and is not built as a direct-install APK. This flavor is currently not built because the app is not yet published to the Play Store.
  • wear — the Wear OS companion, built arm64-v8a and armeabi-v7a and stripped of the unused git2dart_binaries native libraries, which the watch does not use.

All four flavors keep the same applicationId (com.pyggo.pyggo) because the Wear OS Data Layer only pairs apps with identical package names. The phone, fdroid, and wear APKs are not expected to coexist on one device, so the shared id is safe; it does mean the phone and fdroid builds cannot be installed side by side. tool/build.sh --dev overrides the id to com.pyggo.pyggo.dev for all flavors at once, so a dev phone build still pairs with a dev wear build — but never with a prod wear install.

The flavors split their Kotlin and manifest sources so the fdroid build carries no Google Play services code:

  • phone, phoneall, and wear compile android/app/src/gms/kotlin (the real PhoneWearPlugin, PhoneWearListenerService, and PhoneChannelHolder). phone and phoneall also merge src/gms/AndroidManifest.xml, which declares the listener service, via manifest.srcFile; wear declares its own WearDataLayerService in src/wear/AndroidManifest.xml instead, because a flavor has exactly one manifest file.
  • fdroid compiles android/app/src/foss/kotlin, which holds a no-op PhoneWearPlugin that registers the same pyggo/wear method channel and answers every send call with false, so MainActivity and the Dart WearDataLayerService need no build-specific handling — watch sync is simply never offered.
  • The play-services-wearable dependency is declared per-flavor (phone/phoneall/wear Implementation configurations); the fdroid flavor has no Google Play services on its classpath and its manifest contains no com.google.android.gms entries.

Build commands:

flutter build apk --flavor fdroid --target-platform android-arm,android-arm64
flutter build apk --flavor phone --target-platform android-arm,android-arm64
# flutter build appbundle --flavor phoneall  # disabled until Play Store publishing
flutter build apk --flavor wear --target lib/main_wearos.dart --target-platform android-arm,android-arm64

Fastforge builds them via distribute_options.yaml; the android release includes android-apk (phone), android-fdroid-apk (fdroid), and android-wear-apk (wear) jobs. The phoneall AAB job is currently disabled because the app is not yet published to the Play Store. The wear release also builds wear.

Architecture

The watch app reuses the existing lib/models/, lib/services/StorageService, and lib/core/ code directly. Watch-only UI and state live under lib/wearos/. The phone app never imports lib/wearos/, so tree shaking keeps the phone APK free of watch UI code.

The watch and phone communicate over the Wear OS Data Layer. The native bridge is a MethodChannel named pyggo/wear:

  • Watch side: WearDataLayerService (Kotlin WearableListenerService) receives Data Layer messages and forwards them to the Flutter engine via WearChannelHolder. WearMainActivity registers the channel and handles outbound calls.
  • Phone side: PhoneWearListenerService (Kotlin WearableListenerService) does the same for the phone, forwarding watch messages to PhoneChannelHolder. MainActivity adds PhoneWearPlugin, which registers the channel and handles outbound calls; the fdroid flavor swaps in a no-op PhoneWearPlugin so watch sync is simply never offered (see Build flavors).
  • Dart side: WearSyncProvider (lib/wearos/) wraps the channel on the watch; WearDataLayerService (lib/services/) wraps it on the phone. Both expose an onMessage stream and a sendMessage method.

The wear_plus package provides WatchShape (round vs square display adaptation) and AmbientMode for the watch UI.

Sync model

The watch keeps local copies of gear.json, trips.json, trip_legs.json, trip_days.json, packing_lists.json, lists.json, settings.json, and fields.json using the existing StorageService (offline-first). A custom Data Layer sync sends single-entity messages for instant mutations (e.g. one TripLeg JSON when a pack entry is toggled) and does a full-file catch-up on reconnect that reuses ConflictResolver's merge rules. The existing SyncService is not reused for the transport — its debounced whole-file model is wrong for interactive packing. The watch has no cloud sync, no import/export, and no backup.

Sync transport

The watch↔phone sync has two paths:

Incremental (real-time): single-entity messages sent over MessageClient. The WearSyncPaths constants in lib/core/wear_sync_paths.dart (shared by both sides) name the full protocol; which direction each path actually flows:

  • Phone → watch: trip/upsert, tripLeg/upsert, tripDay/upsert, packingList/upsert, gear/upsert — one entity JSON per change, detected by the AppState listener comparing per-entity updatedAt baselines.
  • Phone → watch: gear/tombstone, trip/tombstone, tripLeg/tombstone, tripDay/tombstone, packingList/tombstone — one Tombstone JSON per deletion, sent when an entity disappears from state and a matching tombstone exists.
  • Phone → watch: settings/upsert (full AppSettings JSON — list values ride inside it, so lists/upsert is defined but never sent), fields/upsert (full FieldSchema JSON), ping, sync/file catch-up replies, and sync/requestAll (see catch-up below — the phone re-requests after answering so watch edits converge back).
  • Watch → phone: trip/upsert, tripLeg/upsert, tripDay/upsert, packingList/upsert for edits made on the watch; tripLeg/tombstone, tripDay/tombstone, packingList/tombstone for deletions made on the watch; settings/upsertProperty for single watch-settings edits; sync/requestAll to trigger a catch-up; and sync/file carrying trips.json/trip_legs.json/watch_settings.json when the phone requests the reverse catch-up. The watch never edits gear or deletes trips, so gear/upsert and the gear/trip tombstone paths are phone→watch only.

Catch-up (on reconnect/first pairing): one request/response each way. - Either side sends sync/requestAll; the responder sends sync/file messages, one per data file it can author, each containing the full file content including tombstone lists. - The phone sends all files (gear.json, trips.json, trip_legs.json, trip_days.json, packing_lists.json, lists.json, settings.json, fields.json); the watch sends trips.json and trip_legs.json — the two entity types it mutates — plus watch_settings.json carrying the watch* properties it exclusively authors (lastOpenedTripId stays message-only since the phone writes it too and a file cannot order the writes). - In practice the watch initiates: it sends sync/requestAll at startup and on every reconnect (its onConnected), the phone answers with its files and piggybacks a sync/requestAll, and the watch replies with its files without re-requesting — the exchange cannot ping-pong. - Each side merges incoming files using entity-level updatedAt comparison (the same invariant as ConflictResolver): newer entity wins, tombstones propagate by deletedAt. On the phone the merge routes through the same upsert*FromWatch/apply*Tombstone methods used for single messages, so a stale watch file cannot clobber a newer phone edit or resurrect a phone-side deletion.

Because every reconnect re-runs the exchange in both directions, the files are the durable layer: an incremental message dropped while the link was down is healed by the next catch-up, even across process restarts. The in-memory outbound queue exists for latency, not correctness — it replays watch edits immediately on reconnect rather than waiting for the file exchange.

Offline queue: outbound tripLeg and trip upserts and settings/upsertProperty updates are queued while disconnected, deduplicated by entity id or property name (latest wins), and flushed on reconnect. An entry is dequeued only when its send succeeds — a failed send keeps the entry for the next flush so a dropped message is retried rather than lost — and a re-entrancy guard (_flushing/_flushAgain) serializes flushes so a second onConnected cannot double-send and entries enqueued mid-flush are picked up by the same pass.

On the watch, applying a tombstone also cleans up the cross-references the phone cleans up: a day tombstone removes the id from the owning leg's dayIds, and a leg tombstone removes the id from the owning trip's legIds (the next save re-projects Trip.entries from the new primary leg). A trip tombstone cascades to the trip's legs and their days — each removed entity gets its own tombstone recorded — so a delayed leg or day upsert cannot resurrect an orphan after its trip was deleted.

Merge invariant: both paths use the same rule — newer updatedAt wins, tombstones win over older edits, edits win over older tombstones. This is the same invariant as ConflictResolver._mergeEntities, so the watch and phone converge to the same state as the existing cloud sync.

Key classes: - WearAppState (lib/wearos/wear_app_state.dart) — watch-side state container. Exposes read-only getters + pack-entry mutations shared with the phone through the PackEntryOps mixin (lib/core/pack_entry_ops.dart). Applies incoming phone upserts via upsertTripFromPhone, upsertGearFromPhone, upsertPackingListFromPhone, upsertTripLegFromPhone, upsertTripDayFromPhone, upsertSettingsFromPhone, upsertListsFromPhone, and upsertFieldsFromPhone. It also resolves the primary leg for a trip and keeps Trip.entries as a compatibility projection. Incoming trip upserts never create a leg — the phone sends trips before their legs, so a stored placeholder would duplicate the real leg when it arrives. legForDisplay synthesizes a transient TripLeg backed by Trip.entries so screens can render a trip whose legs have not synced yet; it is never stored, so legsForTrip and primaryLegForTrip only ever count real legs. The placeholder adopts trip.legIds.first as its id when the trip expects exactly one leg so watch edits pushed back land on the real leg. _removePhantomLegs drops stored dayless legs once the trip has a leg with days, cleaning up placeholders persisted by older versions. - WearSyncService (lib/wearos/wear_sync_service.dart) — watch-side sync orchestrator. Decodes incoming messages, applies them to WearAppState, queues outbound tripLeg/upsert, trip/upsert, and settings/upsertProperty messages, and answers sync/requestAll by sending trips.json, trip_legs.json (with tombstones), and watch_settings.json back to the phone. - WearDataLayerService (lib/services/wear_data_layer_service.dart) — phone-side bridge. Listens to AppState changes, sends entity upserts and tombstones to the watch for trips, gear, legs, days, and packing lists, pushes settings/upsert and fields/upsert when those change, handles sync/requestAll by sending all files plus a piggybacked request for the watch's files, merges inbound sync/file payloads through the same watch-upsert/tombstone handlers, and applies watch-originated upserts, tombstones, and settings property updates. - AppState — phone-side merge methods for watch-originated upserts (upsertTripFromWatch, upsertPackingListFromWatch, upsertTripLegFromWatch, upsertTripDayFromWatch) and tombstone handlers (applyTripLegTombstone, applyTripDayTombstone, applyPackingListTombstone).

Loop avoidance

When the watch sends a tripLeg/upsert (or trip/upsert), the phone applies it via AppState.upsertTripLegFromWatch (or upsertTripFromWatch), which calls notifyListeners(). The phone-side WearDataLayerService listens to AppState changes and would normally push the leg back to the watch. To prevent this echo, the phone records the entity id and updatedAt in _echoSuppressions before applying the upsert. When the AppState listener fires, it checks the suppression map — if the entity's updatedAt matches a recent suppression, the push is skipped and the suppression is cleared. This ensures each upsert travels exactly one direction per edit.

The phone-side listener also maintains per-entity updatedAt baselines for trips, gear, packing lists, legs, and days so that only genuinely changed entities are pushed to the watch, not the entire state on every notifyListeners() call.

Watch settings

The watch has its own list summary field settings, stored as watchGearListSummaryFields and watchTripListSummaryFields on AppSettings. These are separate from the phone's gearListSummaryFields and tripListSummaryFields so the two can diverge — the watch defaults are slimmer (type, description, weight, last brought for gear; plus pack location and prep deadline for trips).

The watch also has its own packed-item behavior settings, stored as watchPackedItemBehavior, watchCrossOutPackedItems, and watchShowPackListHeaders on AppSettings. These are separate from the phone's packedItemBehavior, crossOutPackedItems, and showPackListHeaders so the watch can tune its small screen independently.

  • Phone UI: the settings screen has "Watch gear list details" and "Watch trip list details" tiles, plus "Watch packed items" (behavior dropdown), "Watch: cross out packed items", and "Watch: show group headers" switches, all under a Wear OS section.
  • Watch UI: the Settings screen (reached from the home list) shows editable list field pickers and packed-item behavior/cross-out/group-headers controls. Units are inherited from the phone and are not shown or editable on the watch.
  • Sync: watch-originated settings changes are sent to the phone via settings/upsertProperty messages. The phone persists them via AppState.updateSettings; the settings push that update would trigger is suppressed by _suppressNextSettingsPush, since the watch already holds the value it just sent. The WearSyncService.enqueueSettingsPropertyUpdate method accepts an Object value (string for enums, bool for switches, List<String> for field lists). Supported properties are watchGearListSummaryFields, watchTripListSummaryFields, watchPackedItemBehavior, watchCrossOutPackedItems, watchShowPackListHeaders, and lastOpenedTripId — the latter is recorded by WearAppState.recordLastOpenedTrip when a trip's pack list opens so the phone and watch feature the same trip on their home dashboards.
  • Units and theme: inherited from the phone, read-only on the watch. The watch reads themeMode from WearAppState.settings and builds both light and dark ColorSchemes from the phone's seed color, so the watch follows the phone's theme preference.
  • Advanced settings: phone-only, not exposed on the watch.

Watch grouping and packing flow

WearHomeScreen (lib/wearos/screens/wear_home_screen.dart) is a dashboard for the featured trip, mirroring the phone's Home tab. WearAppState.currentOrLastTrip and isTripCurrent are ports of the AppState methods (leg ranges first, trip dates as fallback) so active-trip detection matches across devices. The dashboard shows the app icon, the trip name and date range, a two-line weather summary — condition/precipitation then low/high, from WeatherFormatting.formatDailyForecastSummaryLines/formatWeatherSummaryLines (today's DailyForecast when today is a trip day, else the trip's cached weather) — today's itinerary as Markdown when a TripDay exists, and two action icons: a weather icon opening WearWeatherScreen (lib/wearos/screens/wear_weather_screen.dart — the trip's daily forecast, or the selected day's two-line summary, WeatherNarrative.forDay detail, and hourly breakdown) and a backpack icon opening the pack list for the leg covering the displayed day. The same previous/next-day chevrons flank the watch's weather and pack-list icons, stepping _selectedDate through WearAppState.tripDayRange and changing the day's weather summary, itinerary, and the weather icon's target date. When the displayed day is not covered by a leg, a multi-leg trip opens WearTripLegsScreen (lib/wearos/screens/wear_trip_legs_screen.dart) so the user can pick a leg; a single-leg (or legacy) trip opens WearTripDetailScreen directly for the primary leg. Below the dashboard, menu entries open WearTripListScreen (lib/wearos/screens/wear_trip_list_screen.dart — all trips sorted by first day, most recent first), the gear list, and settings. WearAppState.tripDayForDate, legForDate, and dailyForecastForDate resolve the day, leg, and forecast for a date; WeatherFormatting.formatDailyForecastSummary in lib/core/ builds the one-line day summary shared by the phone and the watch.

The crown's rotary motion arrives as synthetic mouse-wheel events (WearMainActivity.kt), which leaves a phantom pointer hovering over one widget on every screen — painting a persistent ink highlight and holding a tooltip open. The wear MaterialApp theme sets hoverColor/focusColor to transparent and TooltipVisibility(visible: false) wraps the routes so no hover state or tooltip is shown; tooltip: labels still provide accessibility semantics.

The watch leg list shows each leg's date range and pack/prep counts. Tapping a leg opens the pack list for that leg with both tripId and legId passed to WearTripDetailScreen.

The watch trip detail screen (lib/wearos/screens/wear_trip_detail_screen.dart) supports four grouping modes selected from a single icon at the top:

  • Pack location (default) — top level shows containers + "Packed Loose" + "Unorganized"; drilling into a container shows its compartments (if more than one) or its items directly.
  • Category — top level shows gear categories; drilling into a category shows its items.
  • Pack status — top level shows "Packed" and "Unpacked" groups.
  • Prep status — top level shows "Prepped" and "Not prepped" groups.

Items within a group are always sorted alphabetically by gear name. The grouping selector is a full-screen page (WearGroupingMenuScreen in lib/wearos/screens/wear_grouping_menu_screen.dart) rather than a bottom sheet, so it fills the round display and scrolls properly.

The watch gear list screen (lib/wearos/screens/wear_gear_list_screen.dart) supports grouping by category, type, or none (flat list sorted by name), behind the same single-icon menu pattern. The watch gear selection screen (lib/wearos/screens/wear_gear_selection_screen.dart) supports grouping by category (the default) or none, so adding items to a trip is organized the same way as browsing gear.

The trip detail header shows the trip's type icon in the top safe area with the trip name pushed below it, so neither is clipped on round displays. Tapping a pack entry's gear row opens WearGearDetailScreen, matching the gear list's tap behavior. The gear detail screen (lib/wearos/screens/wear_gear_detail_screen.dart) uses the same scrollable-header pattern with a backpack icon, renders visible field definitions via FieldValueService, and formats values per type: weights use the synced unit, dates use DateFormat.yMd(), and category/linked-gear references are resolved to names. Attachments, images, and the journal list are omitted from the rows; the journal is available through a tappable icon that opens WearGearJournalScreen.

The watch packing flow mirrors the phone's _handlePackedChanged / _showPrepAndPackDialog / _choosePackLocation logic in a watch-friendly way, but every mutation targets a specific leg:

  1. If the item has a prep instruction and isn't prepped, show a dialog asking whether to mark it prepped and continue.
  2. If the item is unorganized (not packed loose and not in a container) and containers are available, show a SimpleDialog for container selection, then compartment selection (if the container has multiple compartments), then quantity (if the entry's quantity is greater than 1). The "Packed Loose" option in the dialog sets packedLoose = true.
  3. If the item is unorganized and no containers are available, packing it makes the entry packed loose.
  4. Apply the mutation via packEntryIntoForLeg (or the public packEntryInto helper that resolves the leg from the source entry), which consolidates into an existing entry with the same destination when possible and splits the entry when only a subset of the quantity is moved. Both live in the shared PackEntryOps mixin.
  5. The screen calls _enqueueLegAndMaybeTripSync, which sends a tripLeg/upsert only when the leg is stored or the trip expects a single leg — a display placeholder for a multi-leg trip must not create a stray leg on the phone — and a trip/upsert only when the trip's updatedAt was also bumped (i.e., the edited leg is the primary leg).

packEntryIntoForLeg uses identical(entry, sourceEntry) to find the source entry in the leg's packEntries list, because split entries can have duplicate gear IDs. unpackEntryForLeg constructs a fresh PackEntry directly (rather than using copyWith) to clear the nullable containerGearId and compartment fields, because PackEntry.copyWith uses ?? for nullable fields and cannot set them to null.

The trip detail screen also has a prep list icon at the top that opens WearPrepListScreen for the current leg. The prep list shows only the selected leg's unprepped gear that has a prep requirement; tapping an item marks it prepped, immediately removing it from the list.

The prep icon only appears for items that have a prep instruction, so non-preppable items don't waste screen space. The packed checkbox and prep icon each have 8px of padding and HitTestBehavior.opaque so their tap targets are large enough for a finger on a small watch display.

packEntryInto rejects an attempt to pack an entry into itself by returning early when containerGearId matches the source entry's gearId. The container picker shown by _choosePackLocation filters the source item out of the candidate list as well.

All three drill-down screens (trip detail, gear list, gear selection) use PopScope to intercept swipe-back: when drilled into a group, swipe-back goes up one level instead of popping the whole screen.

The watch trip screen respects watchCrossOutPackedItems by applying TextDecoration.lineThrough to packed entries and to group tiles where all items are packed, and it respects watchPackedItemBehavior by moving packed items and fully-packed groups to the bottom of the list when that setting is moveToBottom.

On the watch trip detail screen, the group tiles (_GroupTile) drill into the group and do not show pack/unpack-all icons. _buildItems builds a single centered control row. At the top level it shows Group by (_GroupingSelector), Prep list (_PrepListButton), and Add gear icon buttons. Once a group is drilled into, the row hides Group by and Prep list and gains unpack all (Icons.check_box_outline_blank) and pack all (Icons.check_box) icon buttons. The pack/unpack-all buttons use _packAllEntriesWatch and _unpackAllEntriesWatch on the current group entries, so pack all still prompts for prep and a container on the watch.

Wear OS UI patterns

Round Wear watches have very little horizontal margin but a large vertical canvas. All watch screens follow these patterns:

  • WearScaffold (lib/wearos/wear_scaffold.dart) — no app bar, no fixed header, small horizontal insets (8px), surface-colored background. The body is expected to be a scrollable that adds its own top/bottom padding.
  • Scrollable body — every screen uses a ListView as its body. The first item is an icon or title header that scrolls with the content, not a fixed AppBar. Generous bottom padding (shortestSide * 0.4) ensures the last item can be scrolled into the round display's safe area.
  • AutoFitText (lib/wearos/widgets/auto_fit_text.dart) — single-line text that shrinks its font size to fit the available width, then falls back to a horizontal marquee if it cannot shrink below minFontSize. Use this for any label that might be long, especially trip and gear names on round displays.
  • WearRotaryInputScope (lib/wearos/wear_rotary_input_scope.dart) — provides a shared PrimaryScrollController for the watch screens. Rotary crown input is handled natively by WearMainActivity (see below), so no Dart-side rotary plumbing is needed.
  • No static nav bar — the home screen is a single scrollable list with trips, a gear entry, and a settings entry. Navigation is via Navigator.push, not a bottom tab bar.
  • Group drill-down headers — when the user taps a group in the trip pack list or gear list, the scrollable header changes to the group name and icon (via WearGroupHeader in lib/wearos/widgets/wear_group_header.dart) and shows a status line for the filtered items. On the trip pack list, the centered control row stays visible, removes the Group by and Prep list icons, and adds pack/unpack-all icons.
  • Touch targets — list tiles use minHeight: 56 (slightly larger than the phone's 48) to account for finger size on a smaller, moving screen.

Rotary crown scroll

WearMainActivity (Kotlin) overrides dispatchGenericMotionEvent to translate rotary encoder events (SOURCE_ROTARY_ENCODER + AXIS_SCROLL) into synthetic mouse-wheel events (SOURCE_MOUSE + AXIS_VSCROLL). Flutter's AndroidTouchProcessor ignores rotary encoder events directly, but receives the synthetic wheel events as ordinary pointer scroll signals. This means any Scrollable in the widget tree scrolls with the crown — no platform channel or scroll-controller wiring is needed on the Dart side.

Manifest merge: preventing duplicate launcher icons

The main source set's AndroidManifest.xml declares MainActivity with a LAUNCHER intent-filter. Android merges main + wear source sets for the wear flavor, so without intervention the wear APK would have two launcher activities (MainActivity + WearMainActivity), producing a duplicate icon on the watch. The wear manifest (android/app/src/wear/AndroidManifest.xml) uses <activity android:name=".MainActivity" tools:node="remove"/> to strip the phone's MainActivity from the merged manifest, leaving only WearMainActivity.

Splash screen

The wear splash (android/app/src/wear/res/drawable/launch_background.xml) uses a black background with the app logo (app_logo.png) at android:gravity="fill". The logo is placed in drawable-nodpi/ so Android does not density-scale it down — fill stretches it to the full screen, which on a round watch means the nearly-square logo fills the circle.

Testing

Run the widget test:

flutter test

And analyzer:

flutter analyze

Optional git hooks

.githooks/ holds a pre-commit and a pre-push hook that run the same gates as the flutter CI job, so a formatting slip or a red test surfaces locally instead of in a pipeline. They add no gates of their own: a clean local run means a clean flutter job.

Git does not clone hooks, so they are opt-in per checkout and are not installed by default:

git config core.hooksPath .githooks   # enable
git config --unset core.hooksPath     # disable
Hook Runs Roughly
pre-commit dart format --set-exit-if-changed, flutter analyze 3–4s
pre-push flutter test 8s

The split keeps commits cheap and runs the expensive gate once per push, which is what CI reacts to anyway. Both are bypassable with --no-verify for work in progress.

One imprecision worth knowing: pre-commit checks the working tree, not the staged snapshot, so a partially staged change is described by the tree rather than by the commit. Stashing around the hook would fix that but can lose work if the hook dies midway, so it is deliberately left alone — CI remains the authority. .githooks/README.md covers this in more detail.

Building

Pyggo uses fastforge for packaging. A thin shell wrapper, tool/build.sh, is provided so local and CI builds use the same path.

Build configuration

Local deploy settings (ADB device IDs and the AppImage copy path) live in tool/build_config.env, which is gitignored. Copy tool/build_config.env.example to tool/build_config.env and fill in the values you need:

cp tool/build_config.env.example tool/build_config.env
# edit tool/build_config.env

The optional on_complete_command there is a shell command run when tool/build.sh exits after build or deploy work has begun — exits before that point (--help, bad arguments) never fire it. The command receives the outcome (success or failure) as $1 and the platform as $2, so a notification can say what finished; a failing command is reported but does not change the script's exit status.

Usage

tool/build.sh linux                  # build all Linux packages
tool/build.sh --deploy linux         # build all Linux packages and install/copy them locally
tool/build.sh --deployonly linux     # install/copy existing Linux packages

tool/build.sh android              # build phone + F-Droid + Wear OS APKs
tool/build.sh android-fdroid       # build only the F-Droid APK
tool/build.sh android-phone        # build only the Android phone APK
tool/build.sh wear                 # build only the Wear OS APK
tool/build.sh all                  # build Linux and Android

tool/build.sh --deploy android     # build and deploy to configured devices
tool/build.sh --deployonly wear    # deploy the Wear OS APK without building

Supported platforms include linux, linux-appimage, linux-deb, linux-pacman, linux-zip, linux-rpm, linux-flatpak, linux-snap, android, android-fdroid, android-phone, wear, and all. The linux platform builds .deb, .AppImage, .pacman, .zip, .rpm, .flatpak, and .snap packages; the linux-* sub-platforms build only that format.

The wrapper exports PYGGO_VERSION_CODE from git rev-list --count HEAD before invoking fastforge or flutter build, so local Android builds derive the same monotonically-increasing versionCode as CI (see Releases and versioning below). Without the export, Gradle's resolveVersionCode would still compute the count itself as a fallback; exporting it pins the value up front, matches the CI path, and logs it. Non-Android platforms ignore the variable — their build number comes from the pubspec +N, as in CI.

The wrapper also writes tool/build-timestamp.json (UTC, ISO 8601) before building and passes it to every target with --dart-define-from-file. The value is available in the app as the BUILD_TIMESTAMP Dart define.

For Android release builds that need to be inspected with adb run-as, pass --debuggable. This temporarily adds android:debuggable="true" to AndroidManifest.xml and restores the original manifest when the script exits; the installed APK remains debuggable.

For a development build that installs side-by-side with the release app on the same Android device, pass --dev:

tool/build.sh --dev --deploy android-phone

The flag writes android/dev-build.properties (gitignored) with applicationId (com.pyggo.pyggo.dev), appLabel (Pyggo Dev), and appIcon (the inverted ic_launcher_dev icons produced by tool/generate.dart app), which android/app/build.gradle.kts reads in defaultConfig — the label and icon reach the manifest through the appLabel/appIcon placeholders. A properties file is used instead of environment variables because a long-lived Gradle daemon keeps the environment it was started with and would not see freshly exported vars. Prod builds delete the file; while it is present, even a plain flutter run/flutter build produces the dev variant. Presetting PYGGO_APPLICATION_ID, PYGGO_APP_LABEL, or PYGGO_APP_ICON overrides the values the script writes. Android artifacts are renamed to pyggo-dev-<channel>-<version>-android.apk so they cannot clobber or be mistaken for prod artifacts in dist/. --dev also implies --debuggable, so the dev APK can be inspected with adb run-as. --dev is valid only for android, android-phone, wear, and all (where it applies to the Android half); it is a local-development flag — CI always builds without it. There is no --prod; omitting --dev builds the normal release.

Linux packaging fails when the project checkout path contains spaces (some flutter_app_packager makers split ldd output on spaces). tool/build.sh detects this and builds Linux packages in a temporary, space-free copy of the repo, then copies the artifacts back.

You can still run the underlying flutter build commands directly, but the wrapper keeps outputs in the same dist/ layout as CI and sets PYGGO_VERSION_CODE and BUILD_TIMESTAMP for you.

Deploy behavior

When --deploy or --deployonly is used, tool/build.sh reads tool/build_config.env:

  • Android phone: android_phone_device_ids is a semicolon-separated list of ADB serials. The phone APK is installed to each in order with adb -s <id> install -r. The legacy android_phone_device_id (single serial) is still honored if the plural form is not set. A failed install for one device does not abort the script; it continues with the remaining devices.
  • Wear OS: wear_os_device_ids is a semicolon-separated list of ADB serials. The Wear OS APK is installed to each in order with adb -s <id> install -r. The legacy wear_os_device_id (single serial) is still honored if the plural form is not set. A failed install for one device does not abort the script; it continues with the remaining devices.
  • F-Droid: android_fdroid_device_ids is a semicolon-separated list of ADB serials for the F-Droid APK, with android_fdroid_device_id as a legacy fallback. deploy android intentionally skips the F-Droid APK — it shares the phone build's applicationId and would replace it — so it is only installed via tool/build.sh --deploy android-fdroid.
  • AppImage: if appimage_path is set, the .AppImage is copied there and made executable.
  • Other Linux packages: the corresponding local package manager command is used (dpkg -i, rpm -i, pacman -U, flatpak install --user --bundle, snap install --dangerous). These may require sudo on some systems.
  • .zip archives have no deploy action; extract them manually.

You can skip any deploy step by setting the matching skip_* flag to true in tool/build_config.env: skip_android_fdroid, skip_android_phone, skip_wear, skip_appimage, skip_deb, skip_flatpak, skip_pacman, skip_rpm, or skip_snap.

The Wear OS APK is built from the wear Android flavor with lib/main_wearos.dart as the entry point. CI builds it as part of the android-package job via fastforge release --name android, which includes the android-wear-apk job defined in distribute_options.yaml.

Building for Linux

Pyggo supports .deb, .rpm, .AppImage, .pacman, .zip, .flatpak, and .snap packages on x86_64. The desktop entry, icon, and AppStream metainfo used by the packages live in linux/packaging/.

Prerequisites

  • Flutter and Linux desktop build dependencies are installed (gtk3, libblkid1, liblzma5, libsecret-1-0, plus the -dev variants on Debian/Ubuntu).
  • Fastforge: dart pub global activate fastforge and add $HOME/.pub-cache/bin to PATH.
  • Native packaging tools:
  • .deb: dpkg / dpkg-deb
  • .rpm: rpmbuild / rpm (Arch/SteamOS package rpm-tools)
  • .AppImage: appimagetool, patchelf. The GitLab CI job downloads the official appimagetool-x86_64.AppImage from the AppImage project and installs a /usr/local/bin/appimagetool wrapper that runs it with APPIMAGE_EXTRACT_AND_RUN=1 so it works in Docker without FUSE.
  • .pacman: bsdtar (Arch libarchive), xz
  • .flatpak: flatpak, flatpak-builder, and the runtime/sdk
  • .snap: snapcraft (destructive mode is used, so a snapcraft-compatible host is required)

For the .flatpak runtime:

flatpak install flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08

flatpak-builder mounts a rofiles-fuse filesystem inside its build directory, which fails with fusermount3: mount failed: Permission denied when the project path is outside the mount targets allowed by the host's AppArmor fusermount3 profile (openSUSE and recent Ubuntu allow only /home, /tmp, /mnt, /media, and /run/user). Set flatpak_work_directory in tool/build_config.env (see tool/build_config.env.example) to relocate the build directory, ostree repo, and .flatpak-builder cache — e.g. /tmp/pyggo-flatpak.

org.freedesktop.Platform does not ship every library the bundled plugins link against — libsecret (flutter_secure_storage), libssh2 (the bundled libgit2), and libjvm (dartjni) at present. flatpak/build-flatpak.sh mirrors what the AppImage maker does: it ldd-resolves each bundled .so, subtracts the libraries the runtime already provides plus the libflutter_linux_gtk.so dependency set, and stages the rest into build/linux/x64/release/bundle/flatpak-libs/, which the manifest installs to /app/lib. It also warns if a staged library requires a newer glibc than the runtime's, and after flatpak-builder finishes it re-runs ldd inside the sandbox to flag anything still unresolved.

Local build

The wrapper builds every Linux format:

tool/build.sh linux

Build only a specific format:

tool/build.sh linux-appimage
tool/build.sh linux-deb
tool/build.sh linux-flatpak
tool/build.sh linux-pacman
tool/build.sh linux-rpm
tool/build.sh linux-snap
tool/build.sh linux-zip

You can still run the underlying steps directly, which is useful when debugging a single packager:

flutter build linux --release
fastforge release --name linux
linux/packaging/rpm/build-rpm.sh
flatpak/build-flatpak.sh
snapcraft --destructive-mode

After a successful build, artifacts appear in dist/<pubspec-version>/ (for example dist/1.0.0+1/):

  • pyggo-<version>-linux.deb
  • pyggo-<version>-1.x86_64.rpm
  • pyggo-<version>-linux.AppImage
  • pyggo-<version>-linux.pacman
  • pyggo-<version>-linux.zip
  • pyggo-<version>-linux.flatpak

The .snap file is written to the project root as pyggo_<version>_amd64.snap.

SteamOS / Arch notes

dpkg, rpm, and patchelf are available in the Arch repositories. fastforge is installed with dart pub global activate fastforge.

rpmbuild 4.20+ on Arch changes the build-directory layout in a way that breaks fastforge's built-in RPM maker. The repository includes linux/packaging/rpm/build-rpm.sh, which generates a minimal spec and uses rpmbuild --build-in-place. It also uses a user-local RPM database at ~/.local/var/lib/rpm, so no root access is required.

Testing the artifacts

You can sanity-check the generated packages without doing a system installation.

.deb

rm -rf /tmp/pyggo-deb && mkdir /tmp/pyggo-deb
dpkg-deb -x dist/1.0.0+1/pyggo-1.0.0+1-linux.deb /tmp/pyggo-deb
ls /tmp/pyggo-deb/opt/pyggo
ldd /tmp/pyggo-deb/opt/pyggo/pyggo

.rpm

rm -rf /tmp/pyggo-rpm && mkdir /tmp/pyggo-rpm && cd /tmp/pyggo-rpm
rpm2cpio /path/to/pyggo-1.0.0-1.x86_64.rpm | cpio -idm
ls usr/share/pyggo
ldd usr/share/pyggo/pyggo

.AppImage

./dist/1.0.0+1/pyggo-1.0.0+1-linux.AppImage --appimage-version
rm -rf /tmp/pyggo-appimage && mkdir /tmp/pyggo-appimage
cd /tmp/pyggo-appimage
/path/to/dist/1.0.0+1/pyggo-1.0.0+1-linux.AppImage --appimage-extract
ls squashfs-root

.zip

rm -rf /tmp/pyggo-zip && mkdir /tmp/pyggo-zip
unzip -q dist/1.0.0+1/pyggo-1.0.0+1-linux.zip -d /tmp/pyggo-zip
ls /tmp/pyggo-zip
ldd /tmp/pyggo-zip/pyggo

.pacman

rm -rf /tmp/pyggo-pacman && mkdir /tmp/pyggo-pacman
bsdtar -xf dist/1.0.0+1/pyggo-1.0.0+1-linux.pacman -C /tmp/pyggo-pacman
ls /tmp/pyggo-pacman/opt/pyggo
ldd /tmp/pyggo-pacman/opt/pyggo/pyggo

.flatpak

flatpak install --user --bundle dist/1.0.0+1/pyggo-1.0.0+1-linux.flatpak
flatpak run --command=sh com.pyggo.pyggo -c 'echo ok'
flatpak run com.pyggo.pyggo

Snap and Flatpak in CI

  • .flatpak is built locally by flatpak/build-flatpak.sh and can be uploaded to GitLab releases as a non-Store bundle.
  • .snap is built locally by tool/build.sh linux-snap (or snapcraft --destructive-mode) and is also built in GitLab CI using snap/snapcraft.yaml. It is not published to the Snap Store. The CI job uses the snapcore/snapcraft image and is marked allow_failure because snapd/snapcraft may need a privileged or VM-based runner.
  • .gitlab-ci.yml also runs flutter analyze, flutter test, Fastforge packaging, and the GitLab SAST/Dependency Scanning templates.

Package metadata

  • Binary name: pyggo
  • Application ID: com.pyggo.pyggo
  • Maintainer: Pyggo Team (placeholder; update in linux/packaging/*/make_config.yaml and the metainfo before publishing)
  • Target architecture: x86_64

Building for Android

The Android build produces three APKs: a direct-install phone APK, a Wear OS APK, and an F-Droid phone APK (see The F-Droid flavor below). The Android App Bundle (AAB) is currently disabled because the app is not yet published to the Play Store; it can be re-enabled by uncommenting the android-aab job in distribute_options.yaml.

Local Android build

Make sure fastforge is on your path, then run the wrapper:

dart pub global activate fastforge
tool/build.sh android              # phone APK + F-Droid APK + Wear OS APK
tool/build.sh android-fdroid       # F-Droid APK only
tool/build.sh android-phone        # phone APK only
tool/build.sh wear                 # Wear OS APK only

Add --deploy to install on a configured device after building, or --deployonly to install an existing build:

tool/build.sh --deploy android

You can also build the APK through fastforge directly:

fastforge package --platform android --targets apk

The AAB can be built manually with fastforge package --platform android --targets aab, but it is not part of the release pipeline while the Play Store is not configured.

After a successful build, artifacts appear in dist/<pubspec-version>/:

  • pyggo-phone-<version>-android.apk
  • pyggo-fdroid-<version>-android.apk
  • pyggo-wear-<version>-android.apk

--dev builds write pyggo-dev-<channel>-<version>-android.apk instead.

The F-Droid flavor

The fdroid product flavor is identical to phone except that it builds without the proprietary Google Play services dependency used for Wear OS Data Layer sync (play-services-wearable). F-Droid's inclusion policy rejects non-free dependencies, so the phone/watch sync stack cannot ship in that build. See Build flavors for how the src/gms/src/foss source-set split keeps the difference to one swapped PhoneWearPlugin class.

Build it directly with:

flutter build apk --release --flavor fdroid

All Dart sources are committed, so an F-Droid build recipe only needs flutter pub get and the build command above. The versionCode resolves from git rev-list --count HEAD, which works in F-Droid's full-clone build environment.

Store-listing metadata for F-Droid lives in metadata/en-US/ at the repo root (Fastlane layout: title.txt, short_description.txt, full_description.txt, images/). F-Droid reads it straight from the tagged release commit. changelogs/ is intentionally absent: changelog files are keyed by versionCode, which is the git commit count and not known ahead of time.

To request inclusion, open an issue in the F-Droid RFP tracker (gitlab.com/fdroid/rfp) once a tagged release exists.

Android signing

Release builds can be signed with a Java keystore. The keystore itself is never committed; CI injects it from GitLab variables, and local builds without those variables fall back to the Android debug key.

Generate a keystore once:

tool/generate_android_keystore.sh $HOME/pyggo-release.keystore "YOUR_PASSWORD"

This prints a base64-encoded keystore and the matching CI variables. Add these to your GitLab project under Settings > CI/CD > Variables:

  • ANDROID_KEYSTORE_FILE — base64 output from the script above
  • ANDROID_KEYSTORE_PASSWORD
  • ANDROID_KEY_PASSWORD
  • ANDROID_KEY_ALIAS — pyggo

No Play Store or other store publishing step is included.

Releases and versioning

Pyggo's version has two parts that serve different audiences and must not be confused:

  • Version name — the 0.0.8 in pubspec.yaml's version: 0.0.8+0. This is the human-readable string shown in the app and in package names (pyggo-0.0.8-android.apk). It is the source of truth for Linux packages, Flatpak, Snap, and the GitLab release title.
  • Android versionCode — an opaque integer that Android uses only to decide upgrade vs. downgrade. A new APK whose versionCode is lower than the installed one is rejected with INSTALL_FAILED_VERSION_DOWNGRADE, regardless of the version name. This code must be monotonically increasing across every release.

The pubspec build number (the +N in 0.0.8+0) is not a reliable source for the Android versionCode. It was historically reset to +0 for each new minor version and reused as a CI retry counter (the 0.0.6+0 … 0.0.6+7 series), which broke the monotonic invariant: 0.0.7+0 and 0.0.8+0 both shipped with versionCode 1, lower than the 0.0.6+7 install's versionCode 7, so neither could be installed over 0.0.6+7.

The current scheme derives the versionCode from the total git commit count (git rev-list --count HEAD), which is strictly monotonic and unique per commit and survives rebuilds of the same tag. android/app/build.gradle.kts resolves it in priority order:

  1. PYGGO_VERSION_CODE environment variable — set by the android-package CI job to $(git rev-list --count HEAD) before invoking fastforge.
  2. git rev-list --count HEAD — computed by Gradle for local builds inside a git checkout.
  3. flutter.versionCode — the pubspec build number, as the final fallback for non-git source tarballs.

The android-package job sets GIT_DEPTH: "0" so the clone is full and the commit count is accurate; the default shallow clone would cap the count and reintroduce the bug.

Rules for the pubspec build number going forward:

  • It is a last-resort fallback only. Do not rely on it being monotonic across releases.
  • When bumping the pubspec version for a new release, +0 is fine — CI will override the versionCode. The build number does not need to track the commit count.
  • Never reset it to a value lower than a previously released Android versionCode and rely on it as the code; the commit-count scheme makes this moot, but keep it in mind for non-git builds.

Release tags and CHANGELOG.md headings use the bare version name without a build number (e.g. tag 0.0.9, heading ## 0.0.9). The extract-release-notes CI job does an exact string match between the tag and the changelog heading, so they must agree, but neither needs a +N. The release job strips any +N from the tag before uploading packages (package_version="${version%%+*}"), so bare tags are the clean form. Pubspec keeps +0 because Flutter requires a build number there, but it is a placeholder — it does not need to match the tag.

The release tag flow is unchanged: tag the commit, push the tag, CI builds and creates the GitLab release. The versionCode is whatever the commit count is at that tag (e.g. ~113 at the 0.0.8 tag), which is well above the old 0.0.6+7 ceiling of 7.

Installing with Obtainium

Obtainium can install and auto-update the Android APKs straight from the GitLab releases: the release job attaches each artifact as a release link pointing at the Generic Package Registry, which Obtainium follows. Users add the app with the repository URL https://gitlab.com/johnkneevsource/pyggo.

Each release carries three APKs, so users should set Obtainium's "filter APKs by regular expression" option to pick the right one:

  • phone/tablet: phone-.*\.apk
  • degoogled devices (no Play services): fdroid-.*\.apk
  • Wear OS: wear-.*\.apk

The phone and F-Droid builds share the same applicationId, so they cannot be installed side by side; switching between them requires an uninstall (data is not migrated). Updates only work while the signing key stays the same, which holds for the CI-built APKs.

macOS and Windows code signing

This section lists what is required to sign and notarize macOS .dmg/.pkg and Windows .exe/.msix installers. The actual signing scripts (tool/sign-macos-app.sh and tool/sign-windows-file.ps1) are not created in this pass; they should be added when the certificates are available.

macOS signing

What you need for macOS

  • Apple Developer account ($99/year).
  • Developer ID Application certificate for .app and .dmg signing.
  • Developer ID Installer certificate if you package a .pkg.
  • Apple Team ID.
  • App-specific password for notarization (created at appleid.apple.com).
  • A macos/Runner/Release.entitlements file if the app needs sandbox or capability entitlements.

macOS CI variables

Variable Purpose
APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 Base64-encoded .p12 application certificate.
APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD Password for the .p12 file.
APPLE_KEYCHAIN_PASSWORD Temporary keychain password for the CI job.
APPLE_DEVELOPER_ID_APPLICATION_IDENTITY Certificate name/identity used by codesign.
APPLE_TEAM_ID Apple Developer team identifier.
APPLE_ID Apple ID used for notarization.
APPLE_APP_SPECIFIC_PASSWORD App-specific password for notarytool.

macOS CI steps

  1. Import the .p12 into a temporary keychain on the macOS runner.
  2. Run flutter build macos --release.
  3. Sign the pyggo.app bundle:
codesign --deep --force --verify --verbose \
  --sign "$APPLE_DEVELOPER_ID_APPLICATION_IDENTITY" \
  --entitlements macos/Runner/Release.entitlements \
  "$BUILD_OUTPUT_DIRECTORY/pyggo.app"
  1. Package the DMG or PKG with fastforge, using a pre-package hook if the .app must be signed before packaging:
package:
  platform: macos
  target: dmg
  hooks:
    pre: tool/sign-macos-app.sh
  1. Submit the resulting .dmg or .pkg to Apple for notarization:
xcrun notarytool submit "$DMG_PATH" \
  --apple-id "$APPLE_ID" \
  --password "$APPLE_APP_SPECIFIC_PASSWORD" \
  --team-id "$APPLE_TEAM_ID" \
  --wait
xcrun stapler staple "$DMG_PATH"

Windows signing

What you need for Windows

  • A code signing certificate. Choose one of:
  • Self-signed certificate — free, for local testing only; users must trust the cert manually.
  • Azure Artifact Signing (formerly Trusted Signing) — Microsoft’s managed service, ~$10/month, recommended for production MSIX/EXE distribution.
  • OV code signing certificate from a trusted CA — $300–$500/year, alternative production option.
  • Microsoft Store — free signing on submission if you publish through the Store.
  • SignTool.exe from the Windows SDK, or WinApp CLI for development self-signing.

Windows CI variables

Variable Purpose
WINDOWS_CERTIFICATE_BASE64 Base64-encoded .pfx certificate (for self-signed or OV).
WINDOWS_CERTIFICATE_PASSWORD Password for the .pfx file.
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET Only if using Azure Artifact Signing.

Windows CI steps

  1. Build flutter build windows --release.
  2. For .exe installers, sign pyggo.exe before packaging using a fastforge pre-package hook:
package:
  platform: windows
  target: exe
  hooks:
    pre: tool/sign-windows-file.ps1
  1. For .msix, sign the produced .msix package after packaging using a post-package hook:
package:
  platform: windows
  target: msix
  hooks:
    post: tool/sign-windows-file.ps1
  1. Example SignTool invocation (the script needs the actual artifact path):
signtool sign /f "$env:CERTIFICATE_PATH" `
  /p "$env:WINDOWS_CERTIFICATE_PASSWORD" `
  /tr http://timestamp.digicert.com `
  /td SHA256 /fd SHA256 `
  "$env:ARTIFACT_PATH"

Linux and Android note

  • Linux packages (.deb, .rpm, .AppImage, .pacman, .flatpak, .snap) do not require platform code signing in the same way; only the Flatpak and Snap stores have their own signatures at publish time.
  • Android release signing is already documented in the existing section.

Adding a platform

Run flutter create . to regenerate platform-specific directories if needed. The project was scaffolded with android, ios, linux, macos, and windows.

GitLab CI and runner setup

Pyggo uses .gitlab-ci.yml for continuous integration and packaging. The pipeline is designed for a GitLab Runner that uses the docker executor on an x86_64 Linux host.

What the CI pipeline does

The pipeline in .gitlab-ci.yml has six stages:

  • build-image — builds and pushes a custom pyggo/builder Docker image to the project's GitLab Container Registry when the image definition changes, a tag is pushed, or the job is run manually.
  • test — runs flutter analyze, flutter test, and the GitLab SAST/dependency/secret detection templates.
  • secret-detection — runs the GitLab secret-detection template.
  • package — builds the Linux release bundle (.deb, .rpm, .AppImage, .pacman, .zip, .flatpak) and the Android release APKs. The AAB is currently disabled because the app is not yet published to the Play Store. The .snap job depends on the linux-package artifacts and is marked allow_failure: true.
  • release — creates the GitLab release for a tag and links the packaged artifacts.
  • deploy — publishes the docs/ tree as the project website on GitLab Pages (see The website below).

The dependency-scanning template excludes the gemnasium-maven analyzer (DS_EXCLUDED_ANALYZERS in .gitlab-ci.yml): it detects android/*.gradle.kts and tries to resolve the Gradle project, which needs the Flutter and Android SDKs the analyzer image does not carry, so it can only fail. The app's real dependency surface is pubspec.lock, which the generic gemnasium analyzer still scans.

All Pyggo jobs are tagged with docker:

tags:
  - docker

The android-package, flutter, and linux-package jobs use a custom pyggo/builder image published to the project's GitLab Container Registry ($CI_REGISTRY_IMAGE/builder:$FLUTTER_VERSION). The image is defined in ci/Dockerfile, which builds on the ghcr.io/adrianjagielak/flutter:${FLUTTER_VERSION} base image and adds the Linux desktop build dependencies, all packaging tools, the headless OpenJDK for Android, and appimagetool. The snap-package job uses snapcore/snapcraft:stable and runs snapcraft --destructive-mode, which often needs a privileged or VM-based runner.

Builder image

The build-image job creates the pyggo/builder image with a plain Docker build and push. It uses Docker-in-Docker with a docker:24.0.5 client and docker:24.0.5-dind service. The Flatpak Freedesktop runtime is not baked into the image; it is installed at linux-package job time to keep the image small.

To build the image manually from a local checkout, trigger the build-image job in the GitLab pipeline. It runs on the default branch when ci/Dockerfile changes, on every tag, or when the job is run manually.

Runner requirements

  • Host OS: 64-bit Linux (x86_64). The build, packaging, and snap/flatpak jobs assume an amd64 environment.
  • Docker: Docker Engine installed and running.
  • Resources: At least 4 GB RAM (8 GB+ recommended) and ~20 GB free disk for Flutter builds, packaging, and Flatpak runtimes.
  • Network: The runner must reach ghcr.io, flathub.org, and your GitLab instance.
  • Tags: The runner must provide the docker tag.
  • Privileged containers (required for packaging): The linux-package and snap-package jobs run flatpak-builder and snapcraft, which need a privileged container. If the runner is not privileged, flatpak install will log bwrap: Creating new namespace failed and the flatpak or snap build can fail. See the security note below before enabling this.

Installing GitLab Runner

On Debian/Ubuntu:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner

On Arch / SteamOS, install the gitlab-runner package from the AUR:

yay -S gitlab-runner

You can also download the static Linux binary from the GitLab Runner releases and place it in /usr/local/bin/gitlab-runner.

Then start the service:

sudo systemctl enable --now gitlab-runner

The gitlab-runner user must be able to talk to the Docker daemon. Add it to the docker group and restart the service:

sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner

If you are using rootless Docker, run the runner as the user that owns the Docker socket instead.

Registering a Docker runner

  1. In GitLab, open Settings > CI/CD > Runners (or Admin > Runners on a self-managed instance) and copy the runner authentication token (called a registration token on older GitLab versions).
  2. On the runner host, run:
sudo gitlab-runner register \
  --non-interactive \
  --url https://gitlab.com \
  --token <RUNNER_TOKEN> \
  --description "pyggo docker runner" \
  --executor docker \
  --docker-image docker:24.0.5 \
  --tag-list "docker" \
  --run-untagged="false" \
  --docker-privileged \
  --docker-volumes "/cache" \
  --docker-pull-policy="if-not-present"

Replace https://gitlab.com with your GitLab URL and <RUNNER_TOKEN> with the token from GitLab.

This creates a docker executor runner that picks up only jobs tagged with docker. --docker-privileged is required for the snap-package job and may be needed by the Flatpak build in linux-package.

If you want jobs to be able to run docker commands inside the container (not required for Pyggo today), add the Docker socket as an extra volume:

--docker-volumes "/var/run/docker.sock:/var/run/docker.sock" --docker-volumes "/cache"

Example config.toml

Registration writes to /etc/gitlab-runner/config.toml. A typical Pyggo runner looks like:

concurrent = 2
check_interval = 0
shutdown_timeout = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "pyggo docker runner"
  url = "https://gitlab.com"
  token = "YOUR_RUNNER_TOKEN"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    MaxUploadedArchiveSize = 0
  [runners.docker]
    tls_verify = false
    image = "docker:24.0.5"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache"]
    pull_policy = ["if-not-present"]
    shm_size = 0
    network_mtu = 0

Restart the runner after editing:

sudo gitlab-runner restart
sudo gitlab-runner verify

Enabling snap builds

The snap-package job runs snapcraft --destructive-mode inside the snapcore/snapcraft image. Snap confinement usually requires a privileged container or a VM-based runner.

If you do not want to run a privileged Docker runner:

  • The snap-package job is already set to allow_failure: true, so it will not block the pipeline.
  • Alternatively, set up a separate shell or virtual machine executor on a host with snapd and snapcraft installed, tag it snap, and change the snap-package job's tags to snap (or add a rules: block so it only runs on that runner).

Caching and performance

The android-package, flutter, and linux-package jobs run in the prebuilt pyggo/builder image, so per-pipeline apt-get install and appimagetool downloads are eliminated. Flutter and Android build caches are preserved between runs with GitLab cache: sections.

To speed things up further:

docker pull ghcr.io/adrianjagielak/flutter:3.47.4
docker pull $CI_REGISTRY_IMAGE/builder:3.47.4
docker pull snapcore/snapcraft:stable
  • Use a shared cache directory or an S3-backed cache in config.toml.

The build-image job uses Docker-in-Docker and only runs when the image definition changes, on tags, or when manually triggered, so the builder image is not rebuilt every pipeline. The Flatpak runtime is still installed per linux-package run, but apt-get and appimagetool setup are gone.

Security note

privileged = true gives build containers full access to the host. Only enable this for private runners on isolated, trusted hosts. If the .snap package is not required, run a non-privileged runner and keep snap-package set to allow_failure: true, or move snap builds to a separate, locked-down runner.

Troubleshooting

  • Jobs are stuck "waiting for runner": The runner tag does not match. Make sure the runner is registered with --tag-list "docker" and that the branch is not protected-only if your runner is restricted to protected branches.
  • flatpak-builder fails with bwrap / namespace errors: The linux-package and snap-package jobs need a privileged container or a kernel with unprivileged user namespaces enabled (sudo sysctl kernel.unprivileged_userns_clone=1 on some distros).
  • snapcraft fails inside Docker: Use a privileged runner or a VM/shell executor. The snap-package job is allow_failure: true and will not fail the whole pipeline.
  • Permission denied pulling images: Make sure the gitlab-runner user can reach the Docker daemon, or run Docker in rootless mode with the correct user.
  • build-image fails with "Cannot connect to the Docker daemon": Make sure the runner is privileged and the docker:24.0.5-dind service started. You can also bind-mount /var/run/docker.sock into the runner instead of using Docker-in-Docker.
  • Out of disk or memory: Flutter builds plus Flatpak runtimes can consume several gigabytes. Ensure the runner has at least 20 GB of free disk and 4–8 GB of RAM.

The website (GitLab Pages)

The project publishes a static website built from docs/ itself. mkdocs.yml configures mkdocs-material: docs/README.md is the homepage (it still reads as a normal readme when browsing the repo), and every docs file is listed under nav: — mkdocs build --strict fails on a missing nav entry or a broken link, so add new docs files to nav when you create them. The doc_audit_report_*.md files and the historical docs/design/ notes are kept for reference; they are excluded via exclude_docs rather than published.

The pages job runs automatically on release tags — so the published docs track the released app — and appears as a manual button on main pipelines for shipping doc fixes between releases. It builds public/ with mkdocs build --strict and uploads it as an artifact; gitlab.com deploys any public artifact produced by a job named pages. The job clears the squidfunk/mkdocs-material image's mkdocs entrypoint (entrypoint: [""]) — without that, the runner's sh -c wrapper is passed to mkdocs as arguments and the job fails with "No such command 'sh'". dependencies: [] keeps the runner from downloading earlier stages' artifacts the build does not use. The site logo (docs/assets/pyggo_logo_200.png, shared as the favicon) is generated by tool/generate.dart logo but committed to the repo, so the job needs no generation step — which is just as well, since the image has no Dart SDK.

The site is served at https://pyggo.johnkneev.dev, a custom domain mapped under Deploy > Pages (CNAME pyggo -> johnkneevsource.gitlab.io plus GitLab's verification TXT record; the Let's Encrypt certificate is automatic). The default *.gitlab.io Pages domain keeps serving the same content alongside it.

To preview or check the site locally:

python3 -m venv .venv-mkdocs
.venv-mkdocs/bin/pip install mkdocs-material==9.7.7
.venv-mkdocs/bin/mkdocs build --strict   # or: .venv-mkdocs/bin/mkdocs serve

Keep the pip pin and the squidfunk/mkdocs-material image tag in the pages job on the same version.

One constraint when writing docs: docs/ also renders as plain markdown on the GitLab repo pages, so keep to mostly plain CommonMark. The enabled mkdocs extensions (!!! note admonitions, { ... } attribute lists, fenced details/superfences) may be used sparingly, but only where GitLab's own rendering stays readable.

Self-hosted Windows and macOS runners

If GitLab-hosted runners are not available for your tier or you want more control, you can run gitlab-runner on your own Windows or macOS hardware.

Self-hosted macOS

  1. Install the gitlab-runner binary:
sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/darwin-arm64
sudo chmod +x /usr/local/bin/gitlab-runner
  1. Install Xcode and CocoaPods.
  2. Install Flutter and add flutter/bin to the runner environment PATH.
  3. Register the runner:
sudo gitlab-runner register \
  --url https://gitlab.com \
  --token <project-runner-token> \
  --executor shell \
  --shell bash \
  --tag-list macos,pyggo \
  --description "pyggo macOS runner"
  1. Start the runner:
sudo gitlab-runner run

Self-hosted Windows

  1. Download and install gitlab-runner.exe:
New-Item -ItemType Directory -Force -Path C:\gitlab-runner
Invoke-WebRequest -Uri https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe -OutFile C:\gitlab-runner\gitlab-runner.exe
  1. Install Git for Windows and the Flutter SDK.
  2. Add C:\flutter\bin and C:\Program Files\Git\bin to the system PATH.
  3. Register the runner:
C:\gitlab-runner\gitlab-runner.exe register `
  --url https://gitlab.com `
  --token <project-runner-token> `
  --executor shell `
  --shell powershell `
  --tag-list windows,pyggo `
  --description "pyggo Windows runner"
  1. Install and start the service:
C:\gitlab-runner\gitlab-runner.exe install
C:\gitlab-runner\gitlab-runner.exe start

CI usage

Jobs target the self-hosted runners by tag instead of the SaaS tags:

macos-package:
  stage: package
  tags:
    - macos
  # ... same build steps as the Linux/macOS plan

windows-package:
  stage: package
  tags:
    - windows
  # ... same build steps as the hosted Windows job

Notes

  • Code signing: macOS dmg/pkg and Windows msix/exe need certificates. The CI variables and fastforge build_args for signing should be added separately.
  • SaaS macOS: Keep the SaaS macOS instructions documented but do not add a saas-macos-medium-m1 job to .gitlab-ci.yml until the GitLab for Open Source program is approved.
  • Flutter version pin: Make sure the self-hosted machines have the same Flutter version as FLUTTER_VERSION in .gitlab-ci.yml, or clone the Flutter SDK in the job as the hosted Windows job does.
  • PowerShell vs. Bash: Decide on one Windows shell per job and document the choice; mixing syntax is the most common source of Windows CI failures.

Asset tooling

Icons, logos, and splash screens are all generated assets; edit the source, not the output.

Generator tool

tool/generate.dart is a Dart command-line tool that replaces the previous Python tool scripts. It uses package:args for the CLI and package:image for native image manipulation, with no ImageMagick dependency.

Available subcommands:

  • all — Generate both Material and Iconoir icon maps (material followed by iconoir).
  • app — Generate launcher icons from a source PNG, including the inverted ic_launcher_dev variants used by tool/build.sh --dev builds.
  • logo — Generate brand logos (assets/brand/app_logo.png, assets/brand/android12_splash.png, docs/assets/pyggo_logo.png, and docs/assets/pyggo_logo_200.png) from a source PNG.
  • material — Generate lib/data/material_icons.g.dart.
  • iconoir — Generate lib/data/iconoir_icons.g.dart and lib/data/all_icons.g.dart.

Brand logos

Regenerate the project's brand logos from a single source PNG using the logo subcommand:

dart tool/generate.dart logo /path/to/source_logo.png
dart tool/generate.dart logo -o /path/to/output_dir /path/to/source_logo.png

This writes:

  • assets/brand/app_logo.png (max 1024 px)
  • assets/brand/android12_splash.png (960 x 960)
  • docs/assets/pyggo_logo.png (max 640 px; the large centered logo on the website home page)
  • docs/assets/pyggo_logo_200.png (max 200 px)

App icons

Regenerate launcher icons from a single source image using the app subcommand of tool/generate.dart:

dart tool/generate.dart app /path/to/source_icon.png
dart tool/generate.dart app -o /path/to/output_dir /path/to/source_icon.png

In addition to the platform icons, app writes an inverted copy of the source as ic_launcher_dev.png into each android/app/src/main/res/mipmap-* directory. --dev builds select it through the appIcon manifest placeholder, so dev installs are visually distinct on the launcher.

tool/generate.dart is a standalone Dart script; it uses the image package for native image manipulation and no longer requires ImageMagick.

Splash screens

Splash screens are generated from pubspec.yaml using flutter_native_splash:

dart run flutter_native_splash:create

Update the flutter_native_splash configuration block in pubspec.yaml to change the logo (assets/brand/app_logo.png) or background colors.