ENH - improve Flare profile editing

Add a Sky action sheet for gallery and camera profile photos while preserving ordered multi-photo drafts. Make relationship goals directly editable, restore cancelled drafts, localize the new interactions, and cover the flows with focused contracts.
This commit is contained in:
Dominik
2026-08-16 16:31:16 +02:00
parent 1071b4bde7
commit 91bf310778
5 changed files with 164 additions and 22 deletions
+12 -5
View File
@@ -81,13 +81,17 @@ describe('flare store', () => {
})
})
it('sends only Gallery media ids when profile photos are saved', async () => {
it('preserves ordered profile media ids and urls when profile photos are saved', async () => {
const updated = {
...bootstrap,
profile: {
...bootstrap.profile!,
photoMediaIds: [42],
photoUrls: ['https://cdn.example.test/profile.jpg'],
photoMediaIds: [42, 17, 91],
photoUrls: [
'https://cdn.example.test/profile-primary.jpg',
'https://cdn.example.test/profile-secondary.jpg',
'https://cdn.example.test/profile-tertiary.jpg',
],
},
}
const draft: FlareProfileDraft = {
@@ -101,15 +105,18 @@ describe('flare store', () => {
maxAge: bootstrap.profile!.maxAge,
minAge: bootstrap.profile!.minAge,
name: bootstrap.profile!.name,
photoMediaIds: [42],
photoMediaIds: [42, 17, 91],
}
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
const flare = useFlareStore()
expect(await flare.saveProfile(draft)).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('flare:save-profile', draft)
expect(flare.profile?.photoMediaIds).toEqual([42, 17, 91])
expect(flare.profile?.photoUrls).toEqual([
'https://cdn.example.test/profile.jpg',
'https://cdn.example.test/profile-primary.jpg',
'https://cdn.example.test/profile-secondary.jpg',
'https://cdn.example.test/profile-tertiary.jpg',
])
})
+3 -2
View File
@@ -846,7 +846,7 @@ const defaultLocales: LocaleTree = {
photo: 'Profile photo',
profilePhotos: 'Profile photos',
profilePhotosBody:
'Choose up to six photos from Photos. Your first photo is shown first.',
'Add up to six photos from Photos or Camera. Your first photo is shown first.',
addPhotos: 'Add photos',
choosePhotos: 'Choose from Photos',
primaryPhoto: 'Main',
@@ -904,6 +904,7 @@ const defaultLocales: LocaleTree = {
messages: 'Messages',
settings: 'Settings',
editProfile: 'Edit profile',
editRelationshipGoal: 'Edit relationship goal',
profileGoalBody: 'Shown on your discovery card.',
relationshipGoal: 'Relationship goal',
interests: 'Interests',
@@ -967,7 +968,7 @@ const defaultLocales: LocaleTree = {
errors: {
invalid_profile: 'Check your name, age and profile text.',
invalid_profile_photos:
'Choose up to six photos from your own Photos library.',
'Choose or take up to six photos saved in your own Photos library.',
request_failed: 'Flare could not save those changes. Try again.',
invalid_target: 'This profile is no longer available.',
invalid_choice: 'That swipe could not be saved.',
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./FlareApp.vue', import.meta.url),
'utf8',
)
describe('FlareApp profile editing contract', () => {
it('offers Gallery and Camera as profile photo sources', () => {
const mediaAppStart = source.indexOf('function openProfileMediaApp')
const mediaAppEnd = source.indexOf('function removeDraftPhoto')
const mediaApp = source.slice(mediaAppStart, mediaAppEnd)
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(2)
expect(source).toContain('<sky-action-sheet')
expect(source).toContain(
'<sky-action-button bold @click="openProfileMediaApp(\'photos\')">',
)
expect(source).toContain(
'<sky-action-button @click="openProfileMediaApp(\'camera\')">',
)
expect(mediaAppStart).toBeGreaterThan(-1)
expect(mediaAppEnd).toBeGreaterThan(mediaAppStart)
expect(mediaApp).toContain("app: 'camera' | 'photos'")
expect(mediaApp).toContain("'flare:profile-photos'")
expect(mediaApp).toContain("app === 'photos' ? remaining : 1")
expect(mediaApp).toContain('void router.push({')
expect(mediaApp).toContain("query: { mediaAttachment: 'photo' }")
})
it('opens the relationship goal editor from the profile summary card', () => {
const cardClass = source.indexOf('class="flare-profile-card"')
const cardStart = source.lastIndexOf('<sky-card', cardClass)
const cardEnd = source.indexOf('</sky-card>', cardClass)
const card = source.slice(cardStart, cardEnd)
expect(cardClass).toBeGreaterThan(-1)
expect(cardStart).toBeGreaterThan(-1)
expect(cardEnd).toBeGreaterThan(cardStart)
expect(card).toContain('component="button"')
expect(card).toContain('aria-controls="flare-choice-sheet"')
expect(card).toContain('aria-haspopup="dialog"')
expect(card).toContain('@click="openProfileGoalEditor"')
expect(source).toContain('async function openProfileGoalEditor()')
expect(source).toMatch(
/openProfileGoalEditor\(\)[\s\S]*?profileEditing\.value = true[\s\S]*?openChoice\('lookingFor', null\)/,
)
})
})
+95 -12
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
import {
SkyActionButton,
SkyActionGroup,
SkyActionSheet,
SkyActionsLabel,
SkyBadge,
SkyBlock,
SkyBlockTitle,
@@ -124,6 +128,7 @@ const messageScroll = ref<HTMLElement | null>(null)
const profileEditing = ref(false)
const profileSettings = ref(false)
const profileSaving = ref(false)
const photoSourceOpened = ref(false)
const discoverySaving = ref(false)
const unmatchDialog = ref(false)
const activeExploreMode = ref<ExploreMode>('all')
@@ -297,14 +302,24 @@ function cloneDraft(): FlareProfileDraft {
}
}
function openPhotoGallery(): void {
function openPhotoSourcePicker(): void {
if (draftPhotos.value.length >= 6) return
photoSourceOpened.value = true
}
function closePhotoSourcePicker(): void {
photoSourceOpened.value = false
}
function openProfileMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - draftPhotos.value.length
if (remaining < 1) return
closePhotoSourcePicker()
messageMedia.begin(
'flare:profile-photos',
'photo',
flare.profile ? '/apps/flare?profileEdit=1' : '/apps/flare',
remaining,
app === 'photos' ? remaining : 1,
{
draft: cloneDraft(),
editing: Boolean(flare.profile),
@@ -312,7 +327,7 @@ function openPhotoGallery(): void {
} satisfies FlareMediaContext,
)
void router.push({
path: '/apps/photos',
path: `/apps/${app}`,
query: { mediaAttachment: 'photo' },
})
}
@@ -382,7 +397,7 @@ function choiceLinkProps(field: FlareChoiceField): Record<string, unknown> {
async function openChoice(
field: FlareChoiceField,
trigger: HTMLElement,
trigger: HTMLElement | null,
): Promise<void> {
activeChoiceTrigger = trigger
activeChoiceField.value = field
@@ -397,6 +412,11 @@ async function openChoice(
?.focus({ preventScroll: true })
}
async function openProfileGoalEditor(): Promise<void> {
profileEditing.value = true
await openChoice('lookingFor', null)
}
function closeChoice(): void {
const trigger = activeChoiceTrigger
choiceOpened.value = false
@@ -548,6 +568,7 @@ function openSettings(): void {
}
function closeProfileScreen(): void {
syncDraft()
profileEditing.value = false
profileSettings.value = false
}
@@ -924,10 +945,10 @@ onBeforeUnmount(() => {
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
@click="openPhotoGallery"
@click="openPhotoSourcePicker"
>
<Images />
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</sky-button>
</div>
</sky-card>
@@ -1617,7 +1638,16 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.easyShare.shareProfile') }}
</button>
</div>
<sky-card :content-wrap="false" class="flare-profile-card">
<sky-card
component="button"
type="button"
:content-wrap="false"
class="flare-profile-card"
aria-controls="flare-choice-sheet"
aria-haspopup="dialog"
:aria-label="phone.t('Apps.flare.editRelationshipGoal')"
@click="openProfileGoalEditor"
>
<span><Heart fill="currentColor" /></span>
<div>
<strong>{{
@@ -1667,10 +1697,10 @@ onBeforeUnmount(() => {
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
@click="openPhotoGallery"
@click="openPhotoSourcePicker"
>
<Images />
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</sky-button>
</div>
</sky-card>
@@ -1856,8 +1886,42 @@ onBeforeUnmount(() => {
</div>
</div>
<sky-action-sheet
:aria-label="phone.t('Apps.flare.addPhotos')"
:opened="photoSourceOpened"
@backdropclick="closePhotoSourcePicker"
@escape="closePhotoSourcePicker"
>
<sky-action-group>
<sky-actions-label>
{{ phone.t('Apps.flare.addPhotos') }}
</sky-actions-label>
<sky-action-button bold @click="openProfileMediaApp('photos')">
<span class="flare-photo-source-action">
<Images :size="20" />
{{ phone.t('Apps.flare.choosePhotos') }}
</span>
</sky-action-button>
<sky-action-button @click="openProfileMediaApp('camera')">
<span class="flare-photo-source-action">
<Camera :size="20" />
{{ phone.t('Apps.flare.takePhoto') }}
</span>
</sky-action-button>
</sky-action-group>
<sky-action-group>
<sky-action-button @click="closePhotoSourcePicker">
{{ phone.t('Common.cancel') }}
</sky-action-button>
</sky-action-group>
</sky-action-sheet>
<div class="flare-choice-sheet">
<sky-sheet :opened="choiceOpened" @backdropclick="closeChoice">
<sky-sheet
:opened="choiceOpened"
@backdropclick="closeChoice"
@escape="closeChoice"
>
<section
id="flare-choice-sheet"
ref="choiceSheetContent"
@@ -2625,15 +2689,29 @@ onBeforeUnmount(() => {
box-shadow: 0 7px 18px rgb(255 56 92 / 25%);
}
.flare-profile-card {
box-sizing: border-box;
width: calc(100% - 4px);
min-height: 65px;
margin: 26px 2px 0 !important;
padding: 14px !important;
display: flex;
align-items: center;
gap: 11px;
border: 0;
color: inherit;
font: inherit;
text-align: left;
border-radius: 15px !important;
background: var(--flare-panel) !important;
box-shadow: none !important;
cursor: pointer;
}
.flare-profile-card:active {
background: var(--sky-pressed) !important;
}
.flare-profile-card:focus-visible {
outline: 2px solid var(--flare);
outline-offset: 2px;
}
.flare-profile-card > span {
width: 37px;
@@ -2834,6 +2912,11 @@ onBeforeUnmount(() => {
width: 25px;
height: 25px;
}
.flare-photo-source-action {
display: inline-flex;
align-items: center;
gap: var(--sky-space-2);
}
.flare-error {
margin: 8px 22px 0;
color: #ff3b30;
+3 -3
View File
@@ -406,7 +406,7 @@ Locales["en"] = {
flare = {
name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private Sky Cloud account.",
createProfile = "Create your profile", welcome = "Find your spark", welcomeBody = "Build a profile and meet people across Los Santos.",
photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Choose up to six photos from Photos. Your first photo is shown first.", addPhotos = "Add photos", choosePhotos = "Choose from Photos", primaryPhoto = "Main", removePhoto = "Remove photo {number}",
photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Add up to six photos from Photos or Camera. Your first photo is shown first.", addPhotos = "Add photos", choosePhotos = "Choose from Photos", primaryPhoto = "Main", removePhoto = "Remove photo {number}",
yourName = "Your name", namePlaceholder = "What should people call you?", age = "Age", bio = "About you", bioPlaceholder = "A short line that starts a conversation...",
gender = "I am", showMe = "Show me", woman = "Woman", man = "Man", nonbinary = "Non-binary", women = "Women", men = "Men", nonbinaryPeople = "Non-binary people", everyone = "Everyone",
minimumAge = "Minimum age", maximumAge = "Maximum age", start = "Start exploring", saveProfile = "Save profile", editProfileBody = "Keep your profile fresh and unmistakably you.",
@@ -416,7 +416,7 @@ Locales["en"] = {
exploreTitle = "Explore", exploreBody = "Find people who are into the same things.",
exploreModes = { forYou = "For You", dateNight = "Date Night", freeTonight = "Free Tonight", longTerm = "Long-term thing", newFriends = "New Friends", weekend = "Weekend plans" },
likesTitle = "Likes You", likesBody = "See who already likes you, then make your choice.", matched = "Matched", newMatches = "New Matches", messages = "Messages",
settings = "Settings", editProfile = "Edit profile", profileGoalBody = "Shown on your discovery card.", like = "Like", pass = "Pass",
settings = "Settings", editProfile = "Edit profile", editRelationshipGoal = "Edit relationship goal", profileGoalBody = "Shown on your discovery card.", like = "Like", pass = "Pass",
relationshipGoal = "Relationship goal", interests = "Interests", interestsPlaceholder = "Music, travel, coffee", interestsHint = "Separate up to five interests with commas.",
discoverySettings = "Discovery", discoveryPreferences = "Who you want to meet", showProfile = "Show me on Flare", showProfileBody = "Let new people discover your profile.",
discoveryPrivacyNote = "Turning Discovery off hides you from new people. Your existing matches and chats stay available.",
@@ -428,7 +428,7 @@ Locales["en"] = {
messagePlaceholder = "Write a message", itsAMatch = "It's a spark!", matchBody = "You and {name} liked each other.", sayHello = "Say hello", keepSwiping = "Keep exploring", newMatchNotification = "You and {sender} found a spark!", newMessageNotification = "New message from {sender}",
lookingFor = { longTerm = "Long-term connection", dates = "Good dates", friends = "New friends" },
errors = {
invalid_profile = "Check your name, age and profile text.", invalid_profile_photos = "Choose up to six photos from your own Photos library.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.",
invalid_profile = "Check your name, age and profile text.", invalid_profile_photos = "Choose or take up to six photos saved in your own Photos library.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.",
nothing_to_rewind = "There is no recent swipe to rewind.", cannot_rewind_match = "A swipe that created a match cannot be rewound.",
match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.",
},