MERGE - integrate latest dev changes

This commit is contained in:
Dominik
2026-08-09 05:39:11 +02:00
57 changed files with 2725 additions and 70 deletions
+1
View File
@@ -127,6 +127,7 @@ function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: 'short',
}).format(timestamp)
+3
View File
@@ -236,6 +236,7 @@ function eventsForDay(day: Date): CalendarEvent[] {
function formatTime(value: number): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(value)
}
@@ -1584,6 +1585,7 @@ onMounted(async () => {
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-bottom: 1px solid var(--line);
background: var(--bg);
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@@ -1902,6 +1904,7 @@ onMounted(async () => {
border-bottom: 0;
border-radius: 28px 28px 0 0;
overflow: hidden;
background: var(--elevated);
background: color-mix(in srgb, var(--elevated) 94%, transparent);
box-shadow: 0 -18px 60px rgb(0 0 0 / 38%);
backdrop-filter: blur(30px) saturate(180%);
+1
View File
@@ -345,6 +345,7 @@ function relativeDate(value: DatabaseDateValue): string {
function messageTime(value: DatabaseDateValue): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(parseDatabaseDate(value))
}
+1
View File
@@ -81,6 +81,7 @@ const timerPicker = computed({
const currentTime = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
timeZone: browserTimeZone,
}).format(now.value),
+1
View File
@@ -238,6 +238,7 @@ function formatDate(value: DatabaseDateValue): string {
if (date.toDateString() === today.toDateString()) {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
+2
View File
@@ -185,6 +185,7 @@ function formatDate(value: string): string {
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: 'short',
}).format(date)
@@ -197,6 +198,7 @@ function formatListDate(value: string): string {
if (date.toDateString() === today.toDateString()) {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
+2
View File
@@ -187,6 +187,7 @@ function formatConversationDate(value: DatabaseDateValue): string {
if (date.toDateString() === today.toDateString()) {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
@@ -229,6 +230,7 @@ function timeLabel(value: DatabaseDateValue): string {
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
+5 -1
View File
@@ -295,7 +295,11 @@ function togglePinned(): void {
<k-popover
:opened="menuOpened"
:target="menuTarget"
:class="{ dark: phone.isDarkMode }"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
angle
@backdropclick="menuOpened = false"
>
+563
View File
@@ -0,0 +1,563 @@
<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kList,
kListInput,
kListItem,
kNavbar,
kPage,
kPreloader,
kRange,
kSegmented,
kSegmentedButton,
kToast,
kToggle,
} from 'konsta/vue'
import {
Clock3,
RadioTower,
Settings,
Signal,
Users,
Volume2,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import { useRadioStore } from '@/stores/radio'
import type { RadioHistoryEntry } from '@/types/radio'
type RadioTab = 'radio' | 'settings'
const phone = usePhoneStore()
const radio = useRadioStore()
const tab = ref<RadioTab>('radio')
const primaryInput = ref('')
const secondaryInput = ref('')
const badgeInput = ref('')
const displayNameInput = ref('')
const feedback = ref('')
const now = ref(Date.now())
const memberSnapshotAt = ref(Date.now())
let clockHandle: number | null = null
const statusText = computed(() => {
if (!radio.data.connected) return phone.t('Apps.radio.disconnected')
const secondary = radio.data.secondaryFrequency
? ` / ${radio.data.secondaryFrequency}`
: ''
return phone.t('Apps.radio.connectedTo', {
frequency: `${radio.data.frequency}${secondary}`,
})
})
function eventValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function parseFrequency(value: string): number {
return Number.parseFloat(value.replace(',', '.'))
}
function errorText(code: string): string {
return phone.t(`Apps.radio.errors.${code || 'default'}`)
}
async function connect(
primary = parseFrequency(primaryInput.value),
secondary = parseFrequency(secondaryInput.value) || 0,
): Promise<void> {
if (!Number.isFinite(primary)) {
radio.error = 'invalid_frequency'
return
}
const connected = await radio.connect(primary, secondary)
if (connected) {
memberSnapshotAt.value = Date.now()
primaryInput.value = String(radio.data.frequency)
secondaryInput.value = radio.data.secondaryFrequency
? String(radio.data.secondaryFrequency)
: ''
}
}
async function disconnect(): Promise<void> {
await radio.disconnect()
}
function connectHistory(entry: RadioHistoryEntry): void {
primaryInput.value = String(entry.primary)
secondaryInput.value = entry.secondary ? String(entry.secondary) : ''
void connect(entry.primary, entry.secondary)
}
function formatDuration(joinTime: number): string {
const seconds = Math.max(
0,
joinTime + Math.floor((now.value - memberSnapshotAt.value) / 1000),
)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours) return `${hours}h ${minutes % 60}m`
if (minutes) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
function normalizedBadge(): string {
const clean = badgeInput.value
.replace(/[^A-Za-z0-9_-]/g, '')
.slice(0, radio.data.badgeMaxLength)
badgeInput.value = clean
return clean
}
function normalizedDisplayName(): string {
const clean = Array.from(
displayNameInput.value
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim(),
)
.slice(0, radio.data.displayNameMaxLength)
.join('')
displayNameInput.value = clean
return clean
}
async function saveRadioProfile(): Promise<void> {
if (radio.data.displayNameEnabled && radio.data.displayNameAllowed) {
const displayNameSaved = await radio.saveDisplayName(
normalizedDisplayName(),
)
if (!displayNameSaved) {
feedback.value = errorText(radio.error)
window.setTimeout(() => (feedback.value = ''), 2500)
return
}
}
if (radio.data.badgeEnabled) {
const badgeSaved = await radio.saveBadge(normalizedBadge())
if (!badgeSaved) {
feedback.value = errorText(radio.error)
window.setTimeout(() => (feedback.value = ''), 2500)
return
}
}
feedback.value = phone.t('Apps.radio.profileSaved')
window.setTimeout(() => (feedback.value = ''), 2500)
}
function onMessage(event: MessageEvent): void {
if (event.data?.type === 'radio:updated' && event.data.data?.members) {
memberSnapshotAt.value = Date.now()
radio.updateMembers(event.data.data.members)
}
}
onMounted(async () => {
window.addEventListener('message', onMessage)
clockHandle = window.setInterval(() => (now.value = Date.now()), 1000)
await radio.load()
memberSnapshotAt.value = Date.now()
badgeInput.value = radio.data.badge
displayNameInput.value = radio.data.displayName
if (radio.data.frequency) primaryInput.value = String(radio.data.frequency)
if (radio.data.secondaryFrequency)
secondaryInput.value = String(radio.data.secondaryFrequency)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
if (clockHandle) window.clearInterval(clockHandle)
})
</script>
<template>
<k-page component="main" class="radio-app">
<k-navbar :title="phone.t('Apps.radio.name')">
<template #subnavbar>
<k-segmented :key="tab" strong rounded class="radio-tabs">
<k-segmented-button :active="tab === 'radio'" @click="tab = 'radio'">
<RadioTower :size="17" />
{{ phone.t('Apps.radio.tabs.radio') }}
</k-segmented-button>
<k-segmented-button
:active="tab === 'settings'"
@click="tab = 'settings'"
>
<Settings :size="17" />
{{ phone.t('Apps.radio.tabs.settings') }}
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
<div v-if="radio.isLoading && !radio.data.provider" class="radio-loading">
<k-preloader />
{{ phone.t('Common.loading') }}
</div>
<template v-else-if="tab === 'radio'">
<k-block class="radio-status" strong inset>
<Signal :size="22" :class="{ 'radio-online': radio.data.connected }" />
<div>
<strong>{{ statusText }}</strong>
<small>{{
radio.data.provider ?? phone.t('Apps.radio.noProvider')
}}</small>
</div>
<i :class="{ 'radio-status-dot--online': radio.data.connected }"></i>
</k-block>
<k-block-title>{{ phone.t('Apps.radio.channel') }}</k-block-title>
<k-list strong inset>
<k-list-input
type="number"
:label="phone.t('Apps.radio.primaryFrequency')"
:placeholder="phone.t('Apps.radio.frequencyPlaceholder')"
:min="radio.data.frequencyMin"
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
:value="primaryInput"
@input="primaryInput = eventValue($event)"
>
<template #after>{{ phone.t('Apps.radio.mhz') }}</template>
</k-list-input>
<k-list-input
v-if="radio.data.secondarySupported"
type="number"
:label="phone.t('Apps.radio.secondaryFrequency')"
:placeholder="phone.t('Apps.radio.optional')"
:min="radio.data.frequencyMin"
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
:value="secondaryInput"
@input="secondaryInput = eventValue($event)"
>
<template #after>{{ phone.t('Apps.radio.mhz') }}</template>
</k-list-input>
<k-list-item :title="phone.t('Apps.radio.volume')">
<template #media><Volume2 :size="20" /></template>
<template #inner>
<div class="radio-volume">
<k-range
:value="radio.data.volume"
:min="0"
:max="100"
:step="1"
:aria-label="phone.t('Apps.radio.volume')"
@input="radio.setVolume(Number(eventValue($event)))"
/>
<span>{{ radio.data.volume }}%</span>
</div>
</template>
</k-list-item>
</k-list>
<k-block inset class="radio-action-block">
<k-button
v-if="!radio.data.connected"
large
rounded
:disabled="radio.isLoading"
@click="connect()"
>
{{ phone.t('Apps.radio.connect') }}
</k-button>
<k-button
v-else
large
rounded
class="radio-disconnect"
@click="disconnect"
>
{{ phone.t('Apps.radio.disconnect') }}
</k-button>
<p v-if="radio.error" class="radio-error">
{{ errorText(radio.error) }}
</p>
</k-block>
<template v-if="radio.data.connected">
<k-block-title>
<Users :size="16" />
{{
phone.t('Apps.radio.members', {
count: String(radio.data.members.length),
})
}}
</k-block-title>
<k-list strong inset>
<k-list-item
v-for="member in radio.data.members"
:key="member.id"
:title="member.name"
:subtitle="formatDuration(member.joinTime)"
:after="member.rank || String(member.rankNumber || '')"
/>
<k-list-item
v-if="!radio.data.members.length"
:title="phone.t('Apps.radio.noMembers')"
/>
</k-list>
</template>
<template v-else>
<k-block-title>
<Clock3 :size="16" />
{{ phone.t('Apps.radio.history') }}
</k-block-title>
<k-list strong inset>
<k-list-item
v-for="entry in radio.data.history"
:key="`${entry.primary}-${entry.secondary}`"
link
:title="`${entry.primary}${entry.secondary ? ` / ${entry.secondary}` : ''} ${phone.t('Apps.radio.mhz')}`"
@click="connectHistory(entry)"
/>
<k-list-item
v-if="!radio.data.history.length"
:title="phone.t('Apps.radio.noHistory')"
/>
</k-list>
</template>
</template>
<template v-else>
<template v-if="radio.data.displayNameEnabled">
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.displayName') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-input
type="text"
:disabled="!radio.data.displayNameAllowed"
:maxlength="radio.data.displayNameMaxLength"
:placeholder="phone.t('Apps.radio.displayNamePlaceholder')"
:value="displayNameInput"
@input="displayNameInput = eventValue($event)"
/>
</k-list>
<k-block class="radio-hint-block">
<p class="radio-setting-hint">
{{
phone.t(
radio.data.displayNameAllowed
? 'Apps.radio.displayNameDescription'
: 'Apps.radio.displayNameNotAllowed',
)
}}
</p>
</k-block>
</template>
<template v-if="radio.data.badgeEnabled">
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.badge') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-input
type="text"
:maxlength="radio.data.badgeMaxLength"
:placeholder="phone.t('Apps.radio.badgePlaceholder')"
:value="badgeInput"
@input="
badgeInput = eventValue($event).replace(/[^A-Za-z0-9_-]/g, '')
"
/>
</k-list>
</template>
<k-block
v-if="
radio.data.badgeEnabled ||
(radio.data.displayNameEnabled && radio.data.displayNameAllowed)
"
inset
class="radio-action-block radio-profile-action"
>
<k-button rounded large @click="saveRadioProfile">
{{ phone.t('Common.save') }}
</k-button>
</k-block>
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.otherSettings') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-item
class="radio-setting-row"
:title="phone.t('Apps.radio.autoRejoin')"
:subtitle="phone.t('Apps.radio.autoRejoinDescription')"
>
<template #after>
<k-toggle
:checked="radio.data.settings.autoRejoin"
@change="
radio.saveSetting('autoRejoin', !radio.data.settings.autoRejoin)
"
/>
</template>
</k-list-item>
<k-list-item
class="radio-setting-row"
:title="phone.t('Apps.radio.radioNotifications')"
:subtitle="phone.t('Apps.radio.notificationsDescription')"
>
<template #after>
<k-toggle
:checked="radio.data.settings.notifications"
@change="
radio.saveSetting(
'notifications',
!radio.data.settings.notifications,
)
"
/>
</template>
</k-list-item>
</k-list>
</template>
<k-toast
:opened="Boolean(feedback)"
position="center"
@click="feedback = ''"
>
{{ feedback }}
</k-toast>
</k-page>
</template>
<style scoped>
.radio-app {
--radio-blue: #0a84ff;
overflow-y: auto;
padding-bottom: 34px;
}
.radio-tabs :deep(button) {
align-items: center;
display: flex;
gap: 6px;
justify-content: center;
}
.radio-loading {
align-items: center;
display: flex;
gap: 10px;
justify-content: center;
min-height: 240px;
}
.radio-status {
align-items: center;
display: grid;
gap: 12px;
grid-template-columns: auto 1fr auto;
margin-top: 18px;
}
.radio-status div {
display: flex;
flex-direction: column;
min-width: 0;
}
.radio-status small {
color: var(--k-color-subtitle, #8e8e93);
margin-top: 2px;
text-transform: capitalize;
}
.radio-status i {
background: #c7c7cc;
border-radius: 50%;
height: 10px;
width: 10px;
}
.radio-status .radio-status-dot--online {
background: #30d158;
}
.radio-online {
color: #30d158;
}
.radio-volume {
align-items: center;
display: grid;
gap: 12px;
grid-template-columns: 1fr 44px;
width: 100%;
}
.radio-volume span {
color: var(--k-color-subtitle, #8e8e93);
font-variant-numeric: tabular-nums;
text-align: right;
}
.radio-action-block {
padding-left: 0;
padding-right: 0;
}
.radio-setting-hint {
color: inherit;
font-size: 16px;
font-weight: 450;
line-height: 1.45;
margin: 0;
opacity: 0.82;
}
.radio-hint-block {
margin-bottom: 0;
margin-top: 8px;
}
.radio-settings-title {
margin-bottom: 6px;
margin-top: 16px;
}
.radio-settings-list {
margin-bottom: 0;
margin-top: 0;
}
.radio-profile-action {
margin-bottom: 0;
margin-top: 12px;
}
.radio-setting-row :deep(.text-sm) {
font-size: 15px;
line-height: 1.35;
}
.radio-error {
color: #ff3b30;
font-size: 13px;
margin: 10px 4px 0;
text-align: center;
}
.radio-disconnect {
background: #ff3b30 !important;
color: #fff !important;
}
:deep(.k-block-title) {
align-items: center;
display: flex;
gap: 6px;
}
</style>
+46 -4
View File
@@ -66,6 +66,7 @@ import { nuiCall } from '@/utils/nui'
import { formatPhoneNumber } from '@/utils/phone'
import {
APPEARANCE_MODE_IDS,
GRAPHICS_MODE_IDS,
NOTIFICATION_SOUND_IDS,
PHONE_FRAME_IDS,
PHONE_SCALE_MAX,
@@ -74,6 +75,7 @@ import {
RINGTONE_IDS,
WALLPAPER_IDS,
type AppearanceMode,
type GraphicsMode,
type NotificationSoundId,
type PhoneFrameId,
type RingtoneId,
@@ -111,7 +113,17 @@ type PasscodeFlow =
const FACTORY_RESET_DURATION_MS = 60_000
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
const FRAME_PICKER_WIDTH = 240
const FRAME_PICKER_HEIGHT = 140
const FRAME_PICKER_COLUMN_COUNT = 3
const FRAME_PICKER_SWATCH_SIZE = 40
const FRAME_PICKER_GRID_GAP = 20
const FRAME_PICKER_PADDING = 20
const FRAME_PICKER_ROW_COUNT = Math.ceil(
PHONE_FRAME_IDS.length / FRAME_PICKER_COLUMN_COUNT,
)
const FRAME_PICKER_HEIGHT =
FRAME_PICKER_PADDING * 2 +
FRAME_PICKER_ROW_COUNT * FRAME_PICKER_SWATCH_SIZE +
Math.max(0, FRAME_PICKER_ROW_COUNT - 1) * FRAME_PICKER_GRID_GAP
const FRAME_PICKER_INSET = 8
const FRAME_PICKER_GAP = 8
@@ -465,6 +477,10 @@ function selectAppearanceMode(mode: AppearanceMode): void {
phone.setPreference('appearanceMode', mode)
}
function selectGraphicsMode(mode: GraphicsMode): void {
phone.setPreference('graphicsMode', mode)
}
function selectFrame(frame: PhoneFrameId): void {
phone.setPreference('frame', frame)
framePickerOpened.value = false
@@ -1357,6 +1373,28 @@ onBeforeUnmount(() => {
</template>
<template v-else-if="activeView === 'appearance'">
<k-block-title>
{{ phone.t('Apps.settings.graphicsMode') }}
</k-block-title>
<k-list strong inset>
<k-list-item
v-for="mode in GRAPHICS_MODE_IDS"
:key="mode"
link
:chevron="false"
:title="phone.t(`Apps.settings.${mode}Mode`)"
:subtitle="phone.t(`Apps.settings.${mode}ModeDescription`)"
@click="selectGraphicsMode(mode)"
>
<template #after>
<Check
v-if="phone.preferences.settings.graphicsMode === mode"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.appearanceMode') }}
</k-block-title>
@@ -1451,7 +1489,7 @@ onBeforeUnmount(() => {
<template #after>
<span
class="h-7 w-7 rounded-full border border-black/15 shadow-sm"
:style="{ backgroundColor: selectedFrameColor }"
:style="{ background: selectedFrameColor }"
aria-hidden="true"
/>
</template>
@@ -1463,7 +1501,11 @@ onBeforeUnmount(() => {
:opened="framePickerOpened"
:class="[
'settings-frame-popover !absolute !z-[200] !left-[var(--settings-frame-picker-left)] !top-[var(--settings-frame-picker-top)]',
{ dark: phone.isDarkMode },
{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
},
]"
@backdropclick="framePickerOpened = false"
>
@@ -1477,7 +1519,7 @@ onBeforeUnmount(() => {
:key="frame"
type="button"
class="h-10 w-10 rounded-full border border-black/15 shadow-sm"
:style="{ backgroundColor: PHONE_FRAME_COLORS[frame] }"
:style="{ background: PHONE_FRAME_COLORS[frame] }"
:aria-label="phone.t(`Apps.settings.frames.${frame}`)"
:aria-pressed="phone.preferences.settings.frame === frame"
@click="selectFrame(frame)"
+2 -1
View File
@@ -32,7 +32,8 @@ function conditionLabel(condition: WeatherConditionId): string {
function formatHour(timestamp: number, index: number): string {
if (index === 0) return phone.t('Apps.weather.now')
return new Intl.DateTimeFormat(phone.lang, {
hour: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
}).format(timestamp)
}