This commit is contained in:
Leon.Schmidt
2026-08-16 17:19:25 +02:00
6 changed files with 179 additions and 168 deletions
+2 -1
View File
@@ -81,7 +81,7 @@ describe('flare store', () => {
})
})
it('preserves ordered profile media ids and urls when profile photos are saved', async () => {
it('creates a profile with ordered media selected during onboarding', async () => {
const updated = {
...bootstrap,
profile: {
@@ -110,6 +110,7 @@ describe('flare store', () => {
mockNuiCall.mockResolvedValueOnce({ data: updated, success: true })
const flare = useFlareStore()
expect(flare.profile).toBeNull()
expect(await flare.saveProfile(draft)).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('flare:save-profile', draft)
expect(flare.profile?.photoMediaIds).toEqual([42, 17, 91])
@@ -8,12 +8,31 @@ const source = readFileSync(
)
describe('FlareApp profile editing contract', () => {
it('offers Gallery and Camera as profile photo sources', () => {
it('offers Gallery and Camera directly while creating an account', () => {
const onboardingStart = source.indexOf(
'<template v-else-if="!flare.profile">',
)
const onboardingEnd = source.indexOf(
'<template v-else-if="activeMatch">',
onboardingStart,
)
const onboarding = source.slice(onboardingStart, onboardingEnd)
expect(onboardingStart).toBeGreaterThan(-1)
expect(onboardingEnd).toBeGreaterThan(onboardingStart)
expect(onboarding).toContain('flare-photo-onboarding-sources')
expect(onboarding).toContain("openProfileMediaApp('photos')")
expect(onboarding).toContain("openProfileMediaApp('camera')")
expect(onboarding).toContain("phone.t('Apps.flare.choosePhotos')")
expect(onboarding).toContain("phone.t('Apps.flare.takePhoto')")
})
it('uses the central source picker when editing profile photos', () => {
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.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(1)
expect(source).toContain('<sky-action-sheet')
expect(source).toContain(
'<sky-action-button bold @click="openProfileMediaApp(\'photos\')">',
+32 -8
View File
@@ -940,16 +940,29 @@ onBeforeUnmount(() => {
<X />
</sky-link>
</div>
<sky-button
<div
v-if="draftPhotos.length < 6"
clear
class="flare-photo-add"
:class="{ 'is-empty': !draftPhotos.length }"
@click="openPhotoSourcePicker"
class="flare-photo-onboarding-sources"
role="group"
:aria-label="phone.t('Apps.flare.addPhotos')"
>
<Plus />
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
</sky-button>
<sky-button
tonal
rounded
@click="openProfileMediaApp('photos')"
>
<Images :size="19" aria-hidden="true" />
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
</sky-button>
<sky-button
tonal
rounded
@click="openProfileMediaApp('camera')"
>
<Camera :size="19" aria-hidden="true" />
<span>{{ phone.t('Apps.flare.takePhoto') }}</span>
</sky-button>
</div>
</div>
</sky-card>
<sky-list inset strong>
@@ -2908,6 +2921,17 @@ onBeforeUnmount(() => {
grid-column: 1 / -1;
aspect-ratio: auto;
}
.flare-photo-onboarding-sources {
grid-column: 1 / -1;
display: grid;
gap: var(--sky-space-2);
}
.flare-photo-onboarding-sources :deep(.sky-button) {
min-height: var(--sky-touch-target);
height: auto;
justify-content: flex-start;
padding: var(--sky-space-2) var(--sky-space-4);
}
.flare-photo-grid :deep(.flare-photo-add svg) {
width: 25px;
height: 25px;
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./MusicApp.vue', import.meta.url), 'utf8')
const menuSource = source.slice(
source.indexOf('<SkyPopover'),
source.indexOf('</SkyPopover>') + '</SkyPopover>'.length,
)
describe('MusicApp action menu contract', () => {
it('uses the shared anchored Sky popover and its close paths', () => {
expect(source).toContain('SkyPopover,')
expect(source).toContain('const menuTarget = ref<HTMLElement | null>(null)')
expect(source.split('menuTarget.value = event.currentTarget')).toHaveLength(
3,
)
expect(menuSource).toContain(
':opened="addMenuOpened || actionMenuOpened"',
)
expect(menuSource).toContain(':target="menuTarget"')
expect(menuSource).toContain('@backdropclick="dismissMenus"')
expect(menuSource).toContain('@escape="dismissMenus"')
expect(menuSource).toContain('@positionerror="dismissMenus"')
})
it('keeps every add, playlist, and track action in the central menu', () => {
expect(menuSource).toContain('@click="shareActivePlaylist"')
expect(menuSource).toContain('@click="openActivePlaylistTrackPicker"')
expect(menuSource).toContain('openSheet')
expect(menuSource).toContain('rename')
expect(menuSource).toContain('@click="requestDeletePlaylist"')
expect(menuSource).toContain('youtube')
expect(menuSource).toContain('@click="openNewPlaylist()"')
expect(menuSource).toContain('@click="shareTrack()"')
expect(menuSource).toContain('@click="openPlaylistPicker(actionTrack)"')
expect(menuSource).toContain('@click="removeFromActivePlaylist"')
expect(menuSource).toContain('@click="requestRemoveTrack"')
expect(menuSource.split('variant="danger"')).toHaveLength(4)
})
it('does not retain the manual popover geometry or dismiss overlay', () => {
expect(source).not.toContain('positionPopover')
expect(source).not.toContain('popoverStyle')
expect(source).not.toContain('MUSIC_POPOVER_')
expect(source).not.toContain('music-popover-dismiss')
expect(source).not.toContain('class="music-popover"')
})
})
@@ -9,6 +9,17 @@ const navigationSource = source.slice(
)
describe('MusicApp Sky pill navigation contract', () => {
it('inherits the central safe areas for the iPhone header', () => {
const appRuleStart = source.indexOf('.music-app {')
const appRule = source.slice(
appRuleStart,
source.indexOf('}', appRuleStart) + 1,
)
expect(appRule).not.toContain('--sky-safe-area-top:')
expect(appRule).not.toContain('--sky-safe-area-bottom:')
})
it('uses the full-width sliding Glass navigation outside playlists', () => {
expect(source).not.toContain('kTabbar')
expect(source).not.toContain('kTabbarLink')
@@ -52,6 +63,17 @@ describe('MusicApp Sky pill navigation contract', () => {
'var(--sky-safe-area-bottom) + var(--sky-tabbar-height) + var(--sky-space-2)',
)
expect(source).toContain('--music-mini-player-height: 58px')
const miniPlayerRuleStart = source.indexOf('.music-mini-player {')
const miniPlayerRule = source.slice(
miniPlayerRuleStart,
source.indexOf('}', miniPlayerRuleStart) + 1,
)
expect(miniPlayerRule).toContain(
'right: calc(var(--sky-safe-area-right) + var(--sky-space-4))',
)
expect(miniPlayerRule).toContain(
'left: calc(var(--sky-safe-area-left) + var(--sky-space-4))',
)
expect(source).toMatch(
/\.music-app--playlist \.music-scroll\s*\{[^}]*padding-bottom:\s*42px/s,
)
+53 -157
View File
@@ -14,6 +14,10 @@ import {
SkyNavbar,
SkyNavbarBackLink,
SkyAppPage,
SkyPillNavigation,
SkyPopover,
SkySegmented,
SkySegmentedButton,
SkySpinner,
SkyRange,
SkySearchbar,
@@ -40,7 +44,6 @@ import {
Volume2,
X,
} from 'lucide-vue-next'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -51,7 +54,6 @@ import type { MusicPlaylist, MusicTrack } from '@/types/music'
import { easyShareMusicTarget } from '@/utils/easyshare'
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
import { musicEscapeLayer } from '@/utils/musicEscape'
import { SkyPillNavigation, SkySegmented, SkySegmentedButton } from '@/ui'
type MusicTab = 'library' | 'playlists' | 'search'
type MusicSheet =
@@ -61,10 +63,6 @@ type MusicSheet =
| 'track-picker'
| 'youtube'
const MUSIC_POPOVER_WIDTH = 240
const MUSIC_POPOVER_ITEM_HEIGHT = 52
const MUSIC_POPOVER_INSET = 8
const MUSIC_POPOVER_GAP = 7
const MUSIC_SHEET_TRANSITION_MS = 420
const music = useMusicStore()
@@ -84,10 +82,7 @@ const activePlaylist = ref<MusicPlaylist | null>(null)
const addMenuOpened = ref(false)
const actionMenuOpened = ref(false)
const actionTrack = ref<MusicTrack | null>(null)
const popoverStyle = ref<CSSProperties>({
top: `${MUSIC_POPOVER_INSET}px`,
left: `${MUSIC_POPOVER_INSET}px`,
})
const menuTarget = ref<HTMLElement | null>(null)
const activeSheet = ref<MusicSheet | null>(null)
const playerOpened = ref(false)
const youtubeUrl = ref('')
@@ -162,47 +157,8 @@ function dismissMenus(): void {
actionTrack.value = null
}
function positionPopover(target: HTMLElement, itemCount: number): boolean {
const app = target.closest<HTMLElement>('.music-app')
if (!app) return false
const appRect = app.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const scale = app.offsetWidth ? appRect.width / app.offsetWidth : 1
const targetLeft = (targetRect.left - appRect.left) / scale
const targetTop = (targetRect.top - appRect.top) / scale
const targetWidth = targetRect.width / scale
const targetHeight = targetRect.height / scale
const popoverHeight = itemCount * MUSIC_POPOVER_ITEM_HEIGHT + 2
const desiredLeft = targetLeft + targetWidth - MUSIC_POPOVER_WIDTH
const belowTop = targetTop + targetHeight + MUSIC_POPOVER_GAP
const desiredTop =
belowTop + popoverHeight <= app.offsetHeight - MUSIC_POPOVER_INSET
? belowTop
: targetTop - popoverHeight - MUSIC_POPOVER_GAP
popoverStyle.value = {
left: `${Math.max(
MUSIC_POPOVER_INSET,
Math.min(
desiredLeft,
app.offsetWidth - MUSIC_POPOVER_WIDTH - MUSIC_POPOVER_INSET,
),
)}px`,
top: `${Math.max(
MUSIC_POPOVER_INSET,
Math.min(
desiredTop,
app.offsetHeight - popoverHeight - MUSIC_POPOVER_INSET,
),
)}px`,
}
return true
}
function openAddMenu(event: MouseEvent): void {
const target = event.currentTarget as HTMLElement
if (!positionPopover(target, activePlaylist.value ? 4 : 2)) return
menuTarget.value = event.currentTarget as HTMLElement
actionTrack.value = null
actionMenuOpened.value = false
addMenuOpened.value = true
@@ -275,10 +231,8 @@ function cancelRemoveTrack(): void {
function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
event.stopPropagation()
menuTarget.value = event.currentTarget as HTMLElement
actionTrack.value = track
const itemCount =
2 + (activePlaylist.value ? 1 : 0) + (track.source === 'youtube' ? 1 : 0)
if (!positionPopover(event.currentTarget as HTMLElement, itemCount)) return
addMenuOpened.value = false
actionMenuOpened.value = true
}
@@ -1002,26 +956,21 @@ onBeforeUnmount(() => {
</SkySegmented>
</SkyPillNavigation>
<button
v-if="addMenuOpened || actionMenuOpened"
type="button"
class="music-popover-dismiss"
aria-hidden="true"
tabindex="-1"
@click="dismissMenus"
/>
<section
v-if="addMenuOpened"
class="music-popover"
:style="popoverStyle"
:class="{
'phone-app--light': !phone.isDarkMode,
}"
role="group"
:aria-label="phone.t('Apps.music.addMusic')"
<SkyPopover
:aria-label="
phone.t(
addMenuOpened ? 'Apps.music.addMusic' : 'Apps.music.songActions',
)
"
:opened="addMenuOpened || actionMenuOpened"
role="dialog"
:target="menuTarget"
@backdropclick="dismissMenus"
@escape="dismissMenus"
@positionerror="dismissMenus"
>
<sky-list nested>
<template v-if="activePlaylist">
<sky-list component="div" nested>
<template v-if="addMenuOpened && activePlaylist">
<sky-list-button link-component="button" @click="shareActivePlaylist">
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
</sky-list-button>
@@ -1037,13 +986,13 @@ onBeforeUnmount(() => {
</sky-list-button>
<sky-list-button
link-component="button"
class="music-destructive"
variant="danger"
@click="requestDeletePlaylist"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.deletePlaylist') }}
</sky-list-button>
</template>
<template v-else>
<template v-else-if="addMenuOpened">
<sky-list-button
link-component="button"
@click="openSheet('youtube')"
@@ -1054,47 +1003,35 @@ onBeforeUnmount(() => {
<ListMusic :size="18" /> {{ phone.t('Apps.music.newPlaylist') }}
</sky-list-button>
</template>
<template v-else-if="actionMenuOpened">
<sky-list-button link-component="button" @click="shareTrack()">
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
</sky-list-button>
<sky-list-button
link-component="button"
@click="openPlaylistPicker(actionTrack)"
>
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
</sky-list-button>
<sky-list-button
v-if="activePlaylist"
link-component="button"
variant="danger"
@click="removeFromActivePlaylist"
>
<X :size="18" /> {{ phone.t('Apps.music.removeFromPlaylist') }}
</sky-list-button>
<sky-list-button
v-if="actionTrack?.source === 'youtube'"
link-component="button"
variant="danger"
@click="requestRemoveTrack"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.removeFromLibrary') }}
</sky-list-button>
</template>
</sky-list>
</section>
<section
v-if="actionMenuOpened"
class="music-popover"
:style="popoverStyle"
:class="{
'phone-app--light': !phone.isDarkMode,
}"
role="group"
:aria-label="phone.t('Apps.music.songActions')"
>
<sky-list nested>
<sky-list-button link-component="button" @click="shareTrack()">
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
</sky-list-button>
<sky-list-button
link-component="button"
@click="openPlaylistPicker(actionTrack)"
>
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
</sky-list-button>
<sky-list-button
v-if="activePlaylist"
link-component="button"
class="music-destructive"
@click="removeFromActivePlaylist"
>
<X :size="18" /> {{ phone.t('Apps.music.removeFromPlaylist') }}
</sky-list-button>
<sky-list-button
v-if="actionTrack?.source === 'youtube'"
link-component="button"
class="music-destructive"
@click="requestRemoveTrack"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.removeFromLibrary') }}
</sky-list-button>
</sky-list>
</section>
</SkyPopover>
<div class="music-form-sheet">
<sky-sheet :opened="Boolean(activeSheet)" @backdropclick="closeSheet">
@@ -1473,8 +1410,6 @@ onBeforeUnmount(() => {
--music-label: #111114;
--music-muted: #74747c;
--music-line: rgb(18 18 23 / 9%);
--sky-safe-area-top: 46px;
--sky-safe-area-bottom: 25px;
position: relative;
display: flex !important;
flex-direction: column;
@@ -1851,9 +1786,9 @@ onBeforeUnmount(() => {
.music-mini-player {
position: absolute;
z-index: 32;
right: 8px;
right: calc(var(--sky-safe-area-right) + var(--sky-space-4));
bottom: var(--music-mini-player-bottom);
left: 8px;
left: calc(var(--sky-safe-area-left) + var(--sky-space-4));
height: var(--music-mini-player-height);
padding: 6px 10px 6px 7px;
border-radius: 17px;
@@ -2002,45 +1937,6 @@ onBeforeUnmount(() => {
margin: 14px 16px 0 !important;
}
.music-popover-dismiss {
position: absolute;
z-index: 199;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
}
.music-popover {
position: absolute !important;
z-index: 200 !important;
width: 240px !important;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 28px;
background: #303034 !important;
box-shadow: none !important;
translate: none !important;
transform: none !important;
transition: none !important;
}
.music-popover :deep(.sky-list) {
z-index: auto;
background: transparent;
}
.music-popover.phone-app--light {
border-color: rgb(0 0 0 / 10%);
background: #f8f8fa !important;
}
.music-destructive {
color: #ff453a !important;
}
.music-player-sheet :deep(.sky-sheet__panel) {
background: rgb(22 22 25 / 96%) !important;
}