mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Merge branch 'dev' of https://github.com/sky-systems/sky_phone into dev
This commit is contained in:
@@ -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\')">',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user