ENH - expand Settings controls and wallpaper imports

This commit is contained in:
DerEchteAlec
2026-08-17 05:16:03 +02:00
parent 4532ba393b
commit d121370159
14 changed files with 284 additions and 34 deletions
+2
View File
@@ -4247,6 +4247,8 @@ const defaultLocales: LocaleTree = {
wallpaperFromPhotosDescription: 'Use one of your photos',
wallpaperFromCamera: 'Take Photo',
wallpaperFromCameraDescription: 'Create a new wallpaper',
wallpaperCustomUpload: 'Upload Custom Image',
wallpaperCustomUploadDescription: 'Use a verified HTTPS image link',
wallpaperHistory: 'Recent',
wallpaperCustom: 'Photo wallpaper',
wallpaperTarget: 'Wallpaper destination',
+5
View File
@@ -1,6 +1,11 @@
export type MediaType = 'photo' | 'video'
export type GalleryFilter = 'all' | MediaType
export type MediaConfig = {
customWallpaperUploadEnabled: boolean
videoBitrateKbps: number
}
export type PhoneMedia = {
createdAt: number
favorite: boolean
+66 -4
View File
@@ -190,19 +190,81 @@ label.sky-settings-row__title {
}
.sky-settings-range-row__frame {
padding-top: var(--sky-space-1);
padding-bottom: var(--sky-space-1);
min-height: 88px;
padding-top: 12px;
padding-bottom: 14px;
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.sky-settings-group--compact .sky-settings-row__frame,
.sky-settings-group--compact .sky-settings-range-row__frame {
min-height: 48px;
min-height: 76px;
padding-top: 6px;
padding-bottom: 6px;
}
.sky-settings-range-row .sky-range {
.sky-settings-range-row__header,
.sky-settings-range-row__control {
min-width: 0;
display: flex;
align-items: center;
}
.sky-settings-range-row__header {
justify-content: space-between;
gap: 16px;
}
.sky-settings-range-row__title,
.sky-settings-range-row__value {
font-size: 17px;
line-height: 22px;
}
.sky-settings-range-row__title {
min-width: 0;
color: var(--sky-text);
overflow-wrap: anywhere;
}
.sky-settings-range-row__value {
flex: none;
color: var(--sky-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sky-settings-range-row__control {
min-height: 28px;
gap: 12px;
}
.sky-settings-range-row__control .sky-range {
width: 100%;
flex: 1 1 auto;
}
.sky-settings-range-row__endpoint {
width: 24px;
height: 28px;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
color: var(--sky-muted);
}
.sky-settings-range-row__endpoint > svg {
width: 22px;
height: 22px;
display: block;
}
.sky-settings-range-row__endpoint--leading > svg {
width: 17px;
height: 17px;
}
.sky-settings-icon {
@@ -1,4 +1,4 @@
import { createSSRApp } from 'vue'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
@@ -26,7 +26,9 @@ describe('SkySettingsRangeRow', () => {
expect(html).toContain('min="25"')
expect(html).toContain('max="100"')
expect(html).toContain('step="5"')
expect(html).toContain('75%')
expect(html).toContain('class="sky-settings-range-row__title">Brightness')
expect(html).toContain('class="sky-settings-range-row__value">75%')
expect(html).not.toContain('sky-range__caption')
})
it('uses the effective numeric value as its default visible label', async () => {
@@ -37,7 +39,9 @@ describe('SkySettingsRangeRow', () => {
const html = await renderToString(app)
expect(html).toMatch(/class="sky-range__label">.*1\.25.*<\/span>/)
expect(html).toMatch(
/class="sky-settings-range-row__value">.*1\.25.*<\/span>/,
)
expect(html).not.toContain('aria-valuetext=')
})
@@ -51,7 +55,31 @@ describe('SkySettingsRangeRow', () => {
const html = await renderToString(app)
expect(html).toContain('aria-valuetext="75%"')
expect(html).toMatch(/class="sky-range__label">.*75%.*<\/span>/)
expect(html).toMatch(
/class="sky-settings-range-row__value">.*75%.*<\/span>/,
)
})
it('supports decorative range endpoint icons without changing the label', async () => {
const app = createSSRApp({
render: () =>
h(
SkySettingsRangeRow,
{ modelValue: 50, title: 'Volume', valueLabel: '50%' },
{
leading: () => h('svg', { 'data-endpoint': 'low' }),
trailing: () => h('svg', { 'data-endpoint': 'high' }),
},
),
})
const html = await renderToString(app)
expect(html).toContain('sky-settings-range-row__endpoint--leading')
expect(html).toContain('sky-settings-range-row__endpoint--trailing')
expect(html).toContain('data-endpoint="low"')
expect(html).toContain('data-endpoint="high"')
expect(html).toContain('aria-label="Volume"')
})
it('exposes input, change, and numeric model update events', () => {
@@ -53,21 +53,38 @@ const accessibleValue = computed(
:class="{ 'sky-settings-range-row--disabled': disabled }"
>
<div class="sky-settings-range-row__frame">
<SkyRange
:aria-label="title"
:aria-value-text="accessibleValue"
:caption="title"
:disabled="disabled"
:max="max"
:min="min"
:model-value="effectiveValue"
:step="step"
@change="emit('change', $event)"
@input="emit('input', $event)"
@update:model-value="emit('update:modelValue', $event)"
>
{{ visibleValue }}
</SkyRange>
<div class="sky-settings-range-row__header">
<span class="sky-settings-range-row__title">{{ title }}</span>
<span class="sky-settings-range-row__value">{{ visibleValue }}</span>
</div>
<div class="sky-settings-range-row__control">
<span
v-if="$slots.leading"
class="sky-settings-range-row__endpoint sky-settings-range-row__endpoint--leading"
aria-hidden="true"
>
<slot name="leading" />
</span>
<SkyRange
:aria-label="title"
:aria-value-text="accessibleValue"
:disabled="disabled"
:max="max"
:min="min"
:model-value="effectiveValue"
:step="step"
@change="emit('change', $event)"
@input="emit('input', $event)"
@update:model-value="emit('update:modelValue', $event)"
/>
<span
v-if="$slots.trailing"
class="sky-settings-range-row__endpoint sky-settings-range-row__endpoint--trailing"
aria-hidden="true"
>
<slot name="trailing" />
</span>
</div>
</div>
</li>
</template>
+7 -2
View File
@@ -18,7 +18,12 @@ import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import type {
MediaConfig,
MediaType,
PhoneMedia,
UploadResult,
} from '@/types/media'
import { SkySegmented, SkySegmentedButton } from '@/ui'
import { createGameView, type GameView } from '@/utils/gameView'
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
@@ -416,7 +421,7 @@ onMounted(() => {
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
void nuiCall<{ videoBitrateKbps?: number }>('media:config').then(
void nuiCall<MediaConfig>('media:config').then(
(response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
@@ -12,6 +12,14 @@ const headerActions = source.slice(
)
describe('GalleryApp import action', () => {
it('opens a configured wallpaper upload and returns the imported photo directly', () => {
expect(source).toContain("route.query.wallpaperUpload === '1'")
expect(source).toContain("requestedMessageMedia.value === 'photo'")
expect(source).toContain('openImport()')
expect(source).toContain('messageMedia.complete(response.data)')
expect(source).toContain('await router.push(returnPath)')
})
it('keeps import available as a header button', () => {
expect(headerActions).toContain('<SkyToolbarPane')
expect(headerActions).toContain('gallery-header-tool--icon')
+25 -2
View File
@@ -47,6 +47,7 @@ import type {
GallerySortOrder,
MediaImportSource,
MediaImportSources,
MediaType,
PhoneMedia,
} from '@/types/media'
import {
@@ -77,7 +78,7 @@ const easyShare = useEasyShareStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
@@ -414,7 +415,19 @@ async function loadImportSources(): Promise<void> {
if (isDevelopment && !developmentApiEnabled) return
const response = await nuiCall<MediaImportSources>('media:import:sources')
if (!response.success || !response.data) return
importSources.value = response.data.sources
const requestedType = requestedMessageMedia.value
importSources.value = requestedType
? response.data.sources.filter((source) =>
source.mediaTypes.includes(requestedType),
)
: response.data.sources
if (
route.query.wallpaperUpload === '1' &&
requestedType === 'photo' &&
importSources.value.length > 0
) {
openImport()
}
}
function selectImportSource(source: MediaImportSource): void {
@@ -473,6 +486,16 @@ async function commitUrlImport(): Promise<void> {
}
media.value = mergeMedia(media.value, [response.data])
await fetchCounts()
if (
route.query.wallpaperUpload === '1' &&
requestedMessageMedia.value === 'photo'
) {
const returnPath = messageMedia.complete(response.data)
if (returnPath) {
await router.push(returnPath)
return
}
}
closeImport()
showToast(phone.t('Apps.photos.import.linkCompleted'))
}
@@ -19,6 +19,9 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySettingsGroup')
expect(source).toContain('<SkySettingsRow')
expect(source).toContain('<SkySettingsRangeRow')
expect(source).toContain('<template #leading><Volume1 /></template>')
expect(source).toContain('<template #trailing><Volume2 /></template>')
expect(source).toContain(
`<template v-if="activeView === 'account' && !account.email" #right>`,
)
@@ -36,9 +39,14 @@ describe('SettingsApp Sky UI contract', () => {
)
})
it('offers photo, camera, recent, and two-column built-in wallpaper choices', () => {
it('offers photo, camera, configured custom upload, recent, and built-in wallpaper choices', () => {
expect(source).toContain("openWallpaperMedia('photos')")
expect(source).toContain("openWallpaperMedia('camera')")
expect(source).toContain('customWallpaperUploadAvailable')
expect(source).toContain('openWallpaperCustomUpload')
expect(source).toContain("nuiCall<MediaConfig>('media:config')")
expect(source).toContain('nuiCall<MediaImportSources>')
expect(source).toContain("'media:import:sources'")
expect(source).toContain('`settings:wallpaper:${wallpaperTarget.value}`')
expect(source).toContain("wallpaperTarget === 'home'")
expect(source).toContain("wallpaperTarget === 'lock'")
+86 -6
View File
@@ -17,7 +17,9 @@ import {
Signal,
Smartphone,
Sun,
Upload,
UserRound,
Volume1,
Volume2,
Wifi,
} from 'lucide-vue-next'
@@ -47,6 +49,7 @@ import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
} from '@/types/apps'
import type { MediaConfig, MediaImportSources } from '@/types/media'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
@@ -159,6 +162,8 @@ const simEjectOpened = ref(false)
const factoryResetting = ref(false)
const factoryResetProgress = ref(0)
const wallpaperTarget = ref<WallpaperTarget>('home')
const customWallpaperUploadAvailable = ref(false)
let wallpaperMediaConfigLoaded = false
const selectedFrameColor = computed(
() => PHONE_FRAME_COLORS[phone.preferences.settings.frame],
)
@@ -299,6 +304,44 @@ function openWallpaperMedia(app: 'photos' | 'camera'): void {
})
}
async function loadWallpaperMediaConfig(): Promise<void> {
if (wallpaperMediaConfigLoaded) return
wallpaperMediaConfigLoaded = true
const configResponse = await nuiCall<MediaConfig>('media:config')
if (
!configResponse.success ||
configResponse.data?.customWallpaperUploadEnabled !== true
) {
return
}
const sourcesResponse = await nuiCall<MediaImportSources>(
'media:import:sources',
)
customWallpaperUploadAvailable.value =
sourcesResponse.success &&
Boolean(
sourcesResponse.data?.sources.some((source) =>
source.mediaTypes.includes('photo'),
),
)
}
function openWallpaperCustomUpload(): void {
const target = wallpaperTarget.value
mediaPicker.begin(
`settings:wallpaper:${target}`,
'photo',
`/apps/settings?wallpaper=1&wallpaperTarget=${target}`,
1,
)
void router.push({
path: '/apps/photos',
query: { mediaAttachment: 'photo', wallpaperUpload: '1' },
})
}
function wallpaperPreviewStyle(
entry: WallpaperHistoryEntry,
): Record<string, string> | undefined {
@@ -404,6 +447,7 @@ function openView(view: SubmenuView): void {
if (view === 'security') {
passcodeLength.value = phone.security.length ?? 6
}
if (view === 'wallpaper') void loadWallpaperMediaConfig()
activeView.value = view
scrollPageToTop()
}
@@ -700,6 +744,7 @@ onMounted(() => {
activeView.value = 'wallpaper'
wallpaperTarget.value =
route.query.wallpaperTarget === 'lock' ? 'lock' : 'home'
void loadWallpaperMediaConfig()
}
const selectedPhoto = mediaPicker.consume(
@@ -1198,7 +1243,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('ringtoneVolume', $event)
"
/>
>
<template #leading><Volume1 /></template>
<template #trailing><Volume2 /></template>
</SkySettingsRangeRow>
<SkySettingsRangeRow
:model-value="phone.preferences.settings.notificationVolume"
:title="phone.t('Apps.settings.notificationVolume')"
@@ -1211,7 +1259,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('notificationVolume', $event)
"
/>
>
<template #leading><Volume1 /></template>
<template #trailing><Volume2 /></template>
</SkySettingsRangeRow>
</SkySettingsGroup>
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
@@ -1458,7 +1509,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('screenBrightness', $event)
"
/>
>
<template #leading><Sun /></template>
<template #trailing><Sun /></template>
</SkySettingsRangeRow>
</SkySettingsGroup>
<SkySettingsGroup :aria-label="phone.t('Apps.settings.phoneScale')">
@@ -1471,7 +1525,10 @@ onBeforeUnmount(() => {
:max="PHONE_SCALE_MAX"
:step="PHONE_SCALE_STEP"
@update:model-value="updateNumberPreference('phoneScale', $event)"
/>
>
<template #leading><Smartphone /></template>
<template #trailing><Smartphone /></template>
</SkySettingsRangeRow>
</SkySettingsGroup>
<SkySettingsGroup :title="phone.t('Apps.settings.phoneFrame')">
@@ -1541,6 +1598,24 @@ onBeforeUnmount(() => {
}}</small>
</span>
</button>
<button
v-if="customWallpaperUploadAvailable"
type="button"
class="settings-wallpaper-actions__custom"
@click="openWallpaperCustomUpload"
>
<span class="settings-wallpaper-actions__icon" aria-hidden="true">
<Upload />
</span>
<span>
<strong>{{
phone.t('Apps.settings.wallpaperCustomUpload')
}}</strong>
<small>{{
phone.t('Apps.settings.wallpaperCustomUploadDescription')
}}</small>
</span>
</button>
</section>
<section
@@ -1938,6 +2013,11 @@ onBeforeUnmount(() => {
transform: scale(0.97);
}
.settings-wallpaper-actions > .settings-wallpaper-actions__custom {
grid-column: 1 / -1;
min-height: 92px;
}
.settings-wallpaper-actions > button:focus-visible,
.settings-wallpaper-history > button:focus-visible,
.settings-wallpaper-choice:focus-visible {
@@ -1951,8 +2031,8 @@ onBeforeUnmount(() => {
display: grid;
place-items: center;
border-radius: 12px;
background: var(--sky-app-accent-soft);
color: var(--sky-app-accent);
background: var(--sky-surface-muted);
color: #fff;
}
.settings-wallpaper-actions__icon > svg {
+2
View File
@@ -45,6 +45,8 @@ local translations = {
["Share"] = "Teilen",
["Start"] = "Starten",
["Stop"] = "Stoppen",
["Upload Custom Image"] = "Eigenes Bild hochladen",
["Use a verified HTTPS image link"] = "Verwende einen geprüften HTTPS-Bildlink",
["Use"] = "Verwenden",
["Yes"] = "Ja",
["Today"] = "Heute",
+1
View File
@@ -1930,6 +1930,7 @@ Locales["en"] = {
back = "Settings", wallpaperPicker = "Sky Wallpapers", deviceInformation = "Device Information",
wallpaperFromPhotos = "Choose from Photos", wallpaperFromPhotosDescription = "Use one of your photos",
wallpaperFromCamera = "Take Photo", wallpaperFromCameraDescription = "Create a new wallpaper",
wallpaperCustomUpload = "Upload Custom Image", wallpaperCustomUploadDescription = "Use a verified HTTPS image link",
wallpaperHistory = "Recent", wallpaperCustom = "Photo wallpaper",
wallpaperTarget = "Wallpaper destination", wallpaperHomeScreen = "Home Screen", wallpaperLockScreen = "Lock Screen",
imei = "IMEI", linkedDevices = "Linked Devices", thisDevice = "This Phone", removeDevice = "Remove Device",
+6
View File
@@ -64,6 +64,12 @@ Config.Media = {
},
},
Wallpaper = {
-- Shows the verified HTTPS media import directly in the wallpaper picker.
-- Camera and Photos wallpaper sources are not affected by this setting.
CustomUploadEnabled = true,
},
Photo = {
Encoding = "jpg",
Quality = 0.95,
+3
View File
@@ -643,9 +643,12 @@ Bridge.Callbacks.Register("sky_phone:media:config", function(source)
if not owner then
return error_response
end
local wallpaper_config = type(Config.Media.Wallpaper) == "table" and Config.Media.Wallpaper or {}
return {
success = true,
data = {
customWallpaperUploadEnabled = wallpaper_config.CustomUploadEnabled == true
and Config.Media.Import.Enabled == true,
videoBitrateKbps = tonumber(Config.Media.Video.BitrateKbps) or 1500,
},
}