Skip to content

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

The gym_app profile menu exposes two edit-profile entry points side by side:

  1. «Edit profile» opens EditProfileModal — edits users.full_name, user_profile.language, user_profile.birth_date via PATCH /api/client/me.
  2. «Edit public profile» opens EditPublicProfileScreen — edits users.full_name (again) + bio / specializations / links / slug / coverPhotoUrl via PATCH /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.

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-dd stored.

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:

  1. PATCH /api/client/me with { name, language, birthDate } via AuthService.updateMe. If 4xx → inline error, step 2 is NOT issued.
  2. PATCH /api/client/me/public-profile with { bio, specializations, links, slug } — existing payload, minus globalName.

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 to router.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 869dee6n5 in review).

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”.

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.

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_profile row — the contributor-search / public-profile-view code paths can stop carrying the “row missing” branch in their mental model.
  • Parent ticket 869dee6n5 is 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_app not touched — it has its own profile flow and a separate auth model. Separate ticket if/when needed.
  • EditProfileModal lingers in packages/profile as 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.

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.