Skip to content

Commit 28fc4a5

Browse files
yewreekaclaude
andauthored
feat(onboarding): first-launch profile setup sheet (#1158)
* feat(onboarding): first-launch profile setup sheet Replace the in-conversation "Add your name and pic" prompt with a "Hello / My name is" sheet (Figma 5694-27769) shown once on first launch. The sheet only presents when the pairable-device check finds no other identity in the iCloud-synced keychain backup and no pairing UI is on screen, so it can never race the found-device pairing sheet. The in-conversation flow stays as a fallback for users who dismiss the sheet without saving. The same sheet (no terms row, "Save" CTA — Figma 5694-28789) replaces the MyInfoView form in Settings > My info and the my-profile sheet shown when tapping your own avatar/member row in a conversation; MyInfoView is removed. The sheet keeps a local draft of the edit and writes to ProfileSettingsViewModel only on save — binding the fields directly loses in-flight edits when the global-profile observation re-emits during first-launch inbox churn. QA: rewrote tests 01 (first-launch sheet + no in-conversation re-prompt) and 14 part 5 (own-profile edit from a conversation is now global), updated 40 to name devices via the sheet, and noted the fallback status of the quick-edit path in 07/19. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): profile sheet design pass + empty state Spacing/type corrections against the Figma dev measurements: 36pt gap between the lava band and the name row (not 64 - the band overhangs its text by 28), 132pt band (40 above / 28 below the text), 56pt CTA with a 16pt horizontal inset inside the card padding, and medium-weight photo/camera glyphs. Dropped an experimental presentationCornerRadius override that made the system inset the sheet content, leaving a white cap above the lava band. Empty state per design: placeholder is "Name" (not "Somebody"), the avatar shows person.crop.circle.fill on an inverted circle until a name is typed (monogram) or a photo is chosen, the terms toggle starts OFF, and the CTA renders gray while disabled. QA: test 01 and 40 now enable the terms toggle before saving; empty state assertions added to 01. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): paint profile sheet presentation background to match card The self-sizing detent can settle taller than the measured content, and rubber-banding exposes the area beyond it - both showed the ultra-thin material as a white cap above the lava band. Paint the presentation background lava on top / card gray below so any exposed region reads as the card continuing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): profile sheet owns its presentation; empty-state polish The selfSizingSheet approach presents at .medium and swaps to a .height() detent after measuring, but iOS never re-snaps a presented sheet down from medium - content shorter than medium (this sheet, 398pt vs medium's ~425pt) floats centered with dead space, which showed as a white cap above the lava band. ProfileSetupSheet now presents via plain .sheet and supplies its own detent, seeded per mode and corrected by measurement, so the detent only receives same-kind .height updates. Also: camera.fill glyph per design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): terms toggle defaults on Come in is gated only on entering a name; turning the toggle off still disables it. QA tests 01/40 updated (no toggle tap needed before save). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): address PR review findings on ProfileSetupSheet - Drop the ConversationView onDismiss handler (and the now-unused onProfileSettingsDismissed): it re-saved from the stale myProfileViewModel, clobbering the just-saved profile photo. Also fixes the SwiftLint multiple_closures_with_trailing_closure failure. - Roll the shared view model back to its pre-save values when saveAndAwait throws, so a failed save doesn't present never-persisted edits as stored. - Reseed a pristine draft when the global profile load resolves, so a sheet opened during cold launch can't save a stale empty draft over the stored avatar. - Allow photo-only saves in edit mode (empty name falls back to the stored one in saveAndAwait). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): My info row shows avatar, name, and pencil affordance Mirrors the profile sheet's name row (Figma 5694-28455): 36pt avatar (person.crop.circle.fill placeholder until a name or photo exists), display name ("Name" placeholder in tertiary when unset), and a trailing pencil glyph instead of the old lanyard icon + trailing name/avatar. A "Your name and pic" footer sits under the row. Tapping still opens the profile sheet. Accessibility: label stays "My info" (id my-info-row) with the name exposed as the value; QA test 14 annotated accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1b81b8f commit 28fc4a5

15 files changed

Lines changed: 656 additions & 411 deletions

Convos/App Settings/AppSettingsView.swift

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ struct AppSettingsView: View {
6767
@State private var versionTapCount: Int = 0
6868
@State private var lastVersionTapAt: Date?
6969
@State private var showingEnableDebugConfirmation: Bool = false
70+
@State private var presentingMyInfoSheet: Bool = false
7071

7172
private func ensureNavigator() {
7273
guard navigator == nil else { return }
@@ -144,22 +145,25 @@ struct AppSettingsView: View {
144145
@ViewBuilder
145146
private var myInfoSection: some View {
146147
Section {
147-
NavigationLink {
148-
MyInfoView(
149-
profile: .constant(.empty()),
150-
profileImage: .constant(nil),
151-
editingDisplayName: .constant(""),
152-
profileSettingsViewModel: profileSettingsViewModel,
153-
showsCancelButton: false,
154-
showsProfile: false,
155-
showsUseProfileButton: false,
156-
canEditProfile: true
157-
) { _ in }
158-
.onAppear { navigator?.navigateTo(myInfo: MyInfoNavigatorArgs()) }
148+
Button {
149+
presentingMyInfoSheet = true
150+
navigator?.navigateTo(myInfo: MyInfoNavigatorArgs())
159151
} label: {
160152
myInfoRowLabel
161153
}
162154
.accessibilityIdentifier("my-info-row")
155+
.accessibilityLabel("My info")
156+
.accessibilityValue(
157+
profileSettingsViewModel.editingDisplayName.isEmpty
158+
? "Not set"
159+
: profileSettingsViewModel.editingDisplayName
160+
)
161+
.listRowInsets(.all, DesignConstants.Spacing.step2x)
162+
.sheet(isPresented: $presentingMyInfoSheet) {
163+
ProfileSetupSheet(mode: .edit)
164+
}
165+
} footer: {
166+
Text("Your name and pic")
163167
}
164168
}
165169

@@ -192,29 +196,40 @@ struct AppSettingsView: View {
192196
}
193197
}
194198

199+
/// Mirrors the profile sheet's name row: avatar, name, and a trailing
200+
/// pencil affordance. Tapping anywhere opens the profile sheet.
195201
@ViewBuilder
196202
private var myInfoRowLabel: some View {
197-
HStack {
198-
Image(systemName: "lanyardcard.fill")
199-
.foregroundStyle(.colorTextPrimary)
200-
.frame(width: DesignConstants.Spacing.step8x, alignment: .center)
203+
let displayName = profileSettingsViewModel.editingDisplayName
204+
HStack(spacing: DesignConstants.Spacing.step2x) {
205+
Group {
206+
if !displayName.isEmpty || profileSettingsViewModel.profileImage != nil {
207+
ProfileAvatarView(
208+
profile: profileSettingsViewModel.profile,
209+
profileImage: profileSettingsViewModel.profileImage,
210+
useSystemPlaceholder: false
211+
)
212+
} else {
213+
ZStack {
214+
Circle().fill(.colorBackgroundInverted)
215+
Image(systemName: "person.crop.circle.fill")
216+
.font(.system(size: 20.0))
217+
.foregroundStyle(.colorTextPrimaryInverted)
218+
}
219+
}
220+
}
221+
.frame(width: 36.0, height: 36.0)
201222

202-
Text("My info")
203-
.foregroundStyle(.colorTextPrimary)
223+
Text(displayName.isEmpty ? "Name" : displayName)
224+
.font(.body)
225+
.foregroundStyle(displayName.isEmpty ? .colorTextTertiary : .colorTextPrimary)
204226

205227
Spacer()
206228

207-
if !profileSettingsViewModel.profileSettings.isDefault {
208-
Text(profileSettingsViewModel.editingDisplayName)
209-
.foregroundStyle(.colorTextSecondary)
210-
211-
ProfileAvatarView(
212-
profile: profileSettingsViewModel.profile,
213-
profileImage: profileSettingsViewModel.profileImage,
214-
useSystemPlaceholder: false
215-
)
216-
.frame(width: 16.0, height: 16.0)
217-
}
229+
Image(systemName: "pencil")
230+
.font(.body.weight(.medium))
231+
.foregroundStyle(.colorTextSecondary)
232+
.padding(.trailing, DesignConstants.Spacing.step2x)
218233
}
219234
}
220235

Convos/Conversation Detail/ConversationOnboardingCoordinator.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,28 @@ final class ConversationOnboardingCoordinator {
151151
private static let legacyHasSetQuicknamePrefix: String = "hasSetQuicknameForConversation_"
152152
private static let hasSeenAddAsProfileKey: String = "hasSeenAddAsProfile"
153153
private static let hasShownNUXPaywallKey: String = "hasShownNUXPaywall"
154+
private static let hasShownFirstLaunchProfileSheetKey: String = "hasShownFirstLaunchProfileSheet"
154155

155156
static func markProfileEditorShown() {
156157
UserDefaults.standard.set(true, forKey: hasShownProfileEditorKey)
157158
}
158159

160+
/// Whether the first-launch "Hello / My name is" profile sheet is still
161+
/// owed: it hasn't been shown yet and the user has never been through a
162+
/// profile editor (set up in Settings, completed the in-conversation
163+
/// flow, or adopted a paired identity).
164+
static var shouldOfferFirstLaunchProfileSheet: Bool {
165+
!UserDefaults.standard.bool(forKey: hasShownFirstLaunchProfileSheetKey)
166+
&& !UserDefaults.standard.bool(forKey: hasShownProfileEditorKey)
167+
}
168+
169+
/// Latches the first-launch profile sheet: set when the sheet is
170+
/// presented (or found unnecessary because a profile already exists),
171+
/// so it never shows twice.
172+
static func markFirstLaunchProfileSheetShown() {
173+
UserDefaults.standard.set(true, forKey: hasShownFirstLaunchProfileSheetKey)
174+
}
175+
159176
/// Marks the global onboarding flags as completed so the in-conversation
160177
/// "set up your name / pic" prompts are skipped. Used by the pairing
161178
/// flow on the joiner side — the joiner just adopted the initiator's
@@ -175,6 +192,7 @@ final class ConversationOnboardingCoordinator {
175192
UserDefaults.standard.removeObject(forKey: hasCompletedOnboardingKey)
176193
UserDefaults.standard.removeObject(forKey: hasSeenAddAsProfileKey)
177194
UserDefaults.standard.removeObject(forKey: hasShownNUXPaywallKey)
195+
UserDefaults.standard.removeObject(forKey: hasShownFirstLaunchProfileSheetKey)
178196

179197
let allKeys = UserDefaults.standard.dictionaryRepresentation().keys
180198
for key in allKeys where key.hasPrefix(hasSetProfilePrefix) || key.hasPrefix(legacyHasSetQuicknamePrefix) {

Convos/Conversation Detail/ConversationView.swift

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -571,21 +571,10 @@ struct ConversationView<MessagesBottomBar: View>: View {
571571
capabilityApprovalSheet
572572
}
573573
.sheet(isPresented: $viewModel.presentingProfileSettings) {
574-
MyInfoView(
575-
profile: .constant(viewModel.myProfileViewModel.profile),
576-
profileImage: $viewModel.myProfileViewModel.profileImage,
577-
editingDisplayName: $viewModel.myProfileViewModel.editingDisplayName,
578-
profileSettingsViewModel: profileSettingsViewModel,
579-
showsCancelButton: true,
580-
showsProfile: true,
581-
showsUseProfileButton: true,
582-
canEditProfile: false
583-
) { profileSettings in
584-
viewModel.onUseProfile(profileSettings.profile, profileSettings.profileImage)
585-
}
586-
.onDisappear {
587-
viewModel.onProfileSettingsDismissed(focusCoordinator: focusCoordinator)
588-
}
574+
// ProfileSetupSheet owns the full save; no dismiss handler —
575+
// the old onProfileSettingsDismissed re-saved from the stale
576+
// myProfileViewModel and clobbered the just-saved profile.
577+
ProfileSetupSheet(mode: .edit)
589578
}
590579
.toolbar { topBarTrailing }
591580
.debugConnectionInjectorSheet(

Convos/Conversation Detail/ConversationViewModel.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2817,10 +2817,6 @@ extension ConversationViewModel {
28172817
focusCoordinator.moveFocus(to: .displayName)
28182818
}
28192819

2820-
func onProfileSettingsDismissed(focusCoordinator: FocusCoordinator) {
2821-
onDisplayNameEndedEditing(focusCoordinator: focusCoordinator, context: .editProfile)
2822-
}
2823-
28242820
func addFileAttachment(url: URL, filename: String, mimeType: String, fileSize: Int) {
28252821
guard canStageMoreMedia else {
28262822
try? FileManager.default.removeItem(at: url)

Convos/Conversations List/ConversationsView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ private struct ConversationsSheetModifier: ViewModifier {
365365
.padding(.top, DesignConstants.Spacing.step5x)
366366
}
367367
)
368+
// Plain .sheet: ProfileSetupSheet supplies its own detent,
369+
// indicator, and background (see its body).
370+
.sheet(isPresented: $viewModel.presentingFirstLaunchProfileSetup) {
371+
ProfileSetupSheet(mode: .firstLaunch)
372+
}
368373
.selfSizingSheet(isPresented: $viewModel.presentingExplodeInfo) {
369374
ExplodeInfoView()
370375
}

Convos/Conversations List/ConversationsViewModel.swift

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ final class ConversationsViewModel {
8787
/// Drives the first-install "Pair <device>?" sheet when another
8888
/// device's identity is found in the iCloud-synced keychain backup.
8989
var foundDevicePairingPrompt: FoundDevicePairingPrompt?
90+
/// Drives the first-launch "Hello / My name is" profile setup sheet.
91+
/// Presented only when the pairable-device check found no other
92+
/// identity in the iCloud-synced keychain backup and no pairing UI is
93+
/// on screen (see `presentFirstLaunchProfileSetupIfNeeded`).
94+
var presentingFirstLaunchProfileSetup: Bool = false
9095
/// Joiner pairing flow prepared by `pairWithFoundDevice()`, promoted
9196
/// to `pendingJoinerPairing` once the prompt sheet finishes
9297
/// dismissing - presenting both sheets in the same tick can drop the
@@ -929,7 +934,13 @@ extension ConversationsViewModel {
929934
guard let self else { return }
930935
let backups = await self.session.pairableDeviceBackups()
931936
QAEvent.emit(.pairing, "found_device_check", ["pairableCount": "\(backups.count)"])
932-
guard let newest = backups.first else { return }
937+
guard let newest = backups.first else {
938+
// No other identity in iCloud, so pairing will never be
939+
// offered - this is the moment first-launch profile setup
940+
// becomes eligible.
941+
await self.presentFirstLaunchProfileSetupIfNeeded()
942+
return
943+
}
933944
// Don't fight an in-flight deep-link pairing flow - but
934945
// un-latch so the next chats-list appearance re-offers the
935946
// prompt once that flow ends. Without the reset, a launch
@@ -951,6 +962,45 @@ extension ConversationsViewModel {
951962
}
952963
}
953964

965+
/// First-launch profile setup: shows the "Hello / My name is" sheet
966+
/// once, instead of the in-conversation "Add your name and pic"
967+
/// prompt. Only reached when `checkForPairableDeviceIfNeeded` found no
968+
/// other identity in the iCloud-synced keychain backup, so it can
969+
/// never race the found-device pairing prompt; the explicit pairing
970+
/// guards below cover deep-link and incoming pairing flows. The
971+
/// in-conversation onboarding flow stays as a fallback for users who
972+
/// dismiss this sheet without saving.
973+
private func presentFirstLaunchProfileSetupIfNeeded() async {
974+
guard ConversationOnboardingCoordinator.shouldOfferFirstLaunchProfileSheet else { return }
975+
guard pendingJoinerPairing == nil,
976+
foundDevicePairingPrompt == nil,
977+
incomingPairingRequest == nil else { return }
978+
// Don't decide on an unloaded snapshot: at cold launch the shared
979+
// profile view model holds default values even for a fully
980+
// onboarded user. On timeout skip without latching so the next
981+
// launch retries (mirrors ConversationOnboardingCoordinator).
982+
let profileLoaded = await ProfileSettingsViewModel.shared.waitForProfileLoad(
983+
timeout: Constant.firstLaunchProfileLoadTimeout
984+
)
985+
guard profileLoaded else {
986+
QAEvent.emit(.onboarding, "first_launch_profile_sheet_load_timeout")
987+
return
988+
}
989+
guard ProfileSettingsViewModel.shared.profileSettings.isDefault else {
990+
// A profile already exists (e.g. restored data): never show.
991+
ConversationOnboardingCoordinator.markFirstLaunchProfileSheetShown()
992+
return
993+
}
994+
// Re-check the pairing guards: a deep-link pairing flow may have
995+
// started while we awaited the profile load.
996+
guard pendingJoinerPairing == nil,
997+
foundDevicePairingPrompt == nil,
998+
incomingPairingRequest == nil else { return }
999+
ConversationOnboardingCoordinator.markFirstLaunchProfileSheetShown()
1000+
presentingFirstLaunchProfileSetup = true
1001+
QAEvent.emit(.onboarding, "first_launch_profile_sheet_shown")
1002+
}
1003+
9541004
/// Primary action of the prompt sheet: dismisses the prompt
9551005
/// immediately, mints a pairing invite signed by the found device's
9561006
/// backed-up key (so the standard joiner flow can target it as the
@@ -1247,6 +1297,10 @@ extension ConversationsViewModel {
12471297
/// presentation was dropped. Generous against slow presentation
12481298
/// animations; tiny against the 5s resend cadence that retries.
12491299
static let incomingPairingPresentationGrace: TimeInterval = 3
1300+
/// How long first-launch profile setup waits for the global
1301+
/// profile to load before giving up for this launch. Matches
1302+
/// `ConversationOnboardingCoordinator.profileLoadTimeout`.
1303+
static let firstLaunchProfileLoadTimeout: TimeInterval = 10
12501304
}
12511305
}
12521306

0 commit comments

Comments
 (0)