Frontend¶
How the Flutter app in frontend/ is built and why. For contributors changing
the app; no prior knowledge of the code is assumed. To run it, see
Running and testing. To ship it, see
Distribution.
Overview¶
frontend/ is a Flutter app that talks to the backend’s
REST API. One codebase targets web, Android and Linux desktop.
Stack choices:
No code generation. No
build_runner, nofreezed. Models are hand-written classes withfromJson/toJson. Changing a model’s shape means editing one file and regenerating nothing.Plain Riverpod for state (
Provider,StateNotifierProvider,FutureProvider), not the@riverpodgenerator. A provider is a global, lazily built value that widgets watch and rebuild on.
frontend/lib/
core/ API client (Dio + auth interceptor), auth state and storage,
routing, theme
models/ Request/response types, one file per resource
features/ One directory per area (exercises, routines, activities, calendar,
profile, measurements, auth, home, onboarding, ...). Each has a
repository (wraps Dio), Riverpod providers, and screens.
Vocabulary used below:
Activity: a logged workout. Routine: a saved workout template. Planned workout: a routine or workout scheduled for a date. See Domain model.
Live workout: the in-progress workout screen, before it is saved as an activity.
Superset: exercises done back-to-back.
Authentication¶
Mirrors the backend (Authentication):
The session token is stored with
flutter_secure_storage, not a cookie.There is no refresh token. A 401 from any request (caught by a Dio interceptor) clears the token and routes to
/login. A 401 always means “log in again”.go_routerredirects on the auth state held byAuthNotifier(core/auth/auth_notifier.dart), not on what a screen believes.Logout calls
POST /auth/logout, then clears the local token even if the call failed. An unreachable backend must not leave the person signed in.
Server URL¶
The backend URL is not only a compile-time constant (--dart-define=API_BASE_URL,
see core/api_config.dart). It is also stored on the device
(core/server_url_provider.dart, separate key from the token), so one released
binary can point at anyone’s self-hosted server.
Editable on the login screen. It is committed just before the login/register call, so that call goes to what the field shows now.
Editable in Settings, which logs out first: a token from one server is meaningless on another.
User-facing description: Getting started.
Gotcha: changing the URL rebuilds AuthNotifier (it watches dioProvider,
which watches the URL) while a login/logout may be in flight on the new
instance. Every state transition in AuthNotifier._bootstrap is therefore
guarded by state is AuthUnknown. Without the guard, whichever call finished
last would win.
Secure storage can throw¶
On Linux, flutter_secure_storage uses the system keyring (libsecret). If it
is not running or unlocked (common outside a full GNOME/KDE session), every
call throws PlatformException instead of returning null.
TokenStorage,ServerUrlStorageandInsecureTlsStorage(all infrontend/lib/core/) catch it at every call and treat it as “nothing stored” or “could not persist”. Letting it escapemain()’s startup read crashed the app before any window appeared. Do not remove these catches.All three depend on a small
SecureStoreinterface (core/secure_store.dart) so tests can inject a fake.flutter testhas no platform channel.
Insecure-TLS toggle¶
Next to the server URL (login and Settings) is a per-server switch that skips TLS certificate verification. It exists for homelab servers behind a reverse proxy with a self-signed certificate.
Off by default. It is a real security reduction: any network path can impersonate the server.
Code:
core/insecure_tls_provider.dart, applied to Dio bycore/insecure_tls_configurator.dart.Android and Linux only. A browser owns its TLS handshake, so on web the conditional export (
dart.library.io) selectsinsecure_tls_configurator_stub.dart, a deliberate no-op.When an operator should enable it: Security and networking.
CORS on the web target¶
A browser blocks cross-origin requests unless the server allows them, and the
web build’s origin is rarely the backend’s. The backend therefore installs
CORSMiddleware (backend/src/dinatos_backend/main.py), configured by
cors_allowed_origins (Security and networking).
Android and Linux are unaffected.
Neither flutter analyze nor flutter test can catch a CORS failure, because
no browser is involved. The symptom is net::ERR_FAILED or “could not reach
the server”. Verify web API changes by loading the built app in a browser. The
/docs page (API documentation) is the same
kind of browser-only problem.
First-run tour¶
features/onboarding/ guides a new account through setting units, saving a
first routine and logging a first workout. It dims the screen except the real
control to press next.
It is an overlay, not a screen. OnboardingOverlay is MaterialApp.router’s
builder, so it sits above every route and screens need not know it exists.
The only exception is wrapping the widgets it points at in OnboardingTarget,
which draws nothing and only reports its position.
Design rules:
Steps complete by what the app did, not what the user pressed. A step advances when the router reaches a path (
advanceOnLocation) or the app reports an event (advanceOnEvent, e.g. profile saved). Never use a pointer listener on the highlighted widget: assistive technology and browser tests activate controls with a semantic tap, which pointer listeners never see, and the tour would hang for exactly the people who need it.Only single-press steps are modal. The overlay sits above the app’s own
Navigator, so dropdowns and bottom sheets (the exercise picker) render beneath the dimming. Steps that need typing or picking leave the app usable, draw only a ring, and offer “Skip step”.A missing target never traps the user. A modal step blocks input only once its target is actually visible (not in an inactive tab kept alive by an
IndexedStack). Otherwise the card is docked with no dimming. “Skip step” and “Skip tour” always work.
Other facts:
Completion is stored per account in
shared_preferences(onboarding_seen_<user id>). Settings has “Take the tour again”.Steps are data in
onboarding_steps.dart. To add or reorder one, edit that list and add anOnboardingTargetwhere it points.e2e/drives the tour in a real browser: CI and releases.
Supersets¶
Exercises sharing a supersetGroup number are done back-to-back. A group only
makes sense as a run of adjacent exercises, so every edit (link, unlink,
move, remove) goes through models/superset.dart, which re-normalizes the
list:
every run of neighbours sharing a group has at least two members;
runs are numbered 1, 2, … in order.
This also repairs messy numbers from a Hevy import or a restored backup the first time the list is edited. The helpers take an accessor, so live workouts, the manual activity form and routines share one implementation and one test suite.
Rows in these lists carry a uid, a process-local id (models/uid.dart, never
sent to the server) used as the widget key. Without it, removing or moving a
set leaves a neighbour’s TextFormField showing stale text, because
initialValue is read only once. The number fields also resync their text when
the model changes from outside, e.g. a suggestion being filled in.
Live workout¶
A live workout is held in liveActivityProvider and written to disk on every
change (activities/live/live_session_storage.dart). Killing the app loses
nothing, and logging sets never needs the server.
Server-backed extras are fetched in the background and are simply absent on failure: “last time” hints, suggestions, trophies and the record check. A missing hint never blocks a set.
Saving offline¶
Saving is the one step that needs the server. The summary screen
(activity_summary_screen.dart) is built around it failing:
A failed save keeps the session on screen and on disk, with a Save again button. A finished-but-unsaved workout shows a banner on every tab (
features/home/live_workout_banner.dart).No HTTP status means no response (dropped connection, timeout), so the server may have stored the workout. The backend recognises a retry by title, start and end (Retried saves). After such a failure the session records
pendingTitleand locks the title field to it, because the title is the one thing the user could otherwise change between attempts.pendingTitleis persisted, and cleared by any definite server answer (success, or an error with a status).The record check also needs the server. On failure the summary says it could not check (not the same as “no new records”) and offers a retry, only before saving. After saving, the server’s “best” already includes this workout.
Tests: test/features/activities/live/activity_summary_screen_test.dart (mocked
Dio) and the storage tests (real SharedPreferences kill-and-restore).
Finishing drops what was not done¶
LiveActivityNotifier.finish() removes exercises with no completed set, and
removes never-ticked sets from the others. Only ticked sets count anywhere
(records, history, totals), so saving an untouched planned set would record
work that did not happen.
LiveActivitySession.unfinishedcounts what would be dropped;finishedExercisesis the result.Exercises go through the same
removeItempath as the remove button, so a superset left with one member is dissolved._confirmFinishasks first whenever something would be lost. “Keep logging” leaves the session untouched.
Past sessions: hints, suggestions, plateaus, records¶
GET /exercises/{id}/history feeds three features, computed client-side in
pure functions (features/progress/progression.dart) so they are unit-tested
without widgets:
Last time: the previous session’s sets, shown as field hints.
Suggestion: a simple double progression based on the last session.
Every working set at the top weight got the same reps: add weight (2.5 kg, or 1 kg below 20 kg) and aim for those reps.
Otherwise: keep the weight and aim for the best set’s reps on every set.
Body-weight exercise: one more rep.
Warm-ups never count as a baseline. Drop and failure sets do not either, unless nothing else exists.
Tapping Use fills only values still empty.
Plateau: the best session (by estimated one-rep max, or best reps when there is no load) is at least four sessions behind the latest. Beating the best by under 0.5% is noise, not progress.
Personal records (models/exercise_records.dart, from
GET /exercises/{id}/records) follow the same rules everywhere:
A set counts only if it beats the prior best and every earlier set in the same workout.
Warm-ups never count.
Reps count only for a set with no load.
The live screen flags only exercises that have history. The summary screen counts a first-ever set as a record.
Adding several exercises from a typed list¶
Add several on the live screen opens bulk_add_sheet.dart. The user types
“1 reverse lunges 2 bench press 3 pull ups”, reviews the matches, and adds them
all (without sets, as with a single Add exercise). The three steps stay on
screen and each row says how sure the match is, because a free-text box with a
hidden format is where people give up. Nothing is added until the button is
pressed.
Text only, fully on device. The exercise catalog is already in memory, so there is no endpoint. Dictation is the keyboard’s job; the app has no speech recognition.
Splitting (
splitExerciseListinexercise_text_matcher.dart): numbers, as digits or words, are the separator, because dictation gives no punctuation. Only the next expected number splits, so “one arm dumbbell row” stays whole. A “1” opening a new line starts a new run, which is how a second dictation appends. Without a leading “1” it falls back to new lines, commas, and “and”/”then”/”next”.Matching (
ExerciseMatcher): ignores word order, case, plurals and punctuation; expandsdb/bb; treats “pull ups” as “Pullup”; forgives a one-letter slip in a long word. Ties go to the exercise logged most.Result
Meaning
Ticked by default
Matched
Whole name matches, no extra words
yes
Best guess
All words appear in a longer name
yes
Not sure
Some words match
no
No match
None match
no
Unticked by default because a wrong exercise mid-workout costs more than one extra tap.
Tests:
test/features/exercise_text_matcher_test.dartandtest/features/activities/live/bulk_add_sheet_test.dart.
Planned workouts and reminders¶
A planned workout (lib/features/calendar/) is stored by the backend, not
client state: see “Calendar feed” in Backend and
/planned-workouts. Client-side points:
Reminders are local notifications, not push.
workoutReminderSyncProvider(watched from the app root) reschedules the next 50 reminders wheneverplannedWorkoutListProviderchanges, and clears them on logout. It does not clear them while auth is stillAuthUnknownor when the fetch fails, so starting offline never cancels tomorrow’s reminder.Android uses
AndroidScheduleMode.inexactAllowWhileIdle: a reminder may be a few minutes late in doze, but no exact-alarm permission is needed (Play restricts it).The time is an absolute instant passed as UTC, so no timezone database is needed.
AndroidManifest.xmlmust declare the plugin’s two receivers andRECEIVE_BOOT_COMPLETED. Without them a scheduled notification never shows.
One tap callback, two services.
flutter_local_notificationskeeps a singleonDidReceiveNotificationResponse, and a secondinitialize()call silently replaces the first. Both the live-workout notification and the reminders therefore passdispatchNotificationTap(core/notification_taps.dart) and register a listener for their own payloads (the live one has none; a reminder’s isplanned:<id>).A tap opens the route
/activities/plan/<id>/start, not a callback into a widget: the notification may cold-start the app, and a route survives the login redirect.StartPlannedWorkoutScreenloads the plan and its routine, then starts the live workout.startPlannedWorkoutis the shared entry point, also used by the Home card, the day sheet and the third option of the Start activity sheet.The live session carries
plannedWorkoutId(persisted with it) and the summary sends it withPOST /activities. The plan’s title, not the routine’s name, becomes the default activity title.The Start activity sheet reads the plans Home already loaded (
valueOrNull) rather than waiting on the network.
“Which athlete do you resemble?”¶
features/personas/ compares the user with hand-picked archetypes (sprinter,
distance runner, weightlifter, powerlifter, bodybuilder, gymnast) and draws two
radar-chart shapes: body closeness and training closeness. It has no endpoint.
It reads measurements, activities, the exercise catalog and profile height the
app already loads. The numbers in persona_profiles.dart are rough on purpose:
this is for fun and explainable, not sports science, and the page says so.
Body (
computeBodyFeatures):Uses the latest value of each measurement field, not just the newest entry, because people log partial sets.
Converts them to ratios (FFMI, body fat, waist and thigh/arm over height, shoulders over waist) so short and tall people compare alike.
Each feature scores a bell curve around the archetype’s typical value (one tolerance away is about 60%). The body score is the weighted mean, with height weighted half.
Fewer than three known features gives no score. The page lists which inputs would unlock more.
Smart-scale data adds a muscle index (muscle mass over height squared) and the lower-body muscle share (leg over arm-plus-leg segmental muscle). Scales define “muscle” differently, so the muscle-index targets are approximate.
Training (
classifySet):Every non-warm-up set goes into exactly one of five focuses: max strength, muscle building, explosive, endurance, bodyweight & skill. Inputs are weight, reps, distance, time and exercise name.
A cardio set counts one set-equivalent per three minutes, otherwise an hour of running would weigh the same as one bench set.
Score = 1 minus the total variation distance between the user’s mix and the archetype’s, i.e. how much they overlap. Under ten set-equivalents gives no score.
Known limitation: reference bodies are generic adult-male proportions, because the app does not know sex. The page warns about it. The fix is a profile field plus per-sex targets.