ADR — gym-app unified Profile screen + auto-seed public profile
ADR — gym-app unified Profile screen + auto-seed public profile
Section titled “ADR — gym-app unified Profile screen + auto-seed public profile”Spec: specs/gym-app-unified-profile-screen.md
ClickUp: 869dk67py
Status: Accepted
Date: 2026-06-06
Context
Section titled “Context”The gym_app profile menu exposes two edit-profile entry points side by side:
- «Edit profile» opens
EditProfileModal— editsusers.full_name,user_profile.language,user_profile.birth_dateviaPATCH /api/client/me. - «Edit public profile» opens
EditPublicProfileScreen— editsusers.full_name(again) + bio / specializations / links / slug / coverPhotoUrl viaPATCH /api/client/me/public-profile.
The legacy modal predates the public-profile model introduced by ADR
global-user-identity. They overlap on globalName and ship side by
side because the public-profile screen doesn’t (yet) carry language or
birth-date. The result: confusing menu, no obvious way to close the
public-profile screen (no leading X in its AppBar), and brand-new
users see a fully empty form on first open because
user_public_profile is lazily created only on first PATCH.
The intent of the parent ticket 869dee6n5 (“Сделать профиль бизнес
юзера общим для всех, так же и профиль клиентского юзера”) is one
identity per global user — across surfaces, across companies, scope-
agnostic. The split-screen UX undermines that intent.
Decision
Section titled “Decision”D1. Collapse onto EditPublicProfileScreen. Rename to “Profile”.
Section titled “D1. Collapse onto EditPublicProfileScreen. Rename to “Profile”.”The gym_app _profileConfig in app_router.dart drops the legacy
id: 'edit' menu item. The remaining id: 'edit_public_profile' item
becomes the sole identity entry, relabelled from
profile.public#edit_title to profile#edit_title (the now-vacant
simpler key) and re-iconed from Icons.public to Icons.person_outline.
One menu entry, “Профиль” / “Profile” / “Profil” per locale.
EditProfileModal widget is not deleted — it stays in
packages/profile and remains exported. gym_app simply no longer
routes to it. Deletion is deferred until tickets_app confirms it
doesn’t need the legacy modal either.
D2. Fold language + birthDate into the public-profile screen.
Section titled “D2. Fold language + birthDate into the public-profile screen.”Naively removing the legacy modal would regress those two fields
(SettingsModal only handles notification prefs). Two new form blocks
land on EditPublicProfileScreen:
- Language dropdown — same
_supportedLocales = ['en', 'uk', 'ru', 'de', 'fr']as the legacy modal. On save:context.setLocale(...)fires immediately, matching the modal’s existing side effect. - Birth-date picker —
showDatePicker, ISO-yyyy-MM-ddstored.
Both seed from AuthService.profile in initState (tolerating
absence for widget tests).
D3. Two PATCH calls on save, no contract change.
Section titled “D3. Two PATCH calls on save, no contract change.”Save sequence:
PATCH /api/client/mewith{ name, language, birthDate }viaAuthService.updateMe. If 4xx → inline error, step 2 is NOT issued.PATCH /api/client/me/public-profilewith{ bio, specializations, links, slug }— existing payload, minusglobalName.
Why two PATCHes and not extend the DTO: UpdateMyPublicProfileDto
already accepts globalName for backwards-compat (other consumers may
still send it), so we don’t break anything by sending it via /me
instead — we just stop the redundant double-write into the same
users.full_name column. The canonical owner of users.full_name is
/me; /me/public-profile was always a side-door. No contract change,
no client regeneration, no backend migration.
If step 2 fails after step 1 succeeds, a snackbar reports partial save and the screen stays mounted with the unsaved public-profile fields intact for retry. Two endpoints = two transactional units. Acceptable for v1; if observed in the wild, we revisit.
D4. Close (X) button with discard-confirmation when dirty.
Section titled “D4. Close (X) button with discard-confirmation when dirty.”Add AppBar.leading: IconButton(icon: Icons.close). The form tracks a
_dirty bool flipped on every input change. On close-tap:
- Pristine form → silent close (
router.pop(), falling back torouter.go('/home/profile')when the stack is empty). - Dirty form → AlertDialog (“Discard changes?” / “Keep editing”).
Four new i18n keys per locale, mirroring the existing
profile.public#… namespace.
D5. Backend auto-seeds user_public_profile for every first-time user, both scopes.
Section titled “D5. Backend auto-seeds user_public_profile for every first-time user, both scopes.”In AuthSyncService.validateWithScope, after the users INSERT in
the !existingUser branch, attempt:
await db.insert(userPublicProfiles).values({ userId: supabaseId });— for both client and business. ADR global-user-identity D1
already models user_public_profile as 1:1 with users, no scope
column. One human, one public profile. Seeding for both scopes:
- removes the controller’s missing-row-tolerance dependency from being load-bearing;
- matches the intent of the parent ticket;
- future-proofs the business UI when it gets public-profile editing
(parent ticket
869dee6n5inreview).
Race tolerance: a unique-violation from parallel first-time requests
is logged at warn level and swallowed — auth must succeed even if the
seed loses the race. The wrapping users INSERT remains the source
of truth for “first login succeeded”.
D6. No backfill migration.
Section titled “D6. No backfill migration.”There are no production users to backfill — confirmed with the user.
The auto-seed applies to every signup going forward. Existing dev /
staging users without a user_public_profile row continue to rely on
the controller-side 200-with-all-nulls tolerance, which is unchanged.
If production users emerge before the next major release, a one-shot
backfill INSERT (SELECT id FROM users WHERE NOT EXISTS …) is a
follow-up of about ten lines.
Consequences
Section titled “Consequences”Positive:
- One profile-edit entry point. No “which one do I tap?” confusion.
- Obvious close button on the only edit screen.
- New users see globalName + language already populated (language
from device default via
easy_localization, name from auth metadata via the existing users seed). - Every authenticated user is guaranteed to have a
user_public_profilerow — the contributor-search / public-profile-view code paths can stop carrying the “row missing” branch in their mental model. - Parent ticket
869dee6n5is closer to its stated goal.
Negative / accepted trade-offs:
- Save is two HTTP calls, not one. Partial-failure UX (snackbar + stay on screen) covers the rare case.
tickets_appnot touched — it has its own profile flow and a separate auth model. Separate ticket if/when needed.EditProfileModallingers inpackages/profileas unreferenced-by- gym_app code. Cheap to keep; cheap to delete later.- Phone editing is not exposed anywhere in gym_app today and is not re-added by this work. Out of scope.
Alternatives considered
Section titled “Alternatives considered”Alternative: keep both screens, dedupe globalName. Rejected — doesn’t address the user confusion, and the long-term direction (per parent ticket) is single-identity-per-person.
Alternative: extend UpdateMyPublicProfileDto to accept language
and birthDate, ship a single PATCH.
Rejected — contract change, client regen across mobile / web, server-
side proxy code from public-profile controller to user_profile. Two
PATCHes is the cheapest path; the partial-failure surface is small.
Alternative: seed user_public_profile only for client scope.
Rejected during review — the table has no scope column, ADR D1 models
it as 1:1 with users (one human, one public profile), and the
business surface is the next likely consumer. The one-line guard
saves nothing real and hides a future bug.
Alternative: ship a 0042_public_profile_backfill.sql for existing
users.
Rejected — no production users to backfill. The migration is a follow-
up if/when needed.