Merge branch 'dev' into feature/funk

This commit is contained in:
Alec Schitzkat
2026-08-09 00:46:04 +02:00
59 changed files with 11998 additions and 1582 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# sky_phone
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `sky_phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data.
@@ -8,7 +8,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m
- ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`.
- A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata.
- A non-stackable inventory item named `sky_phone`.
- A non-stackable inventory item named `phone`.
- Two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number.
- `oxmysql` with MySQL/MariaDB.
- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`.
@@ -58,7 +58,7 @@ For `ox_inventory`, configure all three items with `stack = false` and `consume
Example `ox_inventory/data/items.lua` entries:
```lua
["sky_phone"] = {
["phone"] = {
label = "iFruit Phone",
weight = 200,
stack = false,
+141 -9
View File
@@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
@@ -33,6 +34,7 @@ import { useDarkChatStore } from '@/stores/darkchat'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useWeatherStore } from '@/stores/weather'
@@ -131,6 +133,7 @@ const darkchat = useDarkChatStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
const notes = useNotesStore()
const weather = useWeatherStore()
const notifications = useNotificationsStore()
@@ -142,13 +145,21 @@ const appTransitionName = computed(() =>
)
const isLocked = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
const passcodeError = ref('')
const passcodeResetKey = ref(0)
const passcodeRetrySeconds = ref(0)
const passcodeVisible = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
const phoneBaseZoom = computed(() =>
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
const phoneBaseZoom = computed(
() =>
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
)
const phoneResolutionStyle = computed<CSSProperties>(() => ({
'--phone-edge-gap': `${24 * viewportScale.value}px`,
@@ -166,6 +177,7 @@ const phoneFrameImage = computed(
)
let clockTicker: ReturnType<typeof setInterval> | undefined
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -182,12 +194,18 @@ function hydratePhone(payload: PhoneOpenPayload): void {
games.hydrate(payload.device?.data.games?.payload)
media.hydrate(payload.device?.data.media?.payload)
appStore.hydrate(payload.device?.data.apps?.payload)
void mail.bootstrap(payload.account?.email ?? '')
if (payload.account?.email) void marketplace.loadCounts()
widgets.hydrate(payload.device?.data.widgets?.payload)
}
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
void mail.bootstrap(account.email)
if (account.email) void marketplace.loadCounts()
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
if (payload.account?.email) void darkchat.bootstrap()
if (account.email) void darkchat.bootstrap()
}
async function hydrateDevelopmentPhone(): Promise<void> {
@@ -389,8 +407,11 @@ function onMessage(event: MessageEvent<AppMessage>): void {
) {
calls.applyCallState(event.data.data as PhoneCall)
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
if (!phone.security.enabled) {
isLocked.value = false
isUnlocking.value = false
loadUnlockedPhoneData()
}
window.setTimeout(() => void router.push('/apps/phone'), 0)
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
@@ -417,14 +438,78 @@ function updateViewportScale(): void {
viewportScale.value = getViewportScale()
}
function unlockPhone(): void {
function finishUnlock(): void {
if (!isLocked.value) return
isUnlocking.value = true
isLocked.value = false
passcodeVisible.value = false
passcodeError.value = ''
unlockTimer = window.setTimeout(() => {
isUnlocking.value = false
}, 720)
if (pendingUnlockRoute.value) {
const routePath = pendingUnlockRoute.value
pendingUnlockRoute.value = null
window.setTimeout(() => void router.push(routePath), 0)
}
loadUnlockedPhoneData()
}
function unlockPhone(): void {
if (!isLocked.value) return
if (phone.security.enabled) {
passcodeError.value = ''
passcodeVisible.value = true
return
}
finishUnlock()
}
function cancelPasscode(): void {
if (passcodeBusy.value) return
passcodeVisible.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
}
function startPasscodeLock(seconds: number): void {
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds))
passcodeLockTimer = window.setInterval(() => {
passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1)
if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) {
window.clearInterval(passcodeLockTimer)
passcodeLockTimer = undefined
passcodeError.value = ''
}
}, 1000)
}
async function submitUnlockPasscode(passcode: string): Promise<void> {
if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return
passcodeBusy.value = true
const response = await phone.unlockWithPasscode(passcode)
passcodeBusy.value = false
if (response.success) {
finishUnlock()
return
}
passcodeResetKey.value += 1
if (response.error === 'passcode_locked') {
startPasscodeLock(response.data?.retryAfter ?? 30)
passcodeError.value = phone.t('LockScreen.passcode.locked', {
seconds: String(response.data?.retryAfter ?? 30),
})
return
}
if (response.error === 'rate_limited') {
passcodeError.value = phone.t('LockScreen.passcode.rateLimited')
return
}
passcodeError.value = phone.t('LockScreen.passcode.incorrect')
}
function toggleControlCenter(): void {
@@ -433,6 +518,11 @@ function toggleControlCenter(): void {
}
function unlockCamera(): void {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
unlockPhone()
return
}
unlockPhone()
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
@@ -521,12 +611,33 @@ watch(
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
unlockedServicesLoaded.value = false
if (passcodeLockTimer !== undefined) {
window.clearInterval(passcodeLockTimer)
passcodeLockTimer = undefined
}
return
}
isLocked.value = true
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
weather.start()
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
passcodeResetKey.value += 1
passcodeRetrySeconds.value = Math.max(
0,
(phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000),
)
if (passcodeRetrySeconds.value > 0) {
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
void router.replace('/')
},
@@ -543,6 +654,7 @@ onBeforeUnmount(() => {
weather.stop()
if (clockTicker) clearInterval(clockTicker)
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
@@ -635,6 +747,26 @@ onBeforeUnmount(() => {
@unlock="unlockPhone"
/>
</Transition>
<Transition name="lock-screen">
<PhonePasscode
v-if="isLocked && passcodeVisible"
:busy="passcodeBusy"
:disabled="passcodeRetrySeconds > 0"
:error="passcodeError"
:length="phone.security.length ?? 6"
:reset-key="passcodeResetKey"
:subtitle="
passcodeRetrySeconds > 0
? phone.t('LockScreen.passcode.tryAgain', {
seconds: String(passcodeRetrySeconds),
})
: phone.t('LockScreen.passcode.unlockSubtitle')
"
:title="phone.t('LockScreen.passcode.enter')"
@cancel="cancelPasscode"
@complete="submitUnlockPasscode"
/>
</Transition>
<PhoneNotifications
:notification="notifications.current"
@close="notifications.dismissCurrent()"
+1695 -282
View File
File diff suppressed because it is too large Load Diff
+37 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { kBadge } from 'konsta/vue'
import { Minus } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
@@ -27,6 +27,7 @@ const props = withDefaults(
const emit = defineEmits<{
dragcancel: []
dragend: [event: PointerEvent]
dragmove: [event: PointerEvent]
dragstart: [event: PointerEvent]
edit: []
remove: []
@@ -39,16 +40,21 @@ const darkchat = useDarkChatStore()
const router = useRouter()
const iconFailed = ref(false)
const isDragging = ref(false)
const calendarToday = ref(new Date())
const dragOffset = ref({ x: 0, y: 0 })
let dragStartPage = 0
let dragPageWidth = 0
const dragStyle = computed(() =>
isDragging.value
? {
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
}
: undefined,
)
const suppressClick = ref(false)
let holdTimer: number | undefined
let calendarTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
const unreadCount = computed(() => {
if (props.app.id === 'mail') return mail.counts.unread
@@ -60,6 +66,12 @@ const notificationBadgeColors = {
bg: 'bg-[#ff3b30]',
text: 'text-white',
}
const calendarWeekday = computed(() =>
new Intl.DateTimeFormat(phone.lang, { weekday: 'short' })
.format(calendarToday.value)
.replace(/\.$/, ''),
)
const calendarDay = computed(() => calendarToday.value.getDate())
function launch(event: MouseEvent): void {
if (props.editMode || suppressClick.value) {
@@ -124,6 +136,7 @@ function onPointerMove(event: PointerEvent): void {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
emit('dragmove', event)
return
}
if (
@@ -135,6 +148,11 @@ function onPointerMove(event: PointerEvent): void {
}
function beginPointerDrag(event: PointerEvent): void {
dragStartPage = phone.currentPage
dragPageWidth =
(event.target as HTMLElement)
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
@@ -167,8 +185,16 @@ function removeDragListeners(): void {
window.removeEventListener('pointercancel', cancelPointerDrag)
}
onMounted(() => {
if (props.app.id !== 'calendar') return
calendarTimer = window.setInterval(() => {
calendarToday.value = new Date()
}, 60_000)
})
onBeforeUnmount(() => {
clearHold()
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
removeDragListeners()
})
</script>
@@ -200,10 +226,17 @@ onBeforeUnmount(() => {
<span class="app-icon-anchor" aria-hidden="true">
<span
class="app-icon"
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
:class="[
app.iconClass,
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
]"
>
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
<strong>{{ calendarWeekday }}</strong>
<b>{{ calendarDay }}</b>
</span>
<img
v-if="!iconFailed"
v-else-if="!iconFailed"
:src="app.iconImage"
alt=""
draggable="false"
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { Delete } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const props = withDefaults(
defineProps<{
busy?: boolean
cancelable?: boolean
disabled?: boolean
error?: string
length: 4 | 6
resetKey?: number
subtitle?: string
title: string
}>(),
{
busy: false,
cancelable: true,
disabled: false,
error: '',
resetKey: 0,
subtitle: '',
},
)
const emit = defineEmits<{
cancel: []
complete: [passcode: string]
}>()
const phone = usePhoneStore()
const digits = ref('')
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const inputDisabled = computed(() => props.busy || props.disabled)
function enterDigit(digit: number): void {
if (inputDisabled.value || digits.value.length >= props.length) return
digits.value += String(digit)
if (digits.value.length === props.length) emit('complete', digits.value)
}
function removeDigit(): void {
if (inputDisabled.value) return
digits.value = digits.value.slice(0, -1)
}
watch(
() => props.resetKey,
() => {
digits.value = ''
},
)
</script>
<template>
<section class="passcode-screen" :aria-label="title">
<header class="passcode-screen__header">
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
<div class="passcode-screen__dots" aria-hidden="true">
<span
v-for="index in length"
:key="index"
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
></span>
</div>
<p
v-if="error"
class="passcode-screen__error"
role="alert"
>
{{ error }}
</p>
</header>
<div class="passcode-screen__keypad">
<button
v-for="digit in keypad"
:key="digit"
type="button"
:disabled="inputDisabled"
@click="enterDigit(digit)"
>
{{ digit }}
</button>
<button
type="button"
class="passcode-screen__action"
:disabled="!cancelable || busy"
@click="emit('cancel')"
>
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
</button>
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
0
</button>
<button
type="button"
class="passcode-screen__action"
:aria-label="phone.t('LockScreen.passcode.delete')"
:disabled="inputDisabled || digits.length === 0"
@click="removeDigit"
>
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
</button>
</div>
</section>
</template>
<style scoped>
.passcode-screen {
position: absolute;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 76px 28px 30px;
color: white;
background:
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
linear-gradient(160deg, #182139, #080b12 72%);
user-select: none;
}
.passcode-screen__header {
min-height: 170px;
text-align: center;
}
.passcode-screen__header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
}
.passcode-screen__header p {
max-width: 270px;
margin: 8px auto 0;
color: rgb(255 255 255 / 72%);
font-size: 13px;
line-height: 1.35;
}
.passcode-screen__dots {
display: flex;
justify-content: center;
gap: 14px;
margin-top: 28px;
}
.passcode-screen__dots span {
width: 12px;
height: 12px;
border: 1.5px solid rgb(255 255 255 / 78%);
border-radius: 50%;
transition: background-color 120ms ease, transform 120ms ease;
}
.passcode-screen__dots .passcode-screen__dot--filled {
background: white;
transform: scale(1.06);
}
.passcode-screen__header .passcode-screen__error {
color: #ff9b93;
font-weight: 500;
}
.passcode-screen__keypad {
display: grid;
grid-template-columns: repeat(3, 72px);
gap: 15px 20px;
align-items: center;
justify-items: center;
}
.passcode-screen__keypad button:not(.passcode-screen__action) {
width: 72px;
height: 72px;
border: 0;
border-radius: 50%;
color: white;
background: rgb(255 255 255 / 16%);
font-size: 30px;
font-weight: 400;
backdrop-filter: blur(18px);
transition: background-color 100ms ease, transform 100ms ease;
}
.passcode-screen__keypad button:not(.passcode-screen__action):active {
background: rgb(255 255 255 / 34%);
transform: scale(0.96);
}
.passcode-screen__keypad button:disabled {
opacity: 0.45;
}
.passcode-screen__keypad .passcode-screen__action {
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
min-height: 48px;
border: 0;
color: white;
background: transparent;
font-size: 14px;
}
</style>
@@ -0,0 +1,831 @@
<script setup lang="ts">
import {
Banknote,
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSun,
MessageCircle,
MoonStar,
Pause,
Phone,
Play,
SkipForward,
Snowflake,
Sun,
WalletCards,
} from 'lucide-vue-next'
import { kBadge, kGlass } from 'konsta/vue'
import { computed, onBeforeUnmount, ref, type Component } from 'vue'
import { useRouter } from 'vue-router'
import {
useBankService,
useClockService,
useContactsService,
useMusicService,
useWeatherService,
} from '@/services/widgetServices'
import { useCallsStore } from '@/stores/calls'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { WidgetInstance } from '@/types/widgets'
import type { WeatherConditionId } from '@/types/weather'
import { WIDGET_SPANS } from '@/utils/widgetLayout'
const props = withDefaults(
defineProps<{
editMode?: boolean
instance: WidgetInstance
interactive?: boolean
preview?: boolean
}>(),
{ editMode: false, interactive: true, preview: false },
)
const emit = defineEmits<{
dragcancel: []
dragend: [event: PointerEvent]
dragmove: [event: PointerEvent]
dragstart: [event: PointerEvent]
menu: []
remove: []
}>()
const phone = usePhoneStore()
const calls = useCallsStore()
const messages = useMessagesStore()
const router = useRouter()
const clock = useClockService()
const weather = useWeatherService()
const music = useMusicService()
const bank = useBankService()
const contactsService = useContactsService()
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const suppressClick = ref(false)
let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let dragStartPage = 0
let dragPageWidth = 0
const weatherIcons: Record<WeatherConditionId, Component> = {
sunny: Sun,
clear: MoonStar,
partly_cloudy: CloudSun,
cloudy: Cloud,
rain: CloudRain,
thunder: CloudLightning,
fog: CloudFog,
snow: Snowflake,
}
const span = computed(() => WIDGET_SPANS[props.instance.size])
const placementStyle = computed(() =>
props.preview
? undefined
: {
gridColumn: `${props.instance.column + 1} / span ${span.value.columns}`,
gridRow: `${props.instance.row + 1} / span ${span.value.rows}`,
translate: isDragging.value
? `${dragOffset.value.x}px ${dragOffset.value.y}px`
: undefined,
transform: isDragging.value
? `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px) scale(1.035)`
: undefined,
},
)
const forecastHigh = computed(() =>
weather.forecast.value?.hourly.length
? Math.max(
...weather.forecast.value.hourly.map((entry) => entry.temperature),
)
: null,
)
const forecastLow = computed(() =>
weather.forecast.value?.hourly.length
? Math.min(
...weather.forecast.value.hourly.map((entry) => entry.temperature),
)
: null,
)
const weatherIcon = computed(
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
)
const balance = computed(() =>
props.instance.settings.balanceSource === 'cash'
? bank.overview.value.cash
: bank.overview.value.bank,
)
const favoriteContacts = computed(() => {
const selected = props.instance.settings.contactIds ?? []
const ordered = selected.length
? selected
.map((id) =>
contactsService.contacts.value.find((contact) => contact.id === id),
)
.filter((contact) => contact !== undefined)
: contactsService.contacts.value
return ordered.slice(0, props.instance.size === 'large' ? 6 : 4)
})
const visibleTransactions = computed(() =>
bank.overview.value.transactions.slice(
0,
props.instance.size === 'large' ? 5 : 2,
),
)
const removeBadgeColors = {
bg: 'bg-[#d1d1d6]',
text: 'text-black',
}
function formatMoney(value: number): string {
return `${bank.overview.value.currency}${new Intl.NumberFormat(phone.lang, {
maximumFractionDigits: 0,
}).format(value)}`
}
function avatar(name: string): string {
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
}
function clearHold(): void {
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
holdTimer = undefined
}
function onPointerDown(event: PointerEvent): void {
if (
!props.interactive ||
props.preview ||
event.button !== 0 ||
(event.target as HTMLElement).closest('[data-widget-control]')
) {
return
}
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
beginDrag(event)
return
}
holdTimer = window.setTimeout(() => {
suppressClick.value = true
emit('menu')
holdTimer = undefined
}, 520)
}
function onPointerMove(event: PointerEvent): void {
if (isDragging.value) {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
emit('dragmove', event)
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
) {
clearHold()
}
}
function beginDrag(event: PointerEvent): void {
dragStartPage = phone.currentPage
dragPageWidth =
(event.currentTarget as HTMLElement)
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
isDragging.value = true
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', cancelDrag)
emit('dragstart', event)
}
function onPointerUp(event: PointerEvent): void {
clearHold()
if (!isDragging.value) return
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
}
function cancelDrag(): void {
clearHold()
if (!isDragging.value) return
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
removeDragListeners()
emit('dragcancel')
}
function removeDragListeners(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', cancelDrag)
}
function openWidget(): void {
if (props.editMode || suppressClick.value || !props.interactive) {
suppressClick.value = false
return
}
const routes: Partial<Record<WidgetInstance['kind'], string>> = {
clock: '/apps/clock',
contacts: '/apps/phone',
date: '/apps/calendar',
transactions: '/apps/banking',
wallet: '/apps/banking',
weather: '/apps/weather',
}
const route = routes[props.instance.kind]
if (route) {
phone.setLaunchOrigin(null)
void router.push(route)
}
}
async function callContact(phoneNumber: string): Promise<void> {
await calls.dial(phoneNumber)
phone.setLaunchOrigin(null)
void router.push('/apps/phone')
}
async function messageContact(phoneNumber: string): Promise<void> {
await messages.openThread(phoneNumber)
phone.setLaunchOrigin(null)
void router.push('/apps/messages')
}
onBeforeUnmount(() => {
clearHold()
removeDragListeners()
})
</script>
<template>
<div
class="home-widget-shell"
:class="[
`home-widget-shell--${instance.size}`,
{
'home-widget-shell--dragging': isDragging,
'home-widget-shell--editing': editMode,
'home-widget-shell--preview': preview,
},
]"
:style="placementStyle"
:data-widget-id="instance.id"
>
<k-glass
component="article"
class="home-widget"
:class="`home-widget--${instance.kind}`"
role="button"
tabindex="0"
@click="openWidget"
@contextmenu.prevent
@keydown.enter="openWidget"
@pointercancel="cancelDrag"
@pointerdown="onPointerDown"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<template v-if="instance.kind === 'clock'">
<span class="widget-eyebrow">{{ clock.weekday.value }}</span>
<strong class="widget-clock">{{ clock.time.value }}</strong>
<small v-if="instance.settings.showDate !== false">{{
clock.date.value
}}</small>
</template>
<template v-else-if="instance.kind === 'date'">
<span class="widget-date-month">{{ clock.month.value }}</span>
<strong class="widget-date-day">{{ clock.day.value }}</strong>
<small>{{ clock.weekday.value }}</small>
</template>
<template v-else-if="instance.kind === 'weather'">
<div class="widget-weather-top">
<div>
<span class="widget-eyebrow">{{ weather.location.value }}</span>
<strong>{{ weather.forecast.value?.temperature ?? '--' }}°</strong>
</div>
<component
:is="weatherIcon"
:size="instance.size === 'small' ? 30 : 40"
/>
</div>
<small>{{ weather.condition.value }}</small>
<small v-if="instance.size !== 'small'" class="widget-weather-range">
H: {{ forecastHigh ?? '--' }}° &nbsp; L: {{ forecastLow ?? '--' }}°
</small>
</template>
<template v-else-if="instance.kind === 'music'">
<div class="widget-album" aria-hidden="true">
<span>SKY</span>
</div>
<div class="widget-music-copy">
<strong>{{ music.current.value.title }}</strong>
<small>{{ music.current.value.artist }}</small>
</div>
<div class="widget-music-controls" data-widget-control>
<button
type="button"
:aria-label="
phone.t(
music.playing.value
? 'Home.widgets.media.pause'
: 'Home.widgets.media.play',
)
"
@click.stop="music.toggle"
>
<Pause v-if="music.playing.value" :size="20" fill="currentColor" />
<Play v-else :size="20" fill="currentColor" />
</button>
<button
type="button"
:aria-label="phone.t('ControlCenter.next')"
@click.stop="music.next"
>
<SkipForward :size="19" fill="currentColor" />
</button>
</div>
</template>
<template v-else-if="instance.kind === 'wallet'">
<div class="widget-wallet-icon"><WalletCards :size="22" /></div>
<span class="widget-eyebrow">{{
phone.t(
instance.settings.balanceSource === 'cash'
? 'Home.widgetSystem.wallet.cash'
: 'Home.widgetSystem.wallet.bank',
)
}}</span>
<strong class="widget-balance">{{ formatMoney(balance) }}</strong>
<small>{{ bank.overview.value.playerName }}</small>
</template>
<template v-else-if="instance.kind === 'transactions'">
<header class="widget-list-header">
<span>{{ phone.t('Home.widgetSystem.transactions.name') }}</span>
<Banknote :size="19" />
</header>
<button
v-for="transaction in visibleTransactions"
:key="transaction.id"
type="button"
class="widget-transaction"
data-widget-control
@click.stop="openWidget"
>
<span>
<strong>{{ transaction.label }}</strong>
<small>{{ transaction.reference }}</small>
</span>
<b
:class="{
positive:
transaction.kind === 'deposit' ||
transaction.kind === 'transfer_in',
}"
>{{
transaction.kind === 'deposit' ||
transaction.kind === 'transfer_in'
? '+'
: ''
}}{{ formatMoney(transaction.amount) }}</b
>
</button>
</template>
<template v-else-if="instance.kind === 'contacts'">
<header class="widget-list-header">
<span>{{ phone.t('Home.widgetSystem.contacts.name') }}</span>
</header>
<div class="widget-contacts">
<article v-for="contact in favoriteContacts" :key="contact.id">
<span class="widget-contact-avatar">{{
avatar(contact.name)
}}</span>
<strong>{{ contact.name }}</strong>
<div data-widget-control>
<button
type="button"
:aria-label="phone.t('Apps.messages.call')"
@click.stop="callContact(contact.phone_number)"
>
<Phone :size="15" fill="currentColor" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.messages.messageAction')"
@click.stop="messageContact(contact.phone_number)"
>
<MessageCircle :size="15" fill="currentColor" />
</button>
</div>
</article>
</div>
</template>
</k-glass>
<button
v-if="editMode && !preview"
class="home-widget-remove"
type="button"
:aria-label="phone.t('Home.widgetSystem.remove')"
@click.stop="emit('remove')"
@pointerdown.stop
>
<k-badge :colors="removeBadgeColors"></k-badge>
</button>
</div>
</template>
<style scoped>
.home-widget-shell {
position: relative;
z-index: 2;
min-width: 0;
min-height: 0;
transition:
transform 0.32s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.2s ease;
}
.home-widget-shell--dragging {
z-index: 40;
opacity: 0.86;
transition:
transform var(--springboard-page-duration) var(--springboard-page-easing),
opacity 0.2s ease;
pointer-events: none;
}
.home-widget-shell--editing:not(.home-widget-shell--dragging) {
animation: widget-wobble 0.17s ease-in-out infinite alternate;
}
.home-widget-shell--preview {
width: 100%;
aspect-ratio: 2 / 1;
}
.home-widget-shell--preview.home-widget-shell--small {
max-width: 150px;
aspect-ratio: 1;
}
.home-widget-shell--preview.home-widget-shell--large {
aspect-ratio: 1;
}
.home-widget {
width: 100%;
height: 100%;
padding: 14px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 22px;
outline: none;
color: #fff;
background: rgb(28 28 30 / 76%);
box-shadow:
0 8px 24px rgb(0 0 0 / 24%),
inset 0 0.5px rgb(255 255 255 / 18%);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.home-widget:active {
filter: brightness(1.08);
}
.home-widget small,
.widget-eyebrow {
color: rgb(255 255 255 / 68%);
font-size: 11px;
line-height: 1.25;
}
.home-widget--clock,
.home-widget--date,
.home-widget--wallet {
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: flex-end;
}
.widget-clock {
margin: 1px 0;
font-size: 31px;
font-weight: 400;
letter-spacing: -1.6px;
line-height: 1;
}
.home-widget-shell--medium .widget-clock {
font-size: 45px;
}
.home-widget--date {
background: rgb(247 247 247 / 88%);
color: #111;
}
.home-widget--date small {
color: #6e6e73;
}
.widget-date-month {
color: #ff3b30;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.widget-date-day {
font-size: 48px;
font-weight: 300;
letter-spacing: -2px;
line-height: 0.95;
}
.home-widget--weather {
display: flex;
flex-direction: column;
justify-content: space-between;
background: rgb(25 69 112 / 82%);
}
.widget-weather-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.widget-weather-top > div {
display: flex;
flex-direction: column;
}
.widget-weather-top strong {
font-size: 34px;
font-weight: 300;
letter-spacing: -1.5px;
line-height: 1;
}
.widget-weather-range {
margin-top: 4px;
}
.home-widget--music {
display: grid;
align-items: center;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
}
.home-widget-shell--large .home-widget--music {
align-content: center;
grid-template-columns: 1fr;
text-align: center;
}
.widget-album {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border-radius: 13px;
color: #fff;
background: #b33a3a;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
font-size: 13px;
font-weight: 800;
letter-spacing: 1.5px;
}
.home-widget-shell--large .widget-album {
width: 118px;
height: 118px;
margin: 0 auto;
border-radius: 24px;
font-size: 20px;
}
.widget-music-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.widget-music-copy strong,
.widget-music-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-music-controls {
display: flex;
align-items: center;
gap: 5px;
}
.widget-music-controls button,
.widget-contacts button {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(255 255 255 / 13%);
}
.widget-wallet-icon {
position: absolute;
top: 13px;
right: 13px;
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 11px;
color: #0a84ff;
background: rgb(10 132 255 / 15%);
}
.widget-balance {
margin: 2px 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -1.2px;
}
.home-widget--transactions,
.home-widget--contacts {
display: flex;
flex-direction: column;
}
.widget-list-header {
display: flex;
margin-bottom: 7px;
align-items: center;
justify-content: space-between;
color: rgb(255 255 255 / 72%);
font-size: 12px;
font-weight: 600;
}
.widget-transaction {
display: flex;
min-height: 43px;
padding: 5px 0;
align-items: center;
justify-content: space-between;
border: 0;
border-top: 0.5px solid rgb(255 255 255 / 12%);
color: #fff;
background: transparent;
text-align: left;
}
.widget-transaction > span {
display: flex;
min-width: 0;
flex-direction: column;
}
.widget-transaction strong {
font-size: 12px;
}
.widget-transaction b {
color: #fff;
font-size: 12px;
font-weight: 600;
}
.widget-transaction b.positive {
color: #30d158;
}
.widget-contacts {
display: grid;
flex: 1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.home-widget-shell--large .widget-contacts {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(2, 1fr);
}
.widget-contacts article {
display: flex;
min-width: 0;
align-items: center;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.widget-contact-avatar {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 50%;
color: #fff;
background: #5e5ce6;
font-size: 16px;
font-weight: 650;
}
.widget-contacts article:nth-child(2n) .widget-contact-avatar {
background: #ff9f0a;
}
.widget-contacts article:nth-child(3n) .widget-contact-avatar {
background: #34c759;
}
.widget-contacts strong {
max-width: 100%;
overflow: hidden;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-contacts article > div {
display: flex;
gap: 3px;
}
.widget-contacts button {
width: 25px;
height: 25px;
color: #0a84ff;
background: rgb(10 132 255 / 14%);
}
.home-widget-remove {
position: absolute;
z-index: 8;
top: -8px;
left: -8px;
display: grid;
width: 25px;
height: 25px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
}
.home-widget-remove :deep(.k-badge) {
width: 23px;
height: 23px;
min-width: 23px;
padding: 0;
border: 0.5px solid rgb(255 255 255 / 55%);
box-shadow: 0 1px 5px rgb(0 0 0 / 55%);
font-size: 20px;
font-weight: 400;
}
@keyframes widget-wobble {
from {
transform: rotate(-0.7deg) translateY(-0.5px);
}
to {
transform: rotate(0.7deg) translateY(0.5px);
}
}
@media (prefers-reduced-motion: reduce) {
.home-widget-shell--editing {
animation: none;
}
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
import { useWidgetsStore } from '@/stores/widgets'
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
const props = defineProps<{
draggingWidgetId?: string | null
editMode: boolean
page: number
}>()
const emit = defineEmits<{
dragcancel: []
dragend: [event: PointerEvent]
dragmove: [event: PointerEvent]
dragstart: [id: string, event: PointerEvent]
menu: [id: string]
remove: [id: string]
}>()
const widgets = useWidgetsStore()
const rows = computed(() =>
props.page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS,
)
const instances = computed(() =>
widgets.layout.instances.filter((instance) => instance.page === props.page),
)
</script>
<template>
<div
class="springboard-widget-grid"
:class="{
'springboard-widget-grid--editing': editMode,
'springboard-widget-grid--page': page === 0,
'springboard-widget-grid--drag-source': instances.some(
(instance) => instance.id === draggingWidgetId,
),
}"
:style="{ '--widget-grid-rows': rows }"
:data-widget-page="page"
>
<SpringboardWidget
v-for="instance in instances"
:key="instance.id"
:instance="instance"
:edit-mode="editMode"
@dragcancel="emit('dragcancel')"
@dragend="emit('dragend', $event)"
@dragmove="emit('dragmove', $event)"
@dragstart="emit('dragstart', instance.id, $event)"
@menu="emit('menu', instance.id)"
@remove="emit('remove', instance.id)"
/>
</div>
</template>
<style scoped>
.springboard-widget-grid {
position: absolute;
z-index: 3;
top: 82px;
right: 22px;
left: 22px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(
var(--widget-grid-rows),
var(--springboard-grid-row)
);
grid-auto-rows: var(--springboard-grid-row);
gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap);
pointer-events: none;
}
.springboard-widget-grid--page {
position: relative;
top: auto;
right: auto;
left: auto;
}
.springboard-widget-grid--drag-source {
z-index: 50;
}
.springboard-widget-grid :deep(.home-widget-shell) {
pointer-events: auto;
}
</style>
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { Check } from 'lucide-vue-next'
import {
kLink,
kList,
kListItem,
kNavbar,
kPage,
kSegmented,
kSegmentedButton,
kSheet,
kToggle,
} from 'konsta/vue'
import { ref, watch } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
import { useContactsService } from '@/services/widgetServices'
import { usePhoneStore } from '@/stores/phone'
import type {
WidgetInstance,
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
const props = defineProps<{
instance: WidgetInstance | null
opened: boolean
}>()
const emit = defineEmits<{
close: []
save: [size: WidgetSize, settings: WidgetSettings]
}>()
const phone = usePhoneStore()
const contactsService = useContactsService()
const size = ref<WidgetSize>('small')
const showDate = ref(true)
const balanceSource = ref<'bank' | 'cash'>('bank')
const contactIds = ref<string[]>([])
const sheetColors = {
bgIos: 'bg-[#f2f2f7] dark:bg-black',
}
function toggleContact(id: string): void {
const index = contactIds.value.indexOf(id)
if (index !== -1) contactIds.value.splice(index, 1)
else if (contactIds.value.length < 6) contactIds.value.push(id)
}
function save(): void {
emit('save', size.value, {
balanceSource: balanceSource.value,
contactIds: contactIds.value,
showDate: showDate.value,
})
}
watch(
[() => props.opened, () => props.instance],
([opened, instance]) => {
if (!opened || !instance) return
size.value = instance.size
showDate.value = instance.settings.showDate !== false
balanceSource.value = instance.settings.balanceSource ?? 'bank'
contactIds.value = [...(instance.settings.contactIds ?? [])]
},
{ immediate: true },
)
</script>
<template>
<k-sheet
:opened="opened"
class="widget-config-sheet"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<k-page
v-if="instance"
class="widget-config-page"
:class="{ 'widget-config-page--dark': phone.isDarkMode }"
>
<k-navbar :title="phone.t('Home.widgetSystem.configure')">
<template #left>
<k-link component="button" type="button" @click="emit('close')">
{{ phone.t('Common.cancel') }}
</k-link>
</template>
<template #right>
<k-link component="button" type="button" @click="save">
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
<div class="widget-config-scroll">
<div class="widget-config-preview">
<SpringboardWidget
:instance="{ ...instance, size }"
preview
:interactive="false"
/>
</div>
<section class="widget-config-size">
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
<k-segmented raised>
<k-segmented-button
v-for="supportedSize in WIDGET_REGISTRY_BY_KIND.get(instance.kind)
?.supportedSizes"
:key="supportedSize"
:active="size === supportedSize"
@click="size = supportedSize"
>
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
</k-segmented-button>
</k-segmented>
</section>
<k-list
v-if="instance.kind === 'clock'"
inset
strong
class="widget-config-list"
>
<k-list-item :title="phone.t('Home.widgetSystem.clock.showDate')">
<template #after>
<k-toggle :checked="showDate" @change="showDate = !showDate" />
</template>
</k-list-item>
</k-list>
<k-list
v-if="instance.kind === 'wallet'"
inset
strong
class="widget-config-list"
>
<k-list-item :title="phone.t('Home.widgetSystem.wallet.balance')">
<template #after>
<k-segmented class="widget-config-balance">
<k-segmented-button
:active="balanceSource === 'bank'"
@click="balanceSource = 'bank'"
>
{{ phone.t('Home.widgetSystem.wallet.bank') }}
</k-segmented-button>
<k-segmented-button
:active="balanceSource === 'cash'"
@click="balanceSource = 'cash'"
>
{{ phone.t('Home.widgetSystem.wallet.cash') }}
</k-segmented-button>
</k-segmented>
</template>
</k-list-item>
</k-list>
<section v-if="instance.kind === 'contacts'">
<h3>{{ phone.t('Home.widgetSystem.contacts.choose') }}</h3>
<k-list inset strong class="widget-config-list">
<k-list-item
v-for="contact in contactsService.contacts.value"
:key="contact.id"
link
link-component="button"
:title="contact.name"
:subtitle="contact.phone_number"
@click="toggleContact(contact.id)"
>
<template #media>
<span class="widget-config-avatar">{{
contact.name.charAt(0).toUpperCase()
}}</span>
</template>
<template v-if="contactIds.includes(contact.id)" #after>
<Check :size="20" class="widget-config-check" />
</template>
</k-list-item>
</k-list>
</section>
</div>
</k-page>
</k-sheet>
</template>
<style scoped>
:global(.widget-config-sheet) {
z-index: 115;
height: calc(100% - 22px);
overflow: hidden;
border-radius: 28px 28px 0 0;
}
.widget-config-page {
height: 100%;
color: #111;
background: #f2f2f7;
}
.widget-config-page--dark {
color: #fff;
background: #000;
}
.widget-config-scroll {
height: calc(100% - 54px);
padding: 8px 12px 42px;
overflow-y: auto;
scrollbar-width: none;
}
.widget-config-scroll::-webkit-scrollbar {
display: none;
}
.widget-config-preview {
display: flex;
min-height: 215px;
padding: 20px 12px;
align-items: center;
justify-content: center;
}
.widget-config-preview :deep(.home-widget-shell) {
max-height: 188px;
}
.widget-config-size {
display: grid;
margin: 0 4px 16px;
grid-template-columns: auto 1fr;
align-items: center;
gap: 12px;
}
.widget-config-size > span,
.widget-config-scroll h3 {
color: #6e6e73;
font-size: 13px;
font-weight: 600;
}
.widget-config-scroll h3 {
margin: 18px 14px 7px;
}
.widget-config-list :deep([class*='title']) {
color: inherit;
}
.widget-config-balance {
width: 138px;
}
.widget-config-avatar {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 50%;
color: #fff;
background: #5e5ce6;
font-weight: 650;
}
.widget-config-check {
color: #0a84ff;
}
</style>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import {
CalendarDays,
Check,
Clock3,
CloudSun,
Music,
ReceiptText,
Users,
WalletCards,
} from 'lucide-vue-next'
import {
kButton,
kLink,
kList,
kListItem,
kNavbar,
kPage,
kSearchbar,
kSegmented,
kSegmentedButton,
kSheet,
} from 'konsta/vue'
import { computed, ref, watch, type Component } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
import { WIDGET_REGISTRY } from '@/config/widgets'
import { usePhoneStore } from '@/stores/phone'
import type {
WidgetDefinition,
WidgetInstance,
WidgetKind,
WidgetSize,
} from '@/types/widgets'
const props = defineProps<{ opened: boolean }>()
const emit = defineEmits<{
add: [kind: WidgetKind, size: WidgetSize]
close: []
}>()
const phone = usePhoneStore()
const query = ref('')
const selectedKind = ref<WidgetKind>('clock')
const selectedSize = ref<WidgetSize>('small')
const icons: Record<WidgetKind, Component> = {
clock: Clock3,
date: CalendarDays,
weather: CloudSun,
music: Music,
wallet: WalletCards,
transactions: ReceiptText,
contacts: Users,
}
const filteredWidgets = computed(() => {
const search = query.value.trim().toLocaleLowerCase(phone.lang)
if (!search) return WIDGET_REGISTRY
return WIDGET_REGISTRY.filter((definition) =>
`${phone.t(definition.labelKey)} ${phone.t(definition.descriptionKey)}`
.toLocaleLowerCase(phone.lang)
.includes(search),
)
})
const categories = computed(() => {
const groups = new Map<string, WidgetDefinition[]>()
for (const definition of filteredWidgets.value) {
const group = groups.get(definition.categoryKey) ?? []
group.push(definition)
groups.set(definition.categoryKey, group)
}
return [...groups.entries()].map(([key, widgets]) => ({ key, widgets }))
})
const selectedDefinition = computed(
() =>
WIDGET_REGISTRY.find(
(definition) => definition.kind === selectedKind.value,
) ?? WIDGET_REGISTRY[0],
)
const previewInstance = computed<WidgetInstance>(() => ({
column: 0,
id: 'widget-preview',
kind: selectedDefinition.value.kind,
page: 0,
row: 0,
settings: {
...(selectedDefinition.value.kind === 'clock' ? { showDate: true } : {}),
...(selectedDefinition.value.kind === 'wallet'
? { balanceSource: 'bank' as const }
: {}),
},
size: selectedSize.value,
}))
const sheetColors = {
bgIos: 'bg-[#f2f2f7] dark:bg-black',
}
function inputValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function selectWidget(definition: WidgetDefinition): void {
selectedKind.value = definition.kind
if (!definition.supportedSizes.includes(selectedSize.value))
selectedSize.value = definition.defaultSize
}
function addWidget(): void {
emit('add', selectedKind.value, selectedSize.value)
}
watch(
() => props.opened,
(opened) => {
if (!opened) query.value = ''
},
)
</script>
<template>
<k-sheet
:opened="opened"
class="widget-picker-sheet"
:colors="sheetColors"
@backdropclick="emit('close')"
>
<k-page
class="widget-picker-page"
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
>
<k-navbar :title="phone.t('Home.widgetSystem.galleryTitle')">
<template #right>
<k-link component="button" type="button" @click="emit('close')">
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
<div class="widget-picker-scroll">
<k-searchbar
:value="query"
:placeholder="phone.t('Home.widgetSystem.search')"
@input="query = inputValue($event)"
@clear="query = ''"
/>
<section class="widget-picker-preview">
<SpringboardWidget
:instance="previewInstance"
preview
:interactive="false"
/>
<h2>{{ phone.t(selectedDefinition.labelKey) }}</h2>
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
</section>
<section class="widget-picker-size">
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
<k-segmented raised>
<k-segmented-button
v-for="size in selectedDefinition.supportedSizes"
:key="size"
:active="selectedSize === size"
@click="selectedSize = size"
>
{{ phone.t(`Home.widgetSystem.sizes.${size}`) }}
</k-segmented-button>
</k-segmented>
</section>
<k-button large rounded class="widget-picker-add" @click="addWidget">
{{ phone.t('Home.widgetSystem.addWidget') }}
</k-button>
<section
v-for="category in categories"
:key="category.key"
class="widget-picker-category"
>
<h3>{{ phone.t(category.key) }}</h3>
<k-list inset strong>
<k-list-item
v-for="definition in category.widgets"
:key="definition.kind"
link
link-component="button"
:title="phone.t(definition.labelKey)"
:subtitle="phone.t(definition.descriptionKey)"
@click="selectWidget(definition)"
>
<template #media>
<span class="widget-picker-icon">
<component :is="icons[definition.kind]" :size="21" />
</span>
</template>
<template v-if="selectedKind === definition.kind" #after>
<Check :size="20" class="widget-picker-check" />
</template>
</k-list-item>
</k-list>
</section>
<p v-if="filteredWidgets.length === 0" class="widget-picker-empty">
{{ phone.t('Home.widgetSystem.noResults') }}
</p>
</div>
</k-page>
</k-sheet>
</template>
<style scoped>
:global(.widget-picker-sheet) {
z-index: 110;
height: calc(100% - 22px);
overflow: hidden;
border-radius: 28px 28px 0 0;
}
.widget-picker-page {
height: 100%;
color: #111;
background: #f2f2f7;
}
.widget-picker-page--dark {
color: #fff;
background: #000;
}
.widget-picker-scroll {
height: calc(100% - 54px);
padding: 8px 13px 42px;
overflow-y: auto;
scrollbar-width: none;
}
.widget-picker-scroll::-webkit-scrollbar {
display: none;
}
.widget-picker-preview {
display: flex;
min-height: 235px;
padding: 22px 14px 14px;
align-items: center;
flex-direction: column;
justify-content: center;
}
.widget-picker-preview :deep(.home-widget-shell) {
max-height: 188px;
}
.widget-picker-preview h2 {
margin: 14px 0 3px;
font-size: 20px;
letter-spacing: -0.4px;
}
.widget-picker-preview p {
max-width: 285px;
margin: 0;
color: #6e6e73;
font-size: 13px;
line-height: 1.35;
text-align: center;
}
.widget-picker-page--dark .widget-picker-preview p {
color: #98989d;
}
.widget-picker-size {
display: grid;
margin: 0 4px 14px;
grid-template-columns: auto 1fr;
align-items: center;
gap: 12px;
}
.widget-picker-size > span {
font-size: 13px;
font-weight: 600;
}
.widget-picker-add {
margin-bottom: 22px;
}
.widget-picker-category h3 {
margin: 18px 12px 7px;
color: #6e6e73;
font-size: 13px;
font-weight: 500;
}
.widget-picker-category :deep([class*='title']) {
color: inherit;
}
.widget-picker-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 10px;
color: #fff;
background: #2c2c2e;
}
.widget-picker-check {
color: #0a84ff;
}
.widget-picker-empty {
padding: 40px 20px;
color: #8e8e93;
text-align: center;
}
</style>
@@ -65,4 +65,7 @@ const formattedAmount = computed(() =>
<style scoped>
.citymarkt-offer{width:92%;padding:11px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:18px}.citymarkt-offer header i{padding:5px 7px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:8px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.35}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:6px}.citymarkt-offer__actions button{min-height:35px;padding:7px 6px;border:1px solid #ffffff12;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:4px;background:#ffffff09;color:inherit;font-size:9.5px;font-weight:850;line-height:1.1}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
.citymarkt-offer{padding:12px}.citymarkt-offer header>span{width:35px;height:35px}.citymarkt-offer header small{font-size:10.5px}.citymarkt-offer header strong{font-size:20px}.citymarkt-offer header i{padding:5px 8px;font-size:9.5px}.citymarkt-offer>p{margin-top:8px;font-size:11.5px}.citymarkt-offer__actions{margin-top:11px;gap:7px}.citymarkt-offer__actions button{min-height:40px;padding:8px;font-size:12px;font-weight:850;gap:5px}.citymarkt-offer__actions button svg{width:15px;height:15px}
.citymarkt-offer:not(.citymarkt-offer--accepted) {
border-color: transparent;
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import { kGlass } from 'konsta/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
type SelectOption = {
@@ -20,7 +21,9 @@ const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const highlightedIndex = ref(0)
const selectedLabel = computed(
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
() =>
props.options.find((option) => option.value === props.modelValue)?.label ??
'',
)
function open(): void {
@@ -44,7 +47,8 @@ function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (!isOpen.value) open()
else select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
else
select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
return
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
@@ -52,7 +56,8 @@ function handleKeydown(event: KeyboardEvent): void {
if (!isOpen.value) open()
const direction = event.key === 'ArrowDown' ? 1 : -1
highlightedIndex.value =
(highlightedIndex.value + direction + props.options.length) % props.options.length
(highlightedIndex.value + direction + props.options.length) %
props.options.length
}
function handleOutsidePointer(event: PointerEvent): void {
@@ -60,12 +65,15 @@ function handleOutsidePointer(event: PointerEvent): void {
}
onMounted(() => window.addEventListener('pointerdown', handleOutsidePointer))
onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer))
onUnmounted(() =>
window.removeEventListener('pointerdown', handleOutsidePointer),
)
</script>
<template>
<div ref="root" class="citymarkt-select" @keydown="handleKeydown">
<button
<k-glass
component="button"
class="citymarkt-select__trigger"
type="button"
aria-haspopup="listbox"
@@ -74,7 +82,7 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
>
<span>{{ selectedLabel }}</span>
<ChevronDown :size="14" :class="{ open: isOpen }" />
</button>
</k-glass>
<Transition name="citymarkt-select">
<div v-if="isOpen" class="citymarkt-select__menu" role="listbox">
@@ -100,7 +108,105 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
</template>
<style scoped>
.citymarkt-select{position:relative;min-width:0}.citymarkt-select__trigger{width:100%;height:36px;padding:0 10px;border:1px solid #ffffff0d;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:6px;background:var(--panel);color:inherit;font-size:10px;text-align:left}.citymarkt-select__trigger span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.citymarkt-select__trigger svg{flex:none;color:var(--yellow);transition:transform .18s ease}.citymarkt-select__trigger svg.open{transform:rotate(180deg)}.citymarkt-select__menu{position:absolute;z-index:12;top:calc(100% + 5px);right:0;left:0;max-height:176px;padding:4px;border:1px solid #ffffff16;border-radius:11px;overflow-y:auto;background:#292a27;box-shadow:0 12px 28px #0009;scrollbar-width:none}:global(.citymarkt--light) .citymarkt-select__menu{border-color:#00000014;background:#fff;box-shadow:0 12px 28px #0003}.citymarkt-select__menu button{width:100%;min-height:31px;padding:6px 7px;border:0;border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:none;color:var(--muted);font-size:10px;text-align:left}.citymarkt-select__menu button.highlighted{background:#ffffff0b;color:inherit}:global(.citymarkt--light) .citymarkt-select__menu button.highlighted{background:#0000000b}.citymarkt-select__menu button.selected{color:var(--yellow);font-weight:800}.citymarkt-select-enter-active,.citymarkt-select-leave-active{transition:opacity .15s ease,transform .15s ease}.citymarkt-select-enter-from,.citymarkt-select-leave-to{opacity:0;transform:translateY(-4px) scale(.98)}
.citymarkt-select__trigger{height:40px;font-size:13px}
.citymarkt-select__menu button{min-height:36px;padding:7px 8px;font-size:12px}
.citymarkt-select {
position: relative;
min-width: 0;
}
.citymarkt-select__trigger {
width: 100%;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: inherit;
font-size: 10px;
text-align: left;
}
.citymarkt-select__trigger span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.citymarkt-select__trigger svg {
flex: none;
color: var(--yellow);
transition: transform 0.18s ease;
}
.citymarkt-select__trigger svg.open {
transform: rotate(180deg);
}
.citymarkt-select__menu {
position: absolute;
z-index: 12;
top: calc(100% + 5px);
right: 0;
left: 0;
max-height: 176px;
padding: 4px;
border: 1px solid #ffffff14;
border-radius: 11px;
overflow-y: auto;
background: #292a27;
box-shadow: 0 12px 28px #0009;
scrollbar-width: none;
}
:global(.citymarkt--light) .citymarkt-select__menu {
border-color: #00000012;
background: #fff;
box-shadow: 0 12px 28px #0003;
}
.citymarkt-select__menu button {
width: 100%;
min-height: 31px;
padding: 6px 7px;
border: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
background: none;
color: var(--muted);
font-size: 10px;
text-align: left;
}
.citymarkt-select__menu button.highlighted {
background: #ffffff0b;
color: inherit;
}
:global(.citymarkt--light) .citymarkt-select__menu button.highlighted {
background: #0000000b;
}
.citymarkt-select__menu button.selected {
background: #ffffff14;
color: inherit;
font-weight: 800;
}
:global(.citymarkt--light) .citymarkt-select__menu button.selected {
background: #0000000d;
}
.citymarkt-select-enter-active,
.citymarkt-select-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.citymarkt-select-enter-from,
.citymarkt-select-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
.citymarkt-select__trigger {
height: 40px;
font-size: 13px;
}
.citymarkt-select__menu button {
min-height: 36px;
padding: 7px 8px;
font-size: 12px;
}
</style>
+1 -1
View File
@@ -124,7 +124,7 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'mail'])
).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'banking', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+1 -1
View File
@@ -174,7 +174,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/map',
},
{
category: 'utilities',
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/BankingApp.vue')),
),
+71
View File
@@ -0,0 +1,71 @@
import type { WidgetDefinition, WidgetKind } from '@/types/widgets'
export const WIDGET_REGISTRY: WidgetDefinition[] = [
{
categoryKey: 'Home.widgetSystem.categories.essentials',
configurable: true,
defaultSize: 'small',
descriptionKey: 'Home.widgetSystem.clock.description',
kind: 'clock',
labelKey: 'Home.widgetSystem.clock.name',
supportedSizes: ['small', 'medium'],
},
{
categoryKey: 'Home.widgetSystem.categories.essentials',
configurable: false,
defaultSize: 'small',
descriptionKey: 'Home.widgetSystem.date.description',
kind: 'date',
labelKey: 'Home.widgetSystem.date.name',
supportedSizes: ['small', 'medium'],
},
{
categoryKey: 'Home.widgetSystem.categories.information',
configurable: false,
defaultSize: 'medium',
descriptionKey: 'Home.widgetSystem.weather.description',
kind: 'weather',
labelKey: 'Home.widgetSystem.weather.name',
supportedSizes: ['small', 'medium', 'large'],
},
{
categoryKey: 'Home.widgetSystem.categories.media',
configurable: false,
defaultSize: 'medium',
descriptionKey: 'Home.widgetSystem.music.description',
kind: 'music',
labelKey: 'Home.widgetSystem.music.name',
supportedSizes: ['medium', 'large'],
},
{
categoryKey: 'Home.widgetSystem.categories.finance',
configurable: true,
defaultSize: 'small',
descriptionKey: 'Home.widgetSystem.wallet.description',
kind: 'wallet',
labelKey: 'Home.widgetSystem.wallet.name',
supportedSizes: ['small', 'medium'],
},
{
categoryKey: 'Home.widgetSystem.categories.finance',
configurable: false,
defaultSize: 'medium',
descriptionKey: 'Home.widgetSystem.transactions.description',
kind: 'transactions',
labelKey: 'Home.widgetSystem.transactions.name',
supportedSizes: ['medium', 'large'],
},
{
categoryKey: 'Home.widgetSystem.categories.people',
configurable: true,
defaultSize: 'medium',
descriptionKey: 'Home.widgetSystem.contacts.description',
kind: 'contacts',
labelKey: 'Home.widgetSystem.contacts.name',
supportedSizes: ['medium', 'large'],
},
]
export const WIDGET_REGISTRY_BY_KIND = new Map<WidgetKind, WidgetDefinition>(
WIDGET_REGISTRY.map((definition) => [definition.kind, definition]),
)
@@ -20,6 +20,10 @@ describe('neon drop engine', () => {
it('starts with a valid piece and a complete seven-piece bag', () => {
const game = createNeonDropGame(() => 0.5)
expect(game.status).toBe('playing')
expect(game.board).toHaveLength(17)
expect(game.board.every((row) => row.length === NEON_DROP_COLUMNS)).toBe(
true,
)
expect(new Set([game.active.kind, ...game.queue]).size).toBe(7)
expect(canPlaceNeonDropPiece(game.board, game.active)).toBe(true)
})
@@ -8,7 +8,7 @@ import type {
} from './types'
export const NEON_DROP_COLUMNS = 10
export const NEON_DROP_ROWS = 18
export const NEON_DROP_ROWS = 17
export const NEON_DROP_LINES_PER_LEVEL = 8
const PIECE_KINDS: NeonDropPieceKind[] = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
createSnakeGame,
SNAKE_BOARD_HEIGHT,
SNAKE_BOARD_WIDTH,
stepSnake,
turnSnake,
@@ -27,6 +28,17 @@ describe('Snake engine', () => {
expect(next.body).not.toContainEqual(next.fruit)
})
it('places fruit inside the visible play area', () => {
for (const random of [() => 0, () => 1]) {
const { fruit } = createSnakeGame(random)
expect(fruit.x).toBeGreaterThanOrEqual(0)
expect(fruit.x).toBeLessThan(SNAKE_BOARD_WIDTH)
expect(fruit.y).toBeGreaterThanOrEqual(0)
expect(fruit.y).toBeLessThan(SNAKE_BOARD_HEIGHT)
}
})
it('ignores an immediate reverse direction', () => {
const game = createSnakeGame(() => 0)
@@ -46,6 +58,20 @@ describe('Snake engine', () => {
expect(stepSnake(game).status).toBe('game-over')
})
it('ends the game at the lower edge of the visible play area', () => {
const game: SnakeGameState = {
...createSnakeGame(() => 0),
body: [
{ x: 4, y: SNAKE_BOARD_HEIGHT - 1 },
{ x: 4, y: SNAKE_BOARD_HEIGHT - 2 },
],
direction: 'down',
pendingDirection: 'down',
}
expect(stepSnake(game).status).toBe('game-over')
})
it('ends the game on body collision', () => {
const game: SnakeGameState = {
body: [
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
} from './types'
export const SNAKE_BOARD_WIDTH = 16
export const SNAKE_BOARD_HEIGHT = 18
export const SNAKE_BOARD_HEIGHT = 30
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
up: { x: 0, y: -1 },
+155
View File
@@ -0,0 +1,155 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useBankingStore } from '@/stores/banking'
import { useCallsStore } from '@/stores/calls'
import { usePhoneStore } from '@/stores/phone'
import { useWeatherStore } from '@/stores/weather'
const now = ref(new Date())
let clockConsumers = 0
let clockInterval: number | undefined
const tracks = [
{ artist: 'Sky Radio', title: 'Night Drive' },
{ artist: 'Los Santos FM', title: 'Pacific Coast' },
{ artist: 'Mirror Park', title: 'After Hours' },
]
const trackIndex = ref(0)
const playing = ref(false)
export function useClockService() {
const phone = usePhoneStore()
onMounted(() => {
clockConsumers += 1
if (clockInterval === undefined) {
clockInterval = window.setInterval(() => {
now.value = new Date()
}, 1000)
}
})
onBeforeUnmount(() => {
clockConsumers -= 1
if (clockConsumers === 0 && clockInterval !== undefined) {
window.clearInterval(clockInterval)
clockInterval = undefined
}
})
return {
date: computed(() =>
new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'long',
weekday: 'long',
}).format(now.value),
),
day: computed(() => now.value.getDate()),
month: computed(() =>
new Intl.DateTimeFormat(phone.lang, { month: 'long' }).format(now.value),
),
time: computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
}).format(now.value),
),
weekday: computed(() =>
new Intl.DateTimeFormat(phone.lang, { weekday: 'long' }).format(
now.value,
),
),
}
}
export function useWeatherService() {
const phone = usePhoneStore()
const weather = useWeatherStore()
return {
condition: computed(() =>
weather.forecast
? phone.t(`Apps.weather.conditions.${weather.forecast.condition}`)
: phone.t('Common.loading'),
),
forecast: computed(() => weather.forecast),
location: computed(() =>
phone.t(
`Apps.weather.regions.${weather.forecast?.region ?? 'los_santos'}`,
),
),
}
}
export function useMusicService() {
return {
current: computed(() => tracks[trackIndex.value]),
next(): void {
trackIndex.value = (trackIndex.value + 1) % tracks.length
playing.value = true
},
playing,
toggle(): void {
playing.value = !playing.value
},
}
}
export function useBankService() {
const banking = useBankingStore()
const overview = computed(
() =>
banking.overview ?? {
bank: 24_580,
cash: 1_240,
currency: '$',
playerId: 0,
playerName: 'Sky Citizen',
transactions: [
{
amount: 1800,
createdAt: Date.now() - 3_600_000,
id: -1,
kind: 'deposit' as const,
label: 'Salary',
reference: 'PAYROLL',
},
{
amount: 86,
createdAt: Date.now() - 7_200_000,
id: -2,
kind: 'withdrawal' as const,
label: 'Fuel',
reference: 'CARD',
},
{
amount: 340,
createdAt: Date.now() - 18_000_000,
id: -3,
kind: 'transfer_out' as const,
label: 'Transfer',
reference: 'PHONE',
},
],
},
)
onMounted(() => {
if (!banking.overview && !banking.isLoading) void banking.load()
})
return { overview }
}
export function useContactsService() {
const calls = useCallsStore()
const contacts = computed(() =>
calls.contacts.length
? calls.contacts
: [
{ id: 'mock-nova', name: 'Nova', phone_number: '555-0142' },
{ id: 'mock-alex', name: 'Alex', phone_number: '555-0198' },
{ id: 'mock-mia', name: 'Mia', phone_number: '555-0121' },
{ id: 'mock-liam', name: 'Liam', phone_number: '555-0177' },
],
)
onMounted(() => {
if (!calls.contacts.length) void calls.loadContacts()
})
return { contacts }
}
+16
View File
@@ -8,7 +8,9 @@ import {
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
import {
addHomePage,
createDefaultHomeLayout,
deleteHomePage,
moveHomeApp,
parseHomeLayout,
removeHomeApp,
@@ -39,6 +41,20 @@ export const useAppStoreStore = defineStore('app-store', {
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
actions: {
addHomePage(): boolean {
const next = addHomePage(this.homeLayout)
if (next === this.homeLayout) return false
this.homeLayout = next
this.persist()
return true
},
deleteHomePage(page: number): boolean {
const next = deleteHomePage(this.homeLayout, page)
if (next === this.homeLayout) return false
this.homeLayout = next
this.persist()
return true
},
claimApp(id: LaunchablePhoneAppId): void {
if (!this.claimedApps.includes(id)) {
this.claimedApps.push(id)
+27
View File
@@ -31,4 +31,31 @@ describe('message media handoff', () => {
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
expect(store.cancel()).toBe('/apps/messages')
})
it('returns multiple photos and the requesting app context', () => {
const store = useMessageMediaStore()
const secondPhoto = { ...photo, id: 18 }
store.begin('citymarkt:sell', 'photo', '/apps/citymarkt?sell=1', 2, {
title: 'Draft listing',
})
expect(store.completeMany([photo, secondPhoto])).toBe('/apps/citymarkt?sell=1')
expect(store.consumeMany<{ title: string }>('citymarkt:sell')).toEqual({
context: { title: 'Draft listing' },
media: [photo, secondPhoto],
})
})
it('preserves the requesting app context when selection is cancelled', () => {
const store = useMessageMediaStore()
store.begin('local-pages:compose', 'photo', '/apps/local-pages?compose=1', 6, {
title: 'Draft post',
})
expect(store.cancel()).toBe('/apps/local-pages?compose=1')
expect(store.consumeMany<{ title: string }>('local-pages:compose')).toEqual({
context: { title: 'Draft post' },
media: [],
})
})
})
+39 -3
View File
@@ -3,13 +3,20 @@ import { defineStore } from 'pinia'
import type { MediaType, PhoneMedia } from '@/types/media'
type MessageMediaRequest = {
context?: unknown
maxSelection: number
mediaType: MediaType
returnPath: string
target: string
}
type MessageMediaResult = MessageMediaRequest & {
media: PhoneMedia
media: PhoneMedia[]
}
export type MediaSelectionResult<T = unknown> = {
context?: T
media: PhoneMedia[]
}
export const useMessageMediaStore = defineStore('message-media', {
@@ -22,17 +29,37 @@ export const useMessageMediaStore = defineStore('message-media', {
target: string,
mediaType: MediaType,
returnPath = '/apps/messages',
maxSelection = 1,
context?: unknown,
): void {
this.request = { mediaType, returnPath, target }
this.request = {
context,
maxSelection: Math.max(1, Math.floor(maxSelection)),
mediaType,
returnPath,
target,
}
this.result = null
},
cancel(): string {
const returnPath = this.request?.returnPath ?? '/apps/messages'
if (this.request) this.result = { ...this.request, media: [] }
this.request = null
return returnPath
},
complete(media: PhoneMedia): string | null {
if (!this.request || this.request.mediaType !== media.mediaType) return null
return this.completeMany([media])
},
completeMany(media: PhoneMedia[]): string | null {
if (
!this.request ||
media.length < 1 ||
media.length > this.request.maxSelection ||
media.some((entry) => entry.mediaType !== this.request?.mediaType)
) {
return null
}
const returnPath = this.request.returnPath
this.result = { ...this.request, media }
this.request = null
@@ -40,9 +67,18 @@ export const useMessageMediaStore = defineStore('message-media', {
},
consume(target: string): PhoneMedia | null {
if (!this.result || this.result.target !== target) return null
const media = this.result.media
const media = this.result.media[0] ?? null
this.result = null
return media
},
consumeMany<T = unknown>(target: string): MediaSelectionResult<T> | null {
if (!this.result || this.result.target !== target) return null
const result = {
context: this.result.context as T | undefined,
media: [...this.result.media],
}
this.result = null
return result
},
},
})
@@ -0,0 +1,80 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
describe('phone passcode store', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('stores the server security state after setting a six digit passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: true, length: 6, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
const response = await phone.setPasscode('123456')
expect(response.success).toBe(true)
expect(phone.security).toEqual({
enabled: true,
length: 6,
lockedUntil: 0,
})
expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', {
passcode: '123456',
})
})
it('keeps the configured state after a rejected unlock attempt', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_passcode',
success: false,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.unlockWithPasscode('9999')
expect(phone.security).toEqual({
enabled: true,
length: 4,
lockedUntil: 0,
})
})
it('clears the security state after disabling the passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: false, length: null, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.disablePasscode('1234')
expect(phone.security.enabled).toBe(false)
expect(phone.security.length).toBeNull()
})
})
+164 -1
View File
@@ -1,10 +1,15 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import type {
DeviceBootstrap,
DeviceSecurity,
PhoneDevice,
} from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
parsePhonePreferences,
@@ -15,12 +20,19 @@ import {
type LocaleTree = Record<string, unknown>
export type PasscodeResponseData = {
attemptsRemaining?: number
retryAfter?: number
security?: DeviceSecurity
}
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
lang?: string
locales?: LocaleTree
notes?: DeviceBootstrap['notes']
security?: DeviceSecurity
token?: string
}
@@ -800,6 +812,8 @@ const defaultLocales: LocaleTree = {
sortPriceDesc: 'Highest price',
freshOffers: 'Fresh offers',
offers: 'offers',
compactView: 'Compact view',
largeView: 'Large view',
noListings: 'No offers found',
noListingsBody: 'Try another search or category.',
noDistrict: 'No district',
@@ -1376,6 +1390,7 @@ const defaultLocales: LocaleTree = {
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
general: 'General Settings',
security: 'Passcode & Security',
appearance: 'Appearance',
allowNotifications: 'Allow Notifications',
notificationSounds: 'Sounds',
@@ -1420,6 +1435,28 @@ const defaultLocales: LocaleTree = {
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
factoryResetProgress: 'Erasing iFruit Phone',
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
passcode: {
description:
'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.',
status: 'Passcode',
codeLength: 'Code Length',
sixDigit: '6-Digit Code',
fourDigit: '4-Digit Code',
turnOn: 'Turn Passcode On',
turnOff: 'Turn Passcode Off',
change: 'Change Passcode',
enterNew: 'Enter New Passcode',
confirmNew: 'Verify New Passcode',
enterCurrent: 'Enter Current Passcode',
screenSubtitle: 'Use 4 or 6 numbers.',
incorrect: 'Incorrect passcode.',
mismatch: 'The passcodes did not match.',
locked: 'Too many incorrect attempts. Try again later.',
rateLimited: 'Too many attempts. Please wait.',
failed: 'The passcode could not be updated.',
saved: 'Passcode saved.',
disabled: 'Passcode turned off.',
},
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
@@ -1514,6 +1551,16 @@ const defaultLocales: LocaleTree = {
flashlight: 'Flashlight',
camera: 'Camera',
swipeUp: 'Swipe up to open',
passcode: {
enter: 'Enter Passcode',
unlockSubtitle: 'Enter the passcode for this phone.',
cancel: 'Cancel',
delete: 'Delete digit',
incorrect: 'Incorrect passcode',
locked: 'Too many attempts. Try again in {seconds} seconds.',
tryAgain: 'Try again in {seconds} seconds',
rateLimited: 'Too many attempts. Please wait.',
},
},
Home: {
appLibrary: 'App Library',
@@ -1523,6 +1570,10 @@ const defaultLocales: LocaleTree = {
dock: 'Dock',
noApps: 'No apps found',
removeApp: 'Remove {app} from Home Screen',
addToHome: 'Add {app} to Home Screen',
addPage: 'Add Home Screen page',
deletePage: 'Delete current Home Screen page',
removedFromHome: 'Removed from Home Screen',
page: 'Page',
pages: 'Home screen pages',
groups: {
@@ -1546,6 +1597,59 @@ const defaultLocales: LocaleTree = {
pause: 'Pause',
},
},
widgetSystem: {
galleryTitle: 'Widgets',
search: 'Search Widgets',
size: 'Size',
add: 'Add Widget',
addWidget: 'Add Widget',
editWidget: 'Edit Widget',
removeWidget: 'Remove Widget',
remove: 'Remove widget',
configure: 'Configure Widget',
noResults: 'No widgets found',
sizes: { small: 'Small', medium: 'Medium', large: 'Large' },
categories: {
essentials: 'Essentials',
information: 'Information',
media: 'Media',
finance: 'Finance',
people: 'People',
},
clock: {
name: 'Clock',
description: 'The current time, with an optional date.',
showDate: 'Show Date',
},
date: {
name: 'Date',
description: 'Weekday, month, and date at a glance.',
},
weather: {
name: 'Weather',
description: 'Current conditions, location, and high and low.',
},
music: {
name: 'Now Playing',
description: 'Music controls and the current track.',
},
wallet: {
name: 'Wallet',
description: 'Your current bank or cash balance.',
balance: 'Displayed Balance',
bank: 'Bank',
cash: 'Cash',
},
transactions: {
name: 'Transactions',
description: 'Your latest incoming and outgoing payments.',
},
contacts: {
name: 'Favorites',
description: 'Call or message your favorite contacts.',
choose: 'Favorite Contacts',
},
},
},
}
@@ -1572,6 +1676,11 @@ export const usePhoneStore = defineStore('phone', {
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
security: {
enabled: false,
length: null,
lockedUntil: 0,
} as DeviceSecurity,
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
@@ -1590,6 +1699,11 @@ export const usePhoneStore = defineStore('phone', {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
this.security = payload.security ?? {
enabled: false,
length: null,
lockedUntil: 0,
}
this.isOpen = true
},
hydrateDevice(device: PhoneDevice): void {
@@ -1659,6 +1773,55 @@ export const usePhoneStore = defineStore('phone', {
this.preferences.settings.wallpaper = wallpaper
this.saveDeviceNamespace('settings', this.preferences)
},
async unlockWithPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:unlock',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async setPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:set-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async changePasscode(
currentPasscode: string,
newPasscode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:change-passcode',
{ currentPasscode, newPasscode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async disablePasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:disable-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
+70
View File
@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import {
addWidget,
createDefaultWidgetLayout,
deleteWidgetPage,
moveWidget,
parseWidgetLayout,
removeWidget,
resizeWidget,
updateWidgetSettings,
} from '@/utils/widgetLayout'
export const useWidgetsStore = defineStore('widgets', {
state: () => ({
layout: createDefaultWidgetLayout(),
}),
actions: {
add(kind: WidgetKind, size: WidgetSize, page: number): string | null {
const previousIds = new Set(
this.layout.instances.map((instance) => instance.id),
)
const next = addWidget(this.layout, kind, size, page)
if (next === this.layout) return null
this.layout = next
this.persist()
return (
this.layout.instances.find((instance) => !previousIds.has(instance.id))
?.id ?? null
)
},
hydrate(payload: unknown): void {
this.layout = parseWidgetLayout(payload)
},
deletePage(page: number, maximumPage: number): boolean {
const next = deleteWidgetPage(this.layout, page, maximumPage)
if (next === this.layout) return false
this.layout = next
this.persist()
return true
},
move(id: string, page: number, column: number, row: number): void {
const next = moveWidget(this.layout, id, page, column, row)
if (next === this.layout) return
this.layout = next
this.persist()
},
remove(id: string): void {
const next = removeWidget(this.layout, id)
if (next.instances.length === this.layout.instances.length) return
this.layout = next
this.persist()
},
resize(id: string, size: WidgetSize): void {
const next = resizeWidget(this.layout, id, size)
if (next === this.layout) return
this.layout = next
this.persist()
},
updateSettings(id: string, settings: WidgetSettings): void {
this.layout = updateWidgetSettings(this.layout, id, settings)
this.persist()
},
persist(): void {
usePhoneStore().saveDeviceNamespace('widgets', this.layout)
},
},
})
+7
View File
@@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = {
settings?: string | null
}
export type DeviceSecurity = {
enabled: boolean
length: 4 | 6 | null
lockedUntil: number
}
export type AccountDevice = {
created_at: string
current: boolean
@@ -37,5 +43,6 @@ export type DeviceBootstrap = {
account: IfruitAccount | null
device: PhoneDevice
notes: Note[]
security: DeviceSecurity
token: string
}
+9 -8
View File
@@ -23,9 +23,9 @@ export type MarketplaceImage = {
export type MarketplaceListingSummary = {
category: MarketplaceCategory
created_at: string
created_at: DatabaseDateValue
district: string | null
expires_at: string
expires_at: DatabaseDateValue
id: string
image: string | null
is_favorite: boolean | number
@@ -35,7 +35,7 @@ export type MarketplaceListingSummary = {
seller_name: string
status: MarketplaceStatus
title: string
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceListing = MarketplaceListingSummary & {
@@ -46,7 +46,7 @@ export type MarketplaceListing = MarketplaceListingSummary & {
reserved_account_id: number | null
revision: number
seller_active: number
seller_since: string
seller_since: DatabaseDateValue
show_phone: boolean | number
}
@@ -75,12 +75,12 @@ export type MarketplaceInquirySummary = {
status: MarketplaceStatus
title: string
unread: number
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceMessage = {
body: string
created_at: string
created_at: DatabaseDateValue
id: number
read_at: string | null
sender_account_id: number
@@ -88,13 +88,13 @@ export type MarketplaceMessage = {
export type MarketplaceOffer = {
amount: number | string
created_at: string
created_at: DatabaseDateValue
id: number
proposer_account_id: number
read_at: string | null
response_read_at: string | null
status: MarketplaceOfferStatus
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceInquiry = {
@@ -124,3 +124,4 @@ export type MarketplaceChat = {
}
export type MarketplaceCounts = { active: number; unread: number }
import type { DatabaseDateValue } from '@/utils/date'
+41
View File
@@ -0,0 +1,41 @@
export type WidgetKind =
| 'clock'
| 'date'
| 'weather'
| 'music'
| 'wallet'
| 'transactions'
| 'contacts'
export type WidgetSize = 'small' | 'medium' | 'large'
export type WidgetSettings = {
balanceSource?: 'bank' | 'cash'
contactIds?: string[]
showDate?: boolean
}
export type WidgetInstance = {
column: number
id: string
kind: WidgetKind
page: number
row: number
settings: WidgetSettings
size: WidgetSize
}
export type WidgetLayout = {
instances: WidgetInstance[]
version: 1
}
export type WidgetDefinition = {
categoryKey: string
configurable: boolean
defaultSize: WidgetSize
descriptionKey: string
kind: WidgetKind
labelKey: string
supportedSizes: WidgetSize[]
}
+23
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
addHomePage,
createDefaultHomeLayout,
deleteHomePage,
HOME_GRID_PAGE_SIZE,
MAX_HOME_GRID_PAGES,
moveHomeApp,
parseHomeLayout,
removeHomeApp,
@@ -163,4 +166,24 @@ describe('home layout', () => {
expect(restored.grid[0]).toBe('phone')
expect(restored.hidden).not.toContain('phone')
})
it('adds persistent empty pages up to the home screen limit', () => {
let layout = defaults
for (let page = 1; page < MAX_HOME_GRID_PAGES; page += 1) {
layout = addHomePage(layout)
}
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
expect(addHomePage(layout)).toBe(layout)
})
it('deletes a page and moves its apps into remaining empty slots', () => {
let layout = addHomePage(defaults)
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
const deleted = deleteHomePage(layout, 2)
expect(deleted.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
expect(deleted.grid).toContain('phone')
expect(deleteHomePage(deleted, 1)).toBe(deleted)
})
})
+36 -1
View File
@@ -2,7 +2,7 @@ import type { LaunchablePhoneAppId } from '@/types/apps'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_PAGE_SIZE = 20
const MAX_HOME_GRID_PAGES = 5
export const MAX_HOME_GRID_PAGES = 5
export type HomeArea = 'dock' | 'grid'
export type HomeSlot = LaunchablePhoneAppId | null
@@ -221,6 +221,41 @@ export function restoreHomeApp(
}
}
export function addHomePage(layout: HomeLayout): HomeLayout {
if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) {
return layout
}
return {
dock: [...layout.dock],
grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)],
hidden: [...layout.hidden],
version: 2,
}
}
export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE)
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
const grid = [...layout.grid]
const removedApps = grid
.splice(pageStart, HOME_GRID_PAGE_SIZE)
.filter((appId): appId is LaunchablePhoneAppId => appId !== null)
if (removedApps.length > grid.filter((appId) => appId === null).length) {
return layout
}
for (const appId of removedApps) placeInFirstEmptySlot(grid, appId)
return {
dock: [...layout.dock],
grid,
hidden: [...layout.hidden],
version: 2,
}
}
export function moveHomeApp(
layout: HomeLayout,
from: HomeArea,
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
import {
addWidget,
createDefaultWidgetLayout,
deleteWidgetPage,
moveWidget,
parseWidgetLayout,
removeWidget,
resizeWidget,
widgetOccupiedCells,
} from '@/utils/widgetLayout'
describe('widget layout', () => {
it('creates a non-overlapping default layout across both surfaces', () => {
const layout = createDefaultWidgetLayout()
expect(layout.instances).toHaveLength(7)
expect(widgetOccupiedCells(layout.instances, 0).size).toBe(32)
expect(widgetOccupiedCells(layout.instances, 1).size).toBe(16)
})
it('adds a widget to the first free snapped position', () => {
vi.spyOn(Date, 'now').mockReturnValue(42)
vi.spyOn(Math, 'random').mockReturnValue(0.5)
const layout = createDefaultWidgetLayout()
const next = addWidget(layout, 'wallet', 'small', 1)
const added = next.instances.find((instance) =>
instance.id.startsWith('wallet-'),
)
expect(added).toMatchObject({ column: 0, page: 2, row: 0, size: 'small' })
})
it('moves a widget and reflows a displaced neighbor', () => {
const layout = createDefaultWidgetLayout()
const next = moveWidget(layout, 'home-clock', 1, 2, 0)
const clock = next.instances.find(
(instance) => instance.id === 'home-clock',
)
const weather = next.instances.find(
(instance) => instance.id === 'home-weather',
)
expect(clock).toMatchObject({ column: 2, page: 1, row: 0 })
expect(weather).toMatchObject({ column: 0, page: 1, row: 0 })
expect(next.instances).toHaveLength(layout.instances.length)
})
it('allows a small widget in the center with app cells on both sides', () => {
const layout = createDefaultWidgetLayout()
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
expect(
moved.instances.find((instance) => instance.id === 'home-clock'),
).toMatchObject({ column: 1, page: 2, row: 1 })
expect([...widgetOccupiedCells(moved.instances, 2)]).toEqual([5, 6, 9, 10])
expect(widgetOccupiedCells(moved.instances, 2).has(4)).toBe(false)
expect(widgetOccupiedCells(moved.instances, 2).has(7)).toBe(false)
})
it('keeps the original layout when displaced widgets cannot be reflowed', () => {
const layout = createDefaultWidgetLayout()
const packed = {
instances: [
layout.instances.find((instance) => instance.id === 'home-clock')!,
...Array.from({ length: 5 }, (_, index) => ({
column: 0,
id: `packed-${index}`,
kind: 'date' as const,
page: 0,
row: index * 2,
settings: {},
size: 'medium' as const,
})),
],
version: 1 as const,
}
expect(moveWidget(packed, 'home-clock', 0, 0, 0)).toBe(packed)
})
it('resizes and removes widgets without changing unrelated settings', () => {
const layout = createDefaultWidgetLayout()
const resized = resizeWidget(layout, 'home-clock', 'medium')
const removed = removeWidget(resized, 'home-weather')
expect(
resized.instances.find((instance) => instance.id === 'home-clock'),
).toMatchObject({ settings: { showDate: true }, size: 'medium' })
expect(
removed.instances.some((instance) => instance.id === 'home-weather'),
).toBe(false)
})
it('rejects unsupported and duplicate persisted entries', () => {
const parsed = parseWidgetLayout({
instances: [
{
column: 0,
id: 'clock-a',
kind: 'clock',
page: 1,
row: 0,
settings: { showDate: true },
size: 'large',
},
{
column: 2,
id: 'clock-a',
kind: 'clock',
page: 1,
row: 0,
settings: {},
size: 'small',
},
],
version: 1,
})
expect(parsed.instances).toHaveLength(1)
expect(parsed.instances[0]?.size).toBe('small')
})
it('deletes a home page and preserves its widgets on remaining pages', () => {
const layout = createDefaultWidgetLayout()
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
const deleted = deleteWidgetPage(moved, 2, 1)
expect(deleted).not.toBe(moved)
expect(
deleted.instances.find((instance) => instance.id === 'home-clock')?.page,
).toBe(1)
expect(deleted.instances).toHaveLength(moved.instances.length)
})
})
+357
View File
@@ -0,0 +1,357 @@
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
import type {
WidgetInstance,
WidgetKind,
WidgetLayout,
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
export const WIDGET_GRID_COLUMNS = 4
export const WIDGET_HOME_ROWS = 5
export const WIDGET_PAGE_ROWS = 10
const MAX_WIDGET_HOME_PAGES = 5
export const WIDGET_SPANS: Record<
WidgetSize,
{ columns: number; rows: number }
> = {
small: { columns: 2, rows: 2 },
medium: { columns: 4, rows: 2 },
large: { columns: 4, rows: 4 },
}
function rowsForPage(page: number): number {
return page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS
}
function overlaps(left: WidgetInstance, right: WidgetInstance): boolean {
if (left.page !== right.page) return false
const leftSpan = WIDGET_SPANS[left.size]
const rightSpan = WIDGET_SPANS[right.size]
return (
left.column < right.column + rightSpan.columns &&
left.column + leftSpan.columns > right.column &&
left.row < right.row + rightSpan.rows &&
left.row + leftSpan.rows > right.row
)
}
function fits(instance: WidgetInstance, placed: WidgetInstance[]): boolean {
const span = WIDGET_SPANS[instance.size]
return (
instance.column >= 0 &&
instance.row >= 0 &&
instance.column + span.columns <= WIDGET_GRID_COLUMNS &&
instance.row + span.rows <= rowsForPage(instance.page) &&
!placed.some((candidate) => overlaps(instance, candidate))
)
}
function findPosition(
instance: WidgetInstance,
placed: WidgetInstance[],
startPage: number,
maximumPage = MAX_WIDGET_HOME_PAGES,
): WidgetInstance | null {
const lastPage = startPage === 0 ? 0 : maximumPage
for (let page = startPage; page <= lastPage; page += 1) {
const span = WIDGET_SPANS[instance.size]
for (let row = 0; row <= rowsForPage(page) - span.rows; row += 1) {
for (
let column = 0;
column <= WIDGET_GRID_COLUMNS - span.columns;
column += 1
) {
const candidate = { ...instance, column, page, row }
if (fits(candidate, placed)) return candidate
}
}
}
return null
}
export function deleteWidgetPage(
layout: WidgetLayout,
page: number,
maximumPage: number,
): WidgetLayout {
if (page < 1 || maximumPage < 1 || page > maximumPage + 1) return layout
const placed = layout.instances
.filter((instance) => instance.page !== page)
.map((instance) => ({
...instance,
page: instance.page > page ? instance.page - 1 : instance.page,
}))
const removed = layout.instances.filter((instance) => instance.page === page)
for (const instance of removed) {
const positioned = findPosition(
{ ...instance, page: 1 },
placed,
1,
maximumPage,
)
if (!positioned) return layout
placed.push(positioned)
}
return { instances: placed, version: 1 }
}
function normalizeSettings(value: unknown): WidgetSettings {
if (!value || typeof value !== 'object') return {}
const source = value as WidgetSettings
return {
...(source.balanceSource === 'cash' || source.balanceSource === 'bank'
? { balanceSource: source.balanceSource }
: {}),
...(Array.isArray(source.contactIds)
? {
contactIds: source.contactIds
.filter((id): id is string => typeof id === 'string')
.slice(0, 6),
}
: {}),
...(typeof source.showDate === 'boolean'
? { showDate: source.showDate }
: {}),
}
}
export function createDefaultWidgetLayout(): WidgetLayout {
return {
instances: [
{
column: 0,
id: 'home-clock',
kind: 'clock',
page: 1,
row: 0,
settings: { showDate: true },
size: 'small',
},
{
column: 2,
id: 'home-weather',
kind: 'weather',
page: 1,
row: 0,
settings: {},
size: 'small',
},
{
column: 0,
id: 'home-music',
kind: 'music',
page: 1,
row: 2,
settings: {},
size: 'medium',
},
{
column: 0,
id: 'page-date',
kind: 'date',
page: 0,
row: 0,
settings: {},
size: 'medium',
},
{
column: 0,
id: 'page-wallet',
kind: 'wallet',
page: 0,
row: 2,
settings: { balanceSource: 'bank' },
size: 'medium',
},
{
column: 0,
id: 'page-transactions',
kind: 'transactions',
page: 0,
row: 4,
settings: {},
size: 'medium',
},
{
column: 0,
id: 'page-contacts',
kind: 'contacts',
page: 0,
row: 6,
settings: {},
size: 'medium',
},
],
version: 1,
}
}
export function parseWidgetLayout(value: unknown): WidgetLayout {
if (!value || typeof value !== 'object') return createDefaultWidgetLayout()
const source = value as Partial<WidgetLayout>
if (source.version !== 1 || !Array.isArray(source.instances))
return createDefaultWidgetLayout()
const placed: WidgetInstance[] = []
const ids = new Set<string>()
for (const raw of source.instances) {
if (!raw || typeof raw !== 'object') continue
const candidate = raw as Partial<WidgetInstance>
if (
typeof candidate.id !== 'string' ||
ids.has(candidate.id) ||
typeof candidate.kind !== 'string' ||
!WIDGET_REGISTRY_BY_KIND.has(candidate.kind as WidgetKind)
) {
continue
}
const definition = WIDGET_REGISTRY_BY_KIND.get(candidate.kind as WidgetKind)
const size = definition?.supportedSizes.includes(
candidate.size as WidgetSize,
)
? (candidate.size as WidgetSize)
: definition?.defaultSize
if (!size) continue
const instance: WidgetInstance = {
column: Math.floor(Number(candidate.column) || 0),
id: candidate.id,
kind: candidate.kind as WidgetKind,
page: Math.max(0, Math.floor(Number(candidate.page) || 0)),
row: Math.floor(Number(candidate.row) || 0),
settings: normalizeSettings(candidate.settings),
size,
}
const normalized = fits(instance, placed)
? instance
: findPosition(instance, placed, instance.page)
if (!normalized) continue
placed.push(normalized)
ids.add(normalized.id)
}
return { instances: placed, version: 1 }
}
export function addWidget(
layout: WidgetLayout,
kind: WidgetKind,
size: WidgetSize,
page: number,
): WidgetLayout {
const definition = WIDGET_REGISTRY_BY_KIND.get(kind)
if (!definition?.supportedSizes.includes(size)) return layout
const instance: WidgetInstance = {
column: 0,
id: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
kind,
page,
row: 0,
settings: {
...(kind === 'clock' ? { showDate: true } : {}),
...(kind === 'wallet' ? { balanceSource: 'bank' as const } : {}),
},
size,
}
const positioned = findPosition(instance, layout.instances, page)
if (!positioned) return layout
return { instances: [...layout.instances, positioned], version: 1 }
}
export function removeWidget(layout: WidgetLayout, id: string): WidgetLayout {
return {
instances: layout.instances.filter((instance) => instance.id !== id),
version: 1,
}
}
export function moveWidget(
layout: WidgetLayout,
id: string,
page: number,
column: number,
row: number,
): WidgetLayout {
const moving = layout.instances.find((instance) => instance.id === id)
if (!moving) return layout
const span = WIDGET_SPANS[moving.size]
const requested: WidgetInstance = {
...moving,
column: Math.max(0, Math.min(column, WIDGET_GRID_COLUMNS - span.columns)),
page,
row: Math.max(0, Math.min(row, rowsForPage(page) - span.rows)),
}
const placed: WidgetInstance[] = [requested]
for (const instance of layout.instances) {
if (instance.id === id) continue
if (fits(instance, placed)) placed.push(instance)
else {
const repositioned = findPosition(instance, placed, instance.page)
if (!repositioned) return layout
placed.push(repositioned)
}
}
return { instances: placed, version: 1 }
}
export function resizeWidget(
layout: WidgetLayout,
id: string,
size: WidgetSize,
): WidgetLayout {
const instance = layout.instances.find((candidate) => candidate.id === id)
const definition = instance && WIDGET_REGISTRY_BY_KIND.get(instance.kind)
if (!instance || !definition?.supportedSizes.includes(size)) return layout
const resized = { ...instance, size }
const remaining = layout.instances.filter((candidate) => candidate.id !== id)
const positioned = findPosition(resized, remaining, instance.page)
if (!positioned) return layout
return {
instances: [
...remaining.map((candidate) => ({ ...candidate })),
positioned,
],
version: 1,
}
}
export function updateWidgetSettings(
layout: WidgetLayout,
id: string,
settings: WidgetSettings,
): WidgetLayout {
return {
instances: layout.instances.map((instance) =>
instance.id === id
? {
...instance,
settings: normalizeSettings({ ...instance.settings, ...settings }),
}
: instance,
),
version: 1,
}
}
export function widgetOccupiedCells(
instances: WidgetInstance[],
page: number,
): Set<number> {
const cells = new Set<number>()
for (const instance of instances) {
if (instance.page !== page) continue
const span = WIDGET_SPANS[instance.size]
for (let row = instance.row; row < instance.row + span.rows; row += 1) {
for (
let column = instance.column;
column < instance.column + span.columns;
column += 1
) {
cells.add(row * WIDGET_GRID_COLUMNS + column)
}
}
}
return cells
}
+690 -49
View File
@@ -1,17 +1,31 @@
<script setup lang="ts">
import { Search, X } from 'lucide-vue-next'
import { kGlass } from 'konsta/vue'
import { computed, ref, watch } from 'vue'
import { Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue'
import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue'
import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue'
import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import { useWidgetsStore } from '@/stores/widgets'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import type { LaunchablePhoneAppId } from '@/types/apps'
import { HOME_GRID_PAGE_SIZE, type HomeArea } from '@/utils/homeLayout'
import { paginateItems } from '@/utils/pages'
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import {
deleteHomePage as previewHomePageDelete,
HOME_GRID_PAGE_SIZE,
MAX_HOME_GRID_PAGES,
type HomeArea,
} from '@/utils/homeLayout'
import {
deleteWidgetPage as previewWidgetPageDelete,
moveWidget as previewWidgetMove,
WIDGET_GRID_COLUMNS,
widgetOccupiedCells,
} from '@/utils/widgetLayout'
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'games',
@@ -22,18 +36,42 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
]
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
const searchQuery = ref('')
const searchFocused = ref(false)
const showAllApps = ref(false)
const editMode = ref(false)
const widgetPickerOpened = ref(false)
const widgetActionId = ref<string | null>(null)
const widgetConfigId = ref<string | null>(null)
const draggingWidgetId = ref<string | null>(null)
const widgetDragGrip = ref<{ x: number; y: number } | null>(null)
const widgetDragSize = ref<{ height: number; width: number } | null>(null)
const widgetDragPreview = ref<{
column: number
id: string
page: number
row: number
} | null>(null)
const dragOffset = ref(0)
const dragging = ref(false)
const pageTransitioning = ref(false)
const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
let pointerStart = 0
let pointerStartY = 0
let pointerStartedAt = 0
let pointerPageStart = 0
let backgroundHoldTimer: number | undefined
let blankTapCandidate = false
let widgetPreviewTimer: number | undefined
let pendingWidgetPointer: { clientX: number; clientY: number } | null = null
let lastWidgetPointer: { clientX: number; clientY: number } | null = null
let edgePageTimer: number | undefined
let edgePageDirection = 0
let edgePageLocked = false
const installedApps = computed(() =>
PHONE_APPS.filter(
@@ -43,19 +81,106 @@ const installedApps = computed(() =>
const installedAppsById = computed(
() => new Map(installedApps.value.map((app) => [app.id, app])),
)
const gridSlots = computed(() =>
appStore.homeLayout.grid.map((id) =>
id ? (installedAppsById.value.get(id) ?? null) : null,
const hiddenApps = computed(() =>
installedApps.value.filter((app) =>
appStore.homeLayout.hidden.includes(app.id),
),
)
const appPages = computed(() =>
paginateItems(gridSlots.value, HOME_GRID_PAGE_SIZE),
const gridEntries = computed(() =>
appStore.homeLayout.grid
.map((id, sourceIndex) => ({
app: id ? (installedAppsById.value.get(id) ?? null) : null,
sourceIndex,
}))
.filter(
(entry): entry is { app: PhoneAppDefinition; sourceIndex: number } =>
entry.app !== null,
),
)
const previewWidgetLayout = computed(() => {
const preview = widgetDragPreview.value
return preview
? previewWidgetMove(
widgets.layout,
preview.id,
preview.page,
preview.column,
preview.row,
)
: widgets.layout
})
const appPages = computed(() => {
const entries = [...gridEntries.value]
const pages: Array<{
cells: Array<{ app: PhoneAppDefinition; sourceIndex: number } | null>
page: number
}> = []
const maxWidgetPage = Math.max(
1,
...previewWidgetLayout.value.instances.map((instance) => instance.page),
)
const persistedHomePages = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
)
const lastHomePage = Math.max(maxWidgetPage, persistedHomePages)
let page = 1
let entryIndex = 0
while (entryIndex < entries.length || page <= lastHomePage) {
const occupied = widgetOccupiedCells(
previewWidgetLayout.value.instances,
page,
)
if (widgetDragPreview.value) {
for (const cell of widgetOccupiedCells(widgets.layout.instances, page)) {
occupied.add(cell)
}
}
const cells = Array.from<{
app: PhoneAppDefinition
sourceIndex: number
} | null>({ length: HOME_GRID_PAGE_SIZE }).fill(null)
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
if (occupied.has(cell) || entryIndex >= entries.length) continue
cells[cell] = entries[entryIndex]
entryIndex += 1
}
pages.push({ cells, page })
page += 1
}
return pages.length
? pages
: [{ cells: Array(HOME_GRID_PAGE_SIZE).fill(null), page: 1 }]
})
const pageCount = computed(() => appPages.value.length + 2)
const libraryPage = computed(() => pageCount.value - 1)
const isAppPage = computed(
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
)
const isEditablePage = computed(
() => phone.currentPage >= 0 && phone.currentPage < libraryPage.value,
)
const canAddHomePage = computed(
() =>
appStore.homeLayout.grid.length < HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
)
const addingHomePage = ref(false)
const persistedHomePageCount = computed(() =>
Math.max(1, Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE)),
)
const canDeleteCurrentPage = computed(() => {
if (phone.currentPage < 1 || persistedHomePageCount.value <= 1) return false
const remainingPages = persistedHomePageCount.value - 1
return (
previewHomePageDelete(appStore.homeLayout, phone.currentPage) !==
appStore.homeLayout &&
previewWidgetPageDelete(
widgets.layout,
phone.currentPage,
remainingPages,
) !== widgets.layout
)
})
const dockSlots = computed(() =>
appStore.homeLayout.dock.map((id) =>
id ? (installedAppsById.value.get(id) ?? null) : null,
@@ -113,53 +238,348 @@ const trackStyle = computed(() => ({
width: `${pageCount.value * 100}%`,
}))
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
const activeWidget = computed(() =>
widgets.layout.instances.find(
(instance) => instance.id === widgetActionId.value,
),
)
const configuredWidget = computed(
() =>
widgets.layout.instances.find(
(instance) => instance.id === widgetConfigId.value,
) ?? null,
)
function clearBackgroundHold(): void {
if (backgroundHoldTimer !== undefined)
window.clearTimeout(backgroundHoldTimer)
backgroundHoldTimer = undefined
}
function changePage(page: number): void {
const targetPage = Math.max(0, Math.min(page, pageCount.value - 1))
if (targetPage === phone.currentPage) {
pageTransitioning.value = false
return
}
pageTransitioning.value = true
phone.setCurrentPage(targetPage, pageCount.value)
}
function finishPageTransition(event: TransitionEvent): void {
if (event.propertyName !== 'transform') return
pageTransitioning.value = false
}
function onPointerDown(event: PointerEvent): void {
if (editMode.value) return
const target = event.target as HTMLElement
if (target.closest('button, input')) return
if (target.closest('button, input, .home-widget-shell')) {
clearBackgroundHold()
dragging.value = false
dragOffset.value = 0
blankTapCandidate = false
return
}
pointerStart = event.clientX
pointerStartY = event.clientY
pointerStartedAt = Date.now()
pointerPageStart = phone.currentPage
blankTapCandidate = editMode.value
if (editMode.value) return
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
clearBackgroundHold()
backgroundHoldTimer = window.setTimeout(() => {
enterEditMode()
backgroundHoldTimer = undefined
}, 520)
}
function onPointerMove(event: PointerEvent): void {
const distanceX = event.clientX - pointerStart
const distanceY = event.clientY - pointerStartY
if (Math.hypot(distanceX, distanceY) > 8) {
clearBackgroundHold()
blankTapCandidate = false
}
if (!dragging.value) return
dragOffset.value = event.clientX - pointerStart
if (Math.abs(distanceY) > Math.abs(distanceX)) {
dragging.value = false
dragOffset.value = 0
return
}
const atFirstPage = pointerPageStart === 0 && distanceX > 0
const atLastPage = pointerPageStart === pageCount.value - 1 && distanceX < 0
dragOffset.value = atFirstPage || atLastPage ? distanceX * 0.28 : distanceX
}
function finishPointer(event: PointerEvent): void {
clearBackgroundHold()
if (editMode.value && blankTapCandidate) {
blankTapCandidate = false
editMode.value = false
return
}
if (!dragging.value) return
const distance = event.clientX - pointerStart
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
const velocity = Math.abs(distance) / elapsed
if (Math.abs(distance) > 48 || velocity > 0.45) {
phone.setCurrentPage(
phone.currentPage + (distance < 0 ? 1 : -1),
pageCount.value,
)
changePage(pointerPageStart + (distance < 0 ? 1 : -1))
}
dragging.value = false
dragOffset.value = 0
}
function cancelPointer(): void {
clearBackgroundHold()
blankTapCandidate = false
dragging.value = false
dragOffset.value = 0
}
function enterEditMode(): void {
editMode.value = true
dragging.value = false
dragOffset.value = 0
}
function openWidgetMenu(id: string): void {
enterEditMode()
widgetActionId.value = id
}
function startWidgetDrag(id: string, event: PointerEvent): void {
const widget = Array.from(
document.querySelectorAll<HTMLElement>('[data-widget-id]'),
).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return
pageTransitioning.value = false
const bounds = widget.getBoundingClientRect()
draggingWidgetId.value = id
widgetDragGrip.value = {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
}
widgetDragSize.value = { height: bounds.height, width: bounds.width }
}
function updateWidgetDragPreview(event: {
clientX: number
clientY: number
}): void {
const id = draggingWidgetId.value
const grip = widgetDragGrip.value
if (!id || !grip) return
const page = phone.currentPage
const grid = document.querySelector<HTMLElement>(
`[data-widget-page="${page}"]`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return
const renderedGridBounds = grid.getBoundingClientRect()
const pageBounds = pageElement.getBoundingClientRect()
const springboardBounds = springboard.getBoundingClientRect()
const gridLeft =
springboardBounds.left + (renderedGridBounds.left - pageBounds.left)
const gridStyle = getComputedStyle(grid)
const columnGap = Number.parseFloat(gridStyle.columnGap) || 0
const rowGap = Number.parseFloat(gridStyle.rowGap) || 0
const columnWidth =
(renderedGridBounds.width - columnGap * (WIDGET_GRID_COLUMNS - 1)) /
WIDGET_GRID_COLUMNS
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows)
const column = Math.round(
(event.clientX - grip.x - gridLeft) / (columnWidth + columnGap),
)
const row = Math.round(
(event.clientY - grip.y - renderedGridBounds.top) / (rowHeight + rowGap),
)
if (Number.isFinite(rowHeight)) {
const current = widgetDragPreview.value
if (
!current ||
current.id !== id ||
current.page !== page ||
current.column !== column ||
current.row !== row
) {
widgetDragPreview.value = { column, id, page, row }
}
}
}
function clearEdgePageTurn(): void {
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
edgePageTimer = undefined
edgePageDirection = 0
edgePageLocked = false
}
function queueEdgePageTurn(
event: PointerEvent,
dragType: 'app' | 'widget',
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return
const bounds = springboard.getBoundingClientRect()
const edgeSize = Math.min(42, bounds.width * 0.12)
let direction = 0
if (dragType === 'widget' && widgetDragGrip.value && widgetDragSize.value) {
const widgetLeft = event.clientX - widgetDragGrip.value.x
const widgetRight = widgetLeft + widgetDragSize.value.width
const overhang = Math.min(22, widgetDragSize.value.width * 0.14)
if (widgetLeft <= bounds.left - overhang) direction = -1
else if (widgetRight >= bounds.right + overhang) direction = 1
} else if (event.clientX <= bounds.left + edgeSize) direction = -1
else if (event.clientX >= bounds.right - edgeSize) direction = 1
const minimumPage = dragType === 'widget' ? 0 : 1
const maximumPage = libraryPage.value - 1
const destination = phone.currentPage + direction
if (
direction === 0 ||
destination < minimumPage ||
destination > maximumPage
) {
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
edgePageTimer = undefined
edgePageDirection = 0
edgePageLocked = false
return
}
if (edgePageLocked && edgePageDirection === direction) return
if (edgePageLocked) edgePageLocked = false
if (edgePageTimer !== undefined && edgePageDirection === direction) return
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
edgePageDirection = direction
edgePageTimer = window.setTimeout(() => {
const targetPage = phone.currentPage + direction
changePage(targetPage)
if (dragType === 'widget' && lastWidgetPointer) {
updateWidgetDragPreview(lastWidgetPointer)
}
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = true
}, 520)
}
function queueWidgetDragPreview(event: PointerEvent): void {
queueEdgePageTurn(event, 'widget')
lastWidgetPointer = {
clientX: event.clientX,
clientY: event.clientY,
}
pendingWidgetPointer = lastWidgetPointer
if (widgetPreviewTimer !== undefined) return
widgetPreviewTimer = window.setTimeout(() => {
if (pendingWidgetPointer) updateWidgetDragPreview(pendingWidgetPointer)
pendingWidgetPointer = null
widgetPreviewTimer = undefined
}, 110)
}
function clearWidgetDragPreview(): void {
clearEdgePageTurn()
if (widgetPreviewTimer !== undefined) window.clearTimeout(widgetPreviewTimer)
widgetPreviewTimer = undefined
pendingWidgetPointer = null
lastWidgetPointer = null
widgetDragGrip.value = null
widgetDragSize.value = null
widgetDragPreview.value = null
}
function finishWidgetDrag(event: PointerEvent): void {
const id = draggingWidgetId.value
if (!id) return
lastWidgetPointer = { clientX: event.clientX, clientY: event.clientY }
updateWidgetDragPreview(lastWidgetPointer)
const preview = widgetDragPreview.value
if (preview?.id === id) {
widgets.move(id, preview.page, preview.column, preview.row)
}
draggingWidgetId.value = null
clearWidgetDragPreview()
}
function stopWidgetDrag(): void {
draggingWidgetId.value = null
clearWidgetDragPreview()
}
function removeWidget(id: string): void {
widgets.remove(id)
if (widgetActionId.value === id) widgetActionId.value = null
if (widgetConfigId.value === id) widgetConfigId.value = null
}
async function addWidget(kind: WidgetKind, size: WidgetSize): Promise<void> {
const targetPage = isEditablePage.value ? phone.currentPage : 1
const addedId = widgets.add(kind, size, targetPage)
if (!addedId) return
widgetPickerOpened.value = false
editMode.value = true
await nextTick()
const added = widgets.layout.instances.find(
(instance) => instance.id === addedId,
)
if (added) changePage(added.page)
}
function openWidgetConfig(): void {
widgetConfigId.value = widgetActionId.value
widgetActionId.value = null
}
async function saveWidgetConfig(
size: WidgetSize,
settings: WidgetSettings,
): Promise<void> {
if (!widgetConfigId.value) return
const id = widgetConfigId.value
widgets.resize(id, size)
widgets.updateSettings(id, settings)
widgetConfigId.value = null
editMode.value = true
await nextTick()
const configured = widgets.layout.instances.find(
(instance) => instance.id === id,
)
if (configured) changePage(configured.page)
}
function targetHomeIndex(page: number, cell: number): number {
return Math.min(
Math.max(0, appStore.homeLayout.grid.length - 1),
(page - 1) * HOME_GRID_PAGE_SIZE + cell,
)
}
function startHomeDrag(area: HomeArea, index: number): void {
draggingHomeApp.value = { area, index }
}
function moveHomeDrag(event: PointerEvent): void {
if (draggingHomeApp.value?.area === 'grid') {
queueEdgePageTurn(event, 'app')
}
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
const dragged = draggingHomeApp.value
if (!dragged) return
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.find((element) => !element.closest('.app-icon-item--dragging'))
.find(
(element) =>
!element.closest('.app-icon-item--dragging') &&
(element.closest('[data-home-index]') ||
element.closest('[data-home-area]')),
)
const targetArea = target?.closest<HTMLElement>('[data-home-area]')
let targetItem = target?.closest<HTMLElement>('[data-home-index]')
if (!targetItem && targetArea) {
@@ -191,13 +611,40 @@ function finishHomeDrag(event: PointerEvent): void {
}
function stopHomeDrag(): void {
clearEdgePageTurn()
draggingHomeApp.value = null
}
async function addHomePage(): Promise<void> {
if (addingHomePage.value) return
addingHomePage.value = true
try {
if (!appStore.addHomePage()) return
await nextTick()
changePage(appPages.value.length)
} finally {
addingHomePage.value = false
}
}
async function deleteCurrentPage(): Promise<void> {
if (!canDeleteCurrentPage.value) return
const deletedPage = phone.currentPage
const remainingPages = persistedHomePageCount.value - 1
widgets.deletePage(deletedPage, remainingPages)
appStore.deleteHomePage(deletedPage)
await nextTick()
changePage(Math.min(deletedPage, appPages.value.length))
}
function removeHomeApp(appId: LaunchablePhoneAppId): void {
appStore.removeHomeApp(appId)
}
function restoreHomeApp(appId: LaunchablePhoneAppId): void {
appStore.restoreHomeApp(appId)
}
function clearSearch(): void {
searchQuery.value = ''
searchFocused.value = false
@@ -209,7 +656,7 @@ function openAllApps(): void {
showAllApps.value = true
}
watch(isAppPage, (visible) => {
watch(isEditablePage, (visible) => {
if (!visible) editMode.value = false
})
</script>
@@ -222,6 +669,7 @@ watch(isAppPage, (visible) => {
{
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
>
@@ -231,57 +679,82 @@ watch(isAppPage, (visible) => {
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="finishPointer"
@pointercancel="finishPointer"
@pointercancel="cancelPointer"
@transitionend.self="finishPageTransition"
@transitioncancel.self="finishPageTransition"
>
<section
class="springboard-page springboard-page--widgets"
:style="pageStyle"
:aria-label="phone.t('Home.widgets.label')"
>
<SpringboardWidgets />
<div class="springboard-widget-page-scroll">
<SpringboardWidgetGrid
:page="0"
:dragging-widget-id="draggingWidgetId"
:edit-mode="editMode"
@dragcancel="stopWidgetDrag"
@dragend="finishWidgetDrag"
@dragmove="queueWidgetDragPreview"
@dragstart="startWidgetDrag"
@menu="openWidgetMenu"
@remove="removeWidget"
/>
</div>
</section>
<section
v-for="(apps, pageIndex) in appPages"
:key="`apps-${pageIndex}`"
v-for="page in appPages"
:key="`apps-${page.page}`"
class="springboard-page springboard-page--apps"
:style="pageStyle"
:aria-label="phone.t('Home.apps')"
>
<div class="app-grid" data-home-area="grid">
<SpringboardWidgetGrid
:page="page.page"
:dragging-widget-id="draggingWidgetId"
:edit-mode="editMode"
@dragcancel="stopWidgetDrag"
@dragend="finishWidgetDrag"
@dragmove="queueWidgetDragPreview"
@dragstart="startWidgetDrag"
@menu="openWidgetMenu"
@remove="removeWidget"
/>
<TransitionGroup
:name="
draggingWidgetId && !pageTransitioning ? 'app-reflow' : undefined
"
tag="div"
class="app-grid"
data-home-area="grid"
>
<template
v-for="(app, appIndex) in apps"
:key="
app?.id ??
`grid-empty-${pageIndex * HOME_GRID_PAGE_SIZE + appIndex}`
"
v-for="(cell, cellIndex) in page.cells"
:key="cell?.app.id ?? `grid-empty-${page.page}-${cellIndex}`"
>
<AppIcon
v-if="app"
:app="app"
v-if="cell"
:app="cell.app"
data-home-area="grid"
:data-home-index="pageIndex * HOME_GRID_PAGE_SIZE + appIndex"
:data-home-index="cell.sourceIndex"
:edit-mode="editMode"
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragstart="
startHomeDrag(
'grid',
pageIndex * HOME_GRID_PAGE_SIZE + appIndex,
)
"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(app.id)"
@remove="removeHomeApp(cell.app.id)"
/>
<div
v-else
class="app-grid-slot"
data-home-area="grid"
:data-home-index="pageIndex * HOME_GRID_PAGE_SIZE + appIndex"
:data-home-index="targetHomeIndex(page.page, cellIndex)"
aria-hidden="true"
></div>
</template>
</div>
</TransitionGroup>
</section>
<section
@@ -314,6 +787,32 @@ watch(isAppPage, (visible) => {
class="app-library-groups"
:class="{ 'app-library-groups--behind': showAllApps }"
>
<article
v-if="hiddenApps.length"
class="app-library-group app-library-group--removed"
>
<div class="app-library-group__icons">
<div
v-for="app in hiddenApps.slice(0, 4)"
:key="app.id"
class="app-library-restore-item"
>
<AppIcon :app="app" compact :show-label="false" />
<k-button
small
rounded
class="app-library-restore-button"
:aria-label="
phone.t('Home.addToHome', { app: phone.t(app.labelKey) })
"
@click.stop="restoreHomeApp(app.id)"
>
<Plus :size="13" :stroke-width="3" />
</k-button>
</div>
</div>
<span>{{ phone.t('Home.removedFromHome') }}</span>
</article>
<article
v-for="group in appGroups"
:key="group.key"
@@ -366,6 +865,18 @@ watch(isAppPage, (visible) => {
>
<AppIcon :app="app" compact :show-label="false" />
<span>{{ phone.t(app.labelKey) }}</span>
<k-button
v-if="appStore.homeLayout.hidden.includes(app.id)"
small
rounded
class="app-library-row-restore"
:aria-label="
phone.t('Home.addToHome', { app: phone.t(app.labelKey) })
"
@click.stop="restoreHomeApp(app.id)"
>
<Plus :size="14" :stroke-width="3" />
</k-button>
</div>
</div>
<p v-if="filteredApps.length === 0" class="app-library-empty">
@@ -378,7 +889,20 @@ watch(isAppPage, (visible) => {
<Transition name="edit-done">
<k-glass
v-if="editMode && isAppPage"
v-if="editMode && isEditablePage"
component="button"
class="springboard-edit-add"
type="button"
:aria-label="phone.t('Home.widgetSystem.add')"
@click="widgetPickerOpened = true"
>
<Plus :size="20" :stroke-width="2.5" />
</k-glass>
</Transition>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage"
component="button"
class="springboard-edit-done"
type="button"
@@ -409,6 +933,7 @@ watch(isAppPage, (visible) => {
:show-label="false"
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', appIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(app.id)"
@@ -425,18 +950,134 @@ watch(isAppPage, (visible) => {
</Transition>
<nav
v-if="isAppPage && appPages.length > 1"
v-if="phone.currentPage < libraryPage"
class="page-indicator"
:class="{ 'page-indicator--without-dock': phone.currentPage === 0 }"
:aria-label="phone.t('Home.pages')"
>
<button
v-for="(_, pageIndex) in appPages"
:key="pageIndex"
v-for="pageIndex in appPages.length + 1"
:key="pageIndex - 1"
type="button"
:class="{ active: phone.currentPage === pageIndex + 1 }"
:aria-label="`${phone.t('Home.page')} ${pageIndex + 1}`"
@click="phone.setCurrentPage(pageIndex + 1, pageCount)"
:class="{ active: phone.currentPage === pageIndex - 1 }"
:aria-label="`${phone.t('Home.page')} ${pageIndex}`"
@click="changePage(pageIndex - 1)"
></button>
<k-button
v-if="editMode && canAddHomePage"
small
rounded
class="springboard-page-add"
type="button"
:aria-label="phone.t('Home.addPage')"
@click="addHomePage"
>
<Plus :size="12" :stroke-width="2.8" />
</k-button>
<k-button
v-if="editMode && canDeleteCurrentPage"
small
rounded
class="springboard-page-delete"
type="button"
:aria-label="phone.t('Home.deletePage')"
@click="deleteCurrentPage"
>
<Trash2 :size="12" :stroke-width="2.4" />
</k-button>
</nav>
<k-sheet
:opened="widgetActionId !== null"
class="widget-action-sheet"
@backdropclick="widgetActionId = null"
>
<div class="widget-sheet-handle" />
<h3>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</h3>
<k-list inset strong class="widget-action-list">
<k-list-item
link
link-component="button"
:title="phone.t('Home.widgetSystem.editWidget')"
@click="openWidgetConfig"
>
<template #media><Pencil :size="20" /></template>
</k-list-item>
<k-list-item
link
link-component="button"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
@click="activeWidget && removeWidget(activeWidget.id)"
>
<template #media><Trash2 :size="20" /></template>
</k-list-item>
</k-list>
<k-button
large
rounded
class="widget-action-cancel"
@click="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</k-button>
</k-sheet>
<WidgetPickerSheet
:opened="widgetPickerOpened"
@add="addWidget"
@close="widgetPickerOpened = false"
/>
<WidgetConfigSheet
:instance="configuredWidget"
:opened="widgetConfigId !== null"
@close="widgetConfigId = null"
@save="saveWidgetConfig"
/>
</section>
</template>
<style scoped>
:global(.widget-action-sheet) {
z-index: 105;
padding: 9px 0 30px;
border-radius: 25px 25px 0 0;
}
.widget-sheet-handle {
width: 36px;
height: 5px;
margin: 0 auto 9px;
border-radius: 999px;
background: rgb(142 142 147 / 45%);
}
.widget-action-sheet h3 {
margin: 3px 18px 9px;
color: inherit;
font-size: 16px;
font-weight: 650;
text-align: center;
}
.widget-action-list {
margin-top: 0;
margin-bottom: 10px;
}
.widget-action-remove :deep([class*='title']),
.widget-action-remove :deep(svg) {
color: #ff3b30 !important;
}
.widget-action-cancel {
width: calc(100% - 32px);
margin: 0 16px;
}
</style>
File diff suppressed because it is too large Load Diff
+33 -2
View File
@@ -7,6 +7,7 @@ import {
kSegmentedButton,
} from 'konsta/vue'
import {
ArrowLeft,
Images,
RefreshCw,
RotateCcwSquare,
@@ -38,7 +39,7 @@ const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.messageAttachment
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
@@ -205,6 +206,10 @@ function capture(): void {
void requestPhoto()
}
function cancelMediaSelection(): void {
void router.replace(messageMedia.cancel())
}
function setMode(nextMode: MediaType): void {
if (recording.value || savingVideo.value) return
mode.value = nextMode
@@ -230,6 +235,9 @@ function toggleOrientation(): void {
},
'*',
)
void nuiCall('camera:setOrientation', {
landscape: phone.cameraLandscape,
})
}
function setZoom(zoom: (typeof zoomLevels)[number]): void {
@@ -348,6 +356,7 @@ onMounted(() => {
{ data: { landscape: false }, type: 'camera:orientation' },
'*',
)
void nuiCall('camera:setOrientation', { landscape: false })
window.postMessage(
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
'*',
@@ -381,6 +390,7 @@ onBeforeUnmount(() => {
'*',
)
window.postMessage({ type: 'camera:recordCancel' }, '*')
void nuiCall('camera:setOrientation', { landscape: false })
void nuiCall('camera:setFlash', { enabled: false })
void nuiCall('camera:setActive', { active: false })
})
@@ -413,7 +423,17 @@ onBeforeUnmount(() => {
</div>
<header class="camera-topbar">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-if="!requestedMessageMedia"
component="button"
type="button"
class="camera-control"
@@ -489,7 +509,7 @@ onBeforeUnmount(() => {
router.push({
path: '/apps/photos',
query: requestedMessageMedia
? { messageAttachment: requestedMessageMedia }
? { mediaAttachment: requestedMessageMedia }
: undefined,
})
"
@@ -671,6 +691,17 @@ onBeforeUnmount(() => {
.camera-control {
--color-primary: transparent;
}
.camera-picker-back {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #1c1c1ecc;
color: #fff;
backdrop-filter: blur(16px);
}
.camera-control svg {
width: 21px;
height: 21px;
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -41,9 +41,13 @@ const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const value = route.query.messageAttachment
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const multipleSelection = computed(
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
)
const selectedMediaIds = ref<number[]>([])
const media = ref<PhoneMedia[]>([])
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
const loading = ref(true)
@@ -203,6 +207,14 @@ function observeMore(): void {
function openMedia(entry: PhoneMedia): void {
if (requestedMessageMedia.value) {
if (multipleSelection.value) {
const index = selectedMediaIds.value.indexOf(entry.id)
if (index >= 0) selectedMediaIds.value.splice(index, 1)
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
selectedMediaIds.value.push(entry.id)
}
return
}
const returnPath = messageMedia.complete(entry)
if (returnPath) void router.replace(returnPath)
return
@@ -214,6 +226,15 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function completeMultipleSelection(): void {
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
const entry = media.value.find((item) => item.id === id)
return entry ? [entry] : []
})
const returnPath = messageMedia.completeMany(selectedMedia)
if (returnPath) void router.replace(returnPath)
}
function cancelMessageSelection(): void {
void router.replace(messageMedia.cancel())
}
@@ -352,6 +373,15 @@ onBeforeUnmount(() => {
@click="cancelMessageSelection"
/>
</template>
<template v-if="multipleSelection" #right>
<k-link
component="button"
:disabled="!selectedMediaIds.length"
@click="completeMultipleSelection"
>
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
<div class="gallery-content">
@@ -375,6 +405,7 @@ onBeforeUnmount(() => {
v-for="entry in media"
:key="entry.id"
class="gallery-tile"
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
type="button"
:aria-label="
phone.t(
@@ -401,6 +432,12 @@ onBeforeUnmount(() => {
<span v-if="entry.mediaType === 'video'" class="gallery-video-badge">
<Play :size="16" fill="currentColor" />
</span>
<span
v-if="multipleSelection && selectedMediaIds.includes(entry.id)"
class="gallery-selection-badge"
>
{{ selectedMediaIds.indexOf(entry.id) + 1 }}
</span>
</button>
<span
v-if="hasMore"
@@ -594,6 +631,29 @@ onBeforeUnmount(() => {
border: 0;
background: #d1d1d6;
}
.gallery-tile--selected::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid #0a84ff;
pointer-events: none;
}
.gallery-selection-badge {
position: absolute;
top: 7px;
right: 7px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 2px solid #fff;
border-radius: 50%;
background: #0a84ff;
color: #fff;
font-size: 12px;
font-weight: 700;
box-shadow: 0 2px 6px #0006;
}
.gallery-grid--fill .gallery-tile {
height: 100%;
}
+387 -68
View File
@@ -11,31 +11,51 @@ import {
Images,
MapPin,
Plus,
Search,
Send,
Store,
Trash2,
UserRound,
X,
} from 'lucide-vue-next'
import {
kButton,
kGlass,
kIcon,
kNavbar,
kPage,
kSearchbar,
kTabbar,
kTabbarLink,
kToolbarPane,
} from 'konsta/vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import { useAccountStore } from '@/stores/account'
import { useMediaStore } from '@/stores/media'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import type { PagesCategory, PagesPost } from '@/types/pages'
type SelectedPhoto = { background: string; id: string }
type ComposeDraft = {
body: string
category: Exclude<PagesCategory, 'citymarkt'>
district: string
images: string[]
title: string
}
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
type Screen = 'main' | 'detail' | 'compose'
type Tab = 'feed' | 'create' | 'profile'
const phone = usePhoneStore()
const account = useAccountStore()
const media = useMediaStore()
const messageMedia = useMessageMediaStore()
const pages = usePagesStore()
const route = useRoute()
const router = useRouter()
const screen = ref<Screen>('main')
const tab = ref<Tab>('feed')
@@ -46,9 +66,8 @@ const search = ref('')
const category = ref<string>('all')
const feedback = ref('')
const reactionPending = ref(false)
const photoSource = ref<'camera' | 'gallery' | null>(null)
const cameraFlash = ref(false)
const draft = ref({
const pickedPhotos = ref<SelectedPhoto[]>([])
const draft = ref<ComposeDraft>({
body: '',
category: 'recommendation' as Exclude<PagesCategory, 'citymarkt'>,
district: 'los_santos',
@@ -76,10 +95,10 @@ const displayedPosts = computed(() => tab.value === 'profile'
: pages.items)
const isAuthenticated = computed(() => Boolean(account.email))
const selectedPhotos = computed(() => draft.value.images
.map((id) => media.photos.find((photo) => photo.id === id))
.map((id) => pickedPhotos.value.find((photo) => photo.id === id))
.filter((photo) => photo !== undefined))
const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
gradient: photo.gradient,
gradient: photo.background,
media_id: photo.id,
sort_order: index + 1,
})))
@@ -110,6 +129,15 @@ async function loadFeed(): Promise<void> {
await pages.load({ category: category.value, search: search.value })
}
function updateSearch(event: Event): void {
search.value = (event.target as HTMLInputElement).value
}
function clearSearch(): void {
search.value = ''
void loadFeed()
}
async function selectTab(next: Tab): Promise<void> {
if (next === 'create') {
if (!isAuthenticated.value) {
@@ -134,19 +162,29 @@ async function openPost(post: PagesPost): Promise<void> {
function togglePhoto(id: string): void {
const index = draft.value.images.indexOf(id)
if (index >= 0) draft.value.images.splice(index, 1)
else if (draft.value.images.length < 6) draft.value.images.push(id)
else showFeedback('Apps.localPages.photoLimit')
if (index >= 0) {
draft.value.images.splice(index, 1)
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
}
}
function capturePhoto(): void {
if (draft.value.images.length >= 6) {
function openMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - draft.value.images.length
if (remaining < 1) {
showFeedback('Apps.localPages.photoLimit')
return
}
cameraFlash.value = true
draft.value.images.push(media.capture().id)
window.setTimeout(() => (cameraFlash.value = false), 120)
messageMedia.begin(
'local-pages:compose',
'photo',
'/apps/local-pages?compose=1',
app === 'photos' ? remaining : 1,
{
draft: { ...draft.value, images: [...draft.value.images] },
photos: [...pickedPhotos.value],
} satisfies MediaContext,
)
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
}
async function publish(): Promise<void> {
@@ -166,7 +204,7 @@ async function publish(): Promise<void> {
return
}
draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
photoSource.value = null
pickedPhotos.value = []
tab.value = 'feed'
screen.value = 'main'
showFeedback('Apps.localPages.published')
@@ -225,40 +263,72 @@ function openCityMarktListing(): void {
})
}
onMounted(() => void loadFeed())
onMounted(() => {
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
if (selection) {
if (selection.context) {
draft.value = selection.context.draft
pickedPhotos.value = selection.context.photos
}
for (const media of selection.media) {
const id = String(media.id)
if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
draft.value.images.push(id)
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
}
}
if (route.query.compose === '1') screen.value = 'compose'
void loadFeed()
})
</script>
<template>
<main class="pages" :class="{ 'pages--light': !phone.isDarkMode }">
<k-page
component="main"
class="pages pb-safe-24"
:class="{ 'pages--light': !phone.isDarkMode }"
:colors="{ bgIos: 'bg-transparent' }"
>
<template v-if="screen === 'main'">
<header class="pages__header">
<div>
<span class="pages__brand">
<MapPin :size="14" />
{{ phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name') }}
</span>
<h1>{{ phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile') }}</h1>
</div>
</header>
<k-navbar
class="pages-navbar"
:subtitle="phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name')"
:title="phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile')"
/>
<section class="pages__content">
<template v-if="tab === 'feed'">
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
<form class="pages__search" @submit.prevent="loadFeed"><Search :size="17" /><input v-model="search" :placeholder="phone.t('Apps.localPages.searchPlaceholder')" /><button>{{ phone.t('Apps.localPages.search') }}</button></form>
<k-glass class="pages-hero-glass">
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
</k-glass>
<k-searchbar
component="form"
class="pages-searchbar"
:value="search"
:placeholder="phone.t('Apps.localPages.searchPlaceholder')"
@input="updateSearch"
@clear="clearSearch"
@submit.prevent="loadFeed"
/>
<CityMarktSelect :model-value="category" :options="categoryOptions" @change="(value) => { category = value; loadFeed() }" />
</template>
<template v-else>
<div v-if="!isAuthenticated" class="pages__empty"><UserRound :size="42" /><strong>{{ phone.t('Apps.localPages.signInTitle') }}</strong><span>{{ phone.t('Apps.localPages.signInBody') }}</span></div>
<template v-else>
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
<k-glass class="pages-profile-glass">
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
</k-glass>
<k-glass class="pages-segmented-glass">
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
</k-glass>
</template>
</template>
<div v-if="pages.isLoading" class="pages__empty">{{ phone.t('Common.loading') }}</div>
<div v-else-if="isAuthenticated || tab === 'feed'" class="pages__feed">
<article v-for="post in displayedPosts" :key="post.id" class="pages__post">
<k-glass v-for="post in displayedPosts" :key="post.id" class="pages-post-glass">
<article class="pages__post">
<button class="pages__post-open" type="button" @click="openPost(post)">
<div class="pages__post-head"><span>{{ post.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ post.author_name }}</strong><small><MapPin :size="10" /> {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}</small></div><i>{{ label('categories', post.category) }}</i></div>
<div v-if="post.image" class="pages__cover" :style="{ background: post.image }"><b v-if="post.images.length > 1">1 / {{ post.images.length }}</b></div>
@@ -269,20 +339,53 @@ onMounted(() => void loadFeed())
<span v-if="post.source_type === 'citymarkt'"><Store :size="14" /> CityMarkt</span>
<button type="button" :class="{ active: post.is_saved }" :disabled="reactionPending" :aria-label="phone.t('Apps.localPages.save')" @click="reactToPost(post, 'save')"><Bookmark :size="15" :fill="post.is_saved ? 'currentColor' : 'none'" /> {{ phone.t('Apps.localPages.save') }}</button>
</div>
</article>
</article>
</k-glass>
<div v-if="!displayedPosts.length" class="pages__empty"><Compass :size="38" /><strong>{{ phone.t('Apps.localPages.noPosts') }}</strong><span>{{ phone.t('Apps.localPages.noPostsBody') }}</span></div>
</div>
</section>
<nav class="pages__tabbar">
<button :class="{ active: tab === 'feed' }" @click="selectTab('feed')"><span><Compass :size="20" /></span>{{ phone.t('Apps.localPages.discover') }}</button>
<button class="create" @click="selectTab('create')"><span><Plus :size="23" /></span>{{ phone.t('Apps.localPages.create') }}</button>
<button :class="{ active: tab === 'profile' }" @click="selectTab('profile')"><span><UserRound :size="20" /></span>{{ phone.t('Apps.localPages.profile') }}</button>
</nav>
<k-tabbar
component="nav"
icons
labels
class="bottom-0 left-0 fixed"
inner-class="!w-full !max-w-none !gap-0 !px-1"
:aria-label="phone.t('Apps.localPages.name')"
>
<k-toolbar-pane class="pages__tab-pane">
<k-tabbar-link
component="button"
:active="tab === 'feed'"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('feed')"
>
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.discover') }}</span></template>
<template #icon><k-icon><Compass :size="20" /></k-icon></template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('create')"
>
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.create') }}</span></template>
<template #icon><span class="pages__tab-icon pages__tab-icon--create"><k-icon><Plus :size="21" /></k-icon></span></template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:active="tab === 'profile'"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('profile')"
>
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.profile') }}</span></template>
<template #icon><k-icon><UserRound :size="20" /></k-icon></template>
</k-tabbar-link>
</k-toolbar-pane>
</k-tabbar>
</template>
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
<header><button @click="screen = 'main'"><ArrowLeft :size="20" /></button><strong>{{ phone.t('Apps.localPages.post') }}</strong><button v-if="selected.is_owner" class="danger" @click="removePost"><Trash2 :size="18" /></button><button v-else @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></button></header>
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
<div class="pages__detail-scroll">
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
@@ -291,26 +394,44 @@ onMounted(() => void loadFeed())
</section>
<section v-else class="pages__compose">
<header><button @click="screen = 'main'"><X :size="20" /></button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><button :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</button></header>
<k-navbar
class="pages-create-navbar"
center-title
left-class="pages-create-action pages-create-action--close !w-11 !min-w-11 !max-w-11 !h-11 !p-0 !rounded-full"
right-class="pages-create-action pages-create-action--publish !min-w-[58px] !h-11 !p-0 !rounded-full"
:title="phone.t('Apps.localPages.shareWithCity')"
:subtitle="phone.t('Apps.localPages.newPost')"
>
<template #left>
<button class="pages-create-close" type="button" :aria-label="phone.t('Common.close')" @click="screen = 'main'">
<X :size="20" />
</button>
</template>
<template #right>
<button class="pages-create-publish" type="button" :disabled="!canPublish" @click="publish">
{{ phone.t('Apps.localPages.publish') }}
</button>
</template>
</k-navbar>
<div class="pages__compose-scroll">
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></label>
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></label>
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><k-glass class="pages__field-glass"><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></k-glass></label>
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><k-glass class="pages__field-glass pages__field-glass--textarea"><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></k-glass></label>
<div class="pages__form-row"><label>{{ phone.t('Apps.localPages.category') }}<CityMarktSelect :model-value="draft.category" :options="composeCategoryOptions" @change="(value) => draft.category = value as typeof draft.category" /></label><label>{{ phone.t('Apps.localPages.location') }}<CityMarktSelect :model-value="draft.district" :options="districtOptions" @change="(value) => draft.district = value" /></label></div>
<section class="pages__photos">
<ImagePlus :size="30" />
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
<div class="pages__photo-actions">
<button type="button" @click="photoSource = 'gallery'">
<k-glass><button type="button" @click="openMediaApp('photos')">
<span><Images :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
</button>
<button type="button" @click="photoSource = 'camera'">
</button></k-glass>
<k-glass><button type="button" @click="openMediaApp('camera')">
<span><Camera :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
</button>
</button></k-glass>
</div>
<div class="pages__selected-heading">
<strong>{{ phone.t('Apps.citymarkt.selectedPhotos') }}</strong>
@@ -330,27 +451,9 @@ onMounted(() => void loadFeed())
</div>
</section>
</div>
<div v-if="photoSource" class="pages__photo-source">
<header>
<div><small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small><strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong></div>
<span>{{ draft.images.length }} / 6</span>
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
</header>
<div v-if="photoSource === 'gallery'" class="pages__photo-picker">
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: draft.images.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i v-if="draft.images.includes(photo.id)">{{ draft.images.indexOf(photo.id) + 1 }}</i></button>
</div>
<div v-else class="pages__capture">
<div class="pages__viewfinder" :style="{ background: media.photos[0]?.gradient }">
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
<span class="pages__camera-flash" :class="{ active: cameraFlash }" />
</div>
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
<button class="pages__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="capturePhoto"><Camera :size="23" /></button>
</div>
</div>
</section>
<Transition name="toast"><div v-if="feedback" class="pages__toast">{{ feedback }}</div></Transition>
</main>
</k-page>
</template>
<style scoped>
@@ -395,4 +498,220 @@ onMounted(() => void loadFeed())
.pages__selected-heading span,.pages__photo-source>header small,.pages__photo-source>header>span{font-size:11px}
.pages__selected-strip button i,.pages__photo-picker i{font-size:10px}
.pages__toast{font-size:12px}
.pages {
--color-primary: var(--yellow);
position: relative;
height: 100%;
padding: 0;
background: #12171b !important;
}
.pages--light {
background: #fbfbf6 !important;
}
.pages-navbar {
--k-safe-area-top: 46px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
}
.pages__content {
position: absolute;
inset: 0;
height: auto;
padding: 108px 13px 112px;
}
.pages-hero-glass,
.pages-profile-glass,
.pages-segmented-glass,
.pages-post-glass {
width: 100%;
border-radius: 17px;
}
.pages-hero-glass {
margin-bottom: 10px;
}
.pages__hero {
height: 105px;
margin: 0;
background: transparent;
box-shadow: none;
}
.pages-searchbar {
margin-bottom: 8px;
}
.pages-profile-glass {
margin: 3px 0 10px;
}
.pages__profile {
margin: 0;
background: transparent;
}
.pages-segmented-glass {
padding: 4px;
border-radius: 13px;
}
.pages__segmented {
padding: 0;
background: transparent;
}
.pages-post-glass {
overflow: hidden;
}
.pages__post {
background: transparent;
box-shadow: none;
}
.pages__tab-pane {
width: 100% !important;
max-width: none;
margin-right: auto;
margin-left: auto;
flex: none;
justify-content: space-around;
gap: 2px;
padding: 0 4px;
}
.pages__tab-label {
display: block;
max-width: 52px;
overflow: hidden;
font-size: 9.5px;
line-height: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.pages__tab-pane) {
width: 100% !important;
max-width: none;
margin-right: auto;
margin-left: auto;
flex: none;
justify-content: space-around;
}
:global(.pages-tab-button) {
width: 20% !important;
min-width: 0 !important;
max-width: 58px !important;
flex: 0 0 20% !important;
padding-right: 3px !important;
padding-left: 3px !important;
}
.pages-create-navbar {
--k-safe-area-top: 46px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
}
.pages-create-action {
height: 44px;
border-radius: 9999px;
}
.pages-create-action--close {
width: 44px;
}
.pages-create-action--publish {
min-width: 58px;
}
.pages-create-close,
.pages-create-publish {
width: 100%;
height: 44px;
padding: 0;
border: 0;
appearance: none;
background: transparent;
color: inherit;
}
.pages-create-close {
display: grid;
place-items: center;
}
.pages-create-publish {
min-width: 58px;
padding: 0 13px;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 800;
}
.pages-create-publish:disabled {
opacity: 0.38;
}
.pages__compose-scroll {
position: absolute;
top: 104px;
right: 0;
bottom: 0;
left: 0;
height: auto;
padding-bottom: 35px;
}
.pages__field-glass {
min-height: 44px;
margin-top: 5px;
border-radius: 9999px;
overflow: hidden;
}
.pages__field-glass--textarea {
min-height: 116px;
border-radius: 18px;
}
.pages__field-glass > input,
.pages__field-glass > textarea {
width: 100%;
min-height: 44px;
margin: 0 !important;
padding: 11px 14px !important;
border: 0 !important;
outline: 0;
background: transparent !important;
color: inherit;
font-size: 13px !important;
}
.pages__field-glass > textarea {
min-height: 116px;
resize: none;
line-height: 1.45;
}
.pages__photo-actions > * {
min-width: 0;
border-radius: 14px;
}
.pages__photo-actions > * > button {
width: 100%;
min-height: 118px;
padding: 11px 9px;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
background: transparent;
}
.pages__photo-actions > * > button > span {
width: 34px;
height: 34px;
margin-bottom: 8px;
border-radius: 11px;
display: grid;
place-items: center;
color: var(--yellow);
}
.pages__tab-icon {
position: relative;
display: grid;
place-items: center;
}
.pages__tab-icon--create {
width: 38px;
height: 30px;
margin-top: -4px;
border-radius: 10px;
background: var(--yellow);
color: #17191a;
box-shadow: 0 4px 12px #00000030;
}
</style>
+52 -27
View File
@@ -123,8 +123,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="memory-app" :aria-label="phone.t('Apps.memory.name')">
<header class="memory-header">
<main
class="memory-app"
:class="{ 'memory-app--playing': game }"
:aria-label="phone.t('Apps.memory.name')"
>
<header v-if="!game" class="memory-header">
<div>
<span>{{ phone.t('Apps.memory.eyebrow') }}</span>
<h1>{{ phone.t('Apps.memory.name') }}</h1>
@@ -278,6 +282,10 @@ onBeforeUnmount(() => {
user-select: none;
}
.memory-app--playing {
padding: 0;
}
.memory-header {
height: 52px;
display: flex;
@@ -405,50 +413,67 @@ onBeforeUnmount(() => {
.memory-difficulty__best { align-self: start; color: #74698f; font-size: 11.5px; line-height: 1.2; }
.memory-difficulties svg { grid-column: 3; grid-row: 1 / 3; color: #7658c7; }
.memory-game { padding-top: 5px; }
.memory-stats {
height: 50px;
display: grid;
grid-template-columns: 30px auto auto 1fr;
align-items: center;
gap: 11px;
.memory-game {
position: absolute;
inset: 0;
}
.memory-stats div { display: grid; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; text-transform: uppercase; }
.memory-stats strong { font-size: 19px; line-height: 1.05; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 14px; font-weight: 800; }
.memory-stats {
position: absolute;
z-index: 7;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 1fr auto;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(105 79 160 / 10%);
border-radius: 21px;
background: rgb(246 241 255 / 64%);
box-shadow: 0 8px 24px rgb(75 52 121 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats .memory-menu-button {
width: 30px;
height: 30px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
border: 1px solid rgb(105 79 160 / 10%);
border-radius: 10px;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 48%);
box-shadow: 0 3px 8px rgb(75 52 121 / 8%);
cursor: pointer;
}
.memory-board {
position: relative;
position: absolute;
inset: 0;
display: grid;
gap: 8px;
padding: 9px;
border: 1px solid rgb(101 77 153 / 9%);
border-radius: 20px;
background: rgb(255 255 255 / 31%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 50%);
align-content: center;
padding: 108px 17px 28px;
border: 0;
border-radius: 0;
background:
radial-gradient(circle at 12% 88%, rgb(135 105 207 / 12%), transparent 31%),
radial-gradient(circle at 88% 16%, rgb(255 255 255 / 54%), transparent 28%);
perspective: 900px;
}
.memory-board--small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.memory-board--medium,
.memory-board--large { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.memory-board--large { gap: 6px; padding: 7px; }
.memory-board--large { gap: 6px; padding-right: 15px; padding-left: 15px; }
.memory-game-card {
aspect-ratio: 0.82;
@@ -545,7 +570,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 20px;
border-radius: 0;
background: rgb(244 239 255 / 90%);
backdrop-filter: blur(6px);
text-align: center;
+78 -19
View File
@@ -198,8 +198,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="minesweeper-app" :aria-label="phone.t('Apps.minesweeper.name')">
<header class="minesweeper-header">
<main
class="minesweeper-app"
:class="{ 'minesweeper-app--playing': !minesweeper.menuOpen && game }"
:aria-label="phone.t('Apps.minesweeper.name')"
>
<header v-if="minesweeper.menuOpen" class="minesweeper-header">
<div>
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
@@ -420,6 +424,10 @@ onBeforeUnmount(() => {
user-select: none;
}
.minesweeper-app--playing {
padding: 0;
}
.minesweeper-header {
height: 55px;
display: flex;
@@ -524,22 +532,59 @@ onBeforeUnmount(() => {
.minesweeper-difficulties small { align-self: start; color: #658a89; font-size: 8px; }
.minesweeper-long-press { margin: 0; color: #628887; font-size: 9px; }
.minesweeper-game { position: relative; padding-top: 5px; }
.minesweeper-toolbar { height: 46px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
.minesweeper-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
.minesweeper-toolbar span { color: #5a8484; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.minesweeper-toolbar strong { font-size: 16px; }
.minesweeper-game {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 88%, rgb(26 129 130 / 14%), transparent 32%),
radial-gradient(circle at 88% 16%, rgb(238 255 249 / 45%), transparent 29%);
}
.minesweeper-toolbar {
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(23 83 87 / 10%);
border-radius: 21px;
background: rgb(226 249 242 / 65%);
box-shadow: 0 8px 24px rgb(23 73 75 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.minesweeper-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.minesweeper-toolbar span { color: #5a8484; font-size: 10px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.minesweeper-toolbar strong { display: block; font-size: 18px; line-height: 20px; }
.minesweeper-toolbar .minesweeper-toolbar__icon {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.minesweeper-board {
position: relative;
position: absolute;
top: 50%;
right: 14px;
left: 14px;
display: grid;
grid-template-columns: repeat(var(--minesweeper-columns), minmax(0, 1fr));
gap: 3px;
padding: 7px;
border: 1px solid rgb(16 78 82 / 10%);
border: 0;
border-radius: 18px;
background: #187a7e;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 15px 27px rgb(20 79 81 / 18%);
transform: translateY(-45%);
}
.minesweeper-cell {
@@ -582,8 +627,7 @@ onBeforeUnmount(() => {
.minesweeper-game--exploding::after {
position: absolute;
z-index: 9;
inset: 46px 0 0;
border-radius: 18px;
inset: 0;
background: #ffb341;
content: "";
pointer-events: none;
@@ -767,13 +811,13 @@ onBeforeUnmount(() => {
}
@keyframes minesweeper-board-shake {
0%, 100% { transform: translate(0); }
12% { transform: translate(-7px, 3px) rotate(-1deg); }
24% { transform: translate(6px, -4px) rotate(1deg); }
38% { transform: translate(-5px, -2px); }
52% { transform: translate(4px, 3px); }
68% { transform: translate(-2px, -2px); }
82% { transform: translate(2px, 1px); }
0%, 100% { transform: translate(0, -45%); }
12% { transform: translate(-7px, calc(-45% + 3px)) rotate(-1deg); }
24% { transform: translate(6px, calc(-45% - 4px)) rotate(1deg); }
38% { transform: translate(-5px, calc(-45% - 2px)); }
52% { transform: translate(4px, calc(-45% + 3px)); }
68% { transform: translate(-2px, calc(-45% - 2px)); }
82% { transform: translate(2px, calc(-45% + 1px)); }
}
@keyframes minesweeper-bomb-drop {
@@ -866,7 +910,22 @@ onBeforeUnmount(() => {
.minesweeper-secondary { position: relative; z-index: 1; min-width: 155px; min-height: 40px; border-radius: 13px; font-size: 11px; font-weight: 850; pointer-events: auto; }
.minesweeper-primary { border: 0; color: #104d51; background: #8ce3d2; }
.minesweeper-secondary { border: 1px solid rgb(255 255 255 / 13%); color: #e6faf5; background: rgb(255 255 255 / 7%); }
.minesweeper-game__hint { margin: 9px 0 0; color: #668c8c; font-size: 9px; text-align: center; }
.minesweeper-game__hint {
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #668c8c;
background: rgb(226 249 242 / 52%);
backdrop-filter: blur(10px);
font-size: 9px;
text-align: center;
pointer-events: none;
}
button:active { transform: scale(0.96); }
+97 -117
View File
@@ -1,14 +1,9 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ChevronLeft,
ChevronsDown,
Pause,
Play,
RotateCcw,
RotateCw,
Trophy,
Volume2,
VolumeX,
@@ -201,8 +196,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="neon-drop-app" :aria-label="phone.t('Apps.neonDrop.name')">
<header class="neon-header">
<main
class="neon-drop-app"
:class="{ 'neon-drop-app--playing': !neon.menuOpen && game }"
:aria-label="phone.t('Apps.neonDrop.name')"
>
<header v-if="neon.menuOpen" class="neon-header">
<div>
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
@@ -335,51 +334,11 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.level') }}</span
><strong>{{ game.level }}</strong>
<div class="neon-level">
<i :style="{ height: `${levelProgress * 100}%` }"></i>
<i :style="{ width: `${levelProgress * 100}%` }"></i>
</div>
</aside>
</div>
<div
class="neon-controls"
:aria-label="phone.t('Apps.neonDrop.controls')"
>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.left')"
@click="move(-1)"
>
<ArrowLeft :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.rotate')"
@click="rotate"
>
<RotateCw :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.right')"
@click="move(1)"
>
<ArrowRight :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.softDrop')"
@click="softDrop"
>
<ArrowDown :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.hardDrop')"
@click="hardDrop"
>
<ChevronsDown :size="18" />
</button>
</div>
<p class="neon-hint">{{ phone.t('Apps.neonDrop.gameHint') }}</p>
<div v-if="game.status === 'paused'" class="neon-overlay">
@@ -421,6 +380,9 @@ onBeforeUnmount(() => {
user-select: none;
touch-action: manipulation;
}
.neon-drop-app--playing {
padding: 0;
}
.neon-header {
height: 54px;
display: flex;
@@ -613,42 +575,70 @@ onBeforeUnmount(() => {
background: #ffffff0a;
}
.neon-game {
position: relative;
height: calc(100% - 54px);
position: absolute;
inset: 0;
}
.neon-toolbar {
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 35px 1fr 1fr 35px;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 5px;
gap: 4px;
padding: 4px;
border: 1px solid #7cf6e72b;
border-radius: 21px;
background: #101a38a8;
box-shadow: 0 8px 24px #0008;
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.neon-toolbar span {
font-size: 11px;
line-height: 10px;
}
.neon-toolbar div {
text-align: center;
height: 32px;
display: grid;
grid-template-rows: 10px 20px;
align-content: center;
justify-items: center;
}
.neon-toolbar strong {
font-size: 21px;
display: block;
font-size: 19px;
line-height: 20px;
}
.neon-toolbar button {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.neon-play-area {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 5px;
position: absolute;
inset: 0;
}
.neon-board {
width: 218px;
height: 414px;
position: absolute;
top: 156px;
right: 0;
bottom: 76px;
left: 0;
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(18, 1fr);
gap: 1.5px;
padding: 5px;
border: 1px solid #65f4e43b;
border-radius: 13px;
background: #06091af2;
box-shadow:
inset 0 0 25px #000b,
0 12px 26px #0008;
grid-template-rows: repeat(17, 1fr);
gap: 1px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
touch-action: none;
}
.neon-cell {
@@ -700,31 +690,35 @@ onBeforeUnmount(() => {
animation: neon-clear 0.24s ease-out;
}
.neon-side {
width: 70px;
display: flex;
flex-direction: column;
position: absolute;
top: 110px;
right: 14px;
left: 14px;
height: 40px;
display: grid;
grid-template-columns: auto 40px auto auto 1fr;
align-items: center;
gap: 9px;
padding: 10px 4px 11px;
border: 1px solid #65f4e448;
border-radius: 13px;
background: linear-gradient(180deg, #1420478f, #090d24a3);
box-shadow: inset 0 0 16px #53e8d90a;
gap: 8px;
padding: 0 6px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.neon-side > strong {
font-size: 28px;
font-size: 21px;
}
.neon-preview {
width: 62px;
height: 62px;
width: 40px;
height: 40px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 2px;
padding: 5px;
border: 1px solid #ffffff35;
border-radius: 10px;
background: #ffffff08;
gap: 1px;
padding: 3px;
border: 0;
border-radius: 0;
background: transparent;
}
.neon-preview i[class*='neon-piece--'] {
border: 1px solid #ffffff68;
@@ -734,63 +728,49 @@ onBeforeUnmount(() => {
}
.neon-level {
position: relative;
width: 12px;
height: 221px;
width: 100%;
height: 6px;
overflow: hidden;
border-radius: 8px;
background: #ffffff0c;
}
.neon-level i {
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
border-radius: 8px;
background: linear-gradient(#73a7ff, #71f5e5);
box-shadow: 0 0 9px #69f4e5;
transition: height 0.25s;
}
.neon-controls {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 5px;
margin-top: 9px;
}
.neon-controls button {
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid #ffffff16;
border-radius: 11px;
color: #dffefd;
background: #ffffff0b;
}
.neon-controls button:last-child {
color: #071225;
background: linear-gradient(135deg, #ffe265, #ff9167);
}
.neon-controls svg {
transform: scale(1.2);
transition: width 0.25s;
}
.neon-hint {
margin: 7px 0 0;
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #d6deed;
font-size: 16px;
background: #101a3891;
backdrop-filter: blur(10px);
font-size: 10px;
font-weight: 700;
text-align: center;
pointer-events: none;
}
.neon-overlay {
position: absolute;
z-index: 10;
inset: 42px 0 19px;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 25px;
border-radius: 17px;
background: #080c21e8;
backdrop-filter: blur(6px);
text-align: center;
+68 -56
View File
@@ -1,9 +1,5 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronLeft,
RotateCcw,
Volume2,
@@ -30,16 +26,6 @@ const canResume = computed(
const currentHighest = computed(() =>
Math.max(0, ...(numberMerge.game?.tiles.map((tile) => tile.value) ?? [])),
)
const directionButtons: Array<{
direction: NumberMergeDirection
icon: typeof ArrowUp
}> = [
{ direction: 'left', icon: ArrowLeft },
{ direction: 'up', icon: ArrowUp },
{ direction: 'down', icon: ArrowDown },
{ direction: 'right', icon: ArrowRight },
]
function formatScore(value: number): string {
return new Intl.NumberFormat('en-US').format(value)
}
@@ -139,8 +125,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<main class="number-merge-app" :aria-label="phone.t('Apps.numberMerge.name')">
<header class="number-merge-header">
<main
class="number-merge-app"
:class="{ 'number-merge-app--playing': !numberMerge.menuOpen && game }"
:aria-label="phone.t('Apps.numberMerge.name')"
>
<header v-if="numberMerge.menuOpen" class="number-merge-header">
<div>
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
@@ -317,17 +307,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</div>
<p class="number-merge-hint">{{ phone.t('Apps.numberMerge.swipeHint') }}</p>
<div class="number-merge-controls" :aria-label="phone.t('Apps.numberMerge.controls')">
<button
v-for="control in directionButtons"
:key="control.direction"
type="button"
:aria-label="phone.t(`Apps.numberMerge.directions.${control.direction}`)"
@click="move(control.direction)"
>
<component :is="control.icon" :size="19" :stroke-width="2.5" aria-hidden="true" />
</button>
</div>
</section>
<div v-if="confirmNewGame" class="number-merge-confirm" role="dialog" aria-modal="true">
@@ -360,6 +339,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
user-select: none;
}
.number-merge-app--playing {
padding: 0;
}
.number-merge-header {
height: 55px;
display: flex;
@@ -508,40 +491,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
.number-merge-game { padding-top: 5px; }
.number-merge-game {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 88%, rgb(161 77 47 / 13%), transparent 32%),
radial-gradient(circle at 88% 16%, rgb(255 246 219 / 42%), transparent 29%);
}
.number-merge-toolbar {
height: 48px;
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 36px 1fr 1fr 36px;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 7px;
gap: 4px;
padding: 4px;
border: 1px solid rgb(109 66 52 / 10%);
border-radius: 21px;
background: rgb(255 241 216 / 65%);
box-shadow: 0 8px 24px rgb(95 48 31 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.number-merge-toolbar div {
min-width: 0;
height: 32px;
display: grid;
grid-template-rows: 10px 20px;
align-content: center;
justify-items: center;
line-height: 1.05;
}
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; }
.number-merge-toolbar span { line-height: 10px; }
.number-merge-toolbar strong { display: block; max-width: 80px; overflow: hidden; font-size: 18px; line-height: 20px; text-overflow: ellipsis; }
.number-merge-toolbar__restart { justify-self: end; }
.number-merge-toolbar .number-merge-toolbar__icon {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.number-merge-board {
--board-gap: 7px;
--board-padding: 9px;
position: relative;
width: 100%;
position: absolute;
top: 50%;
right: 14px;
left: 14px;
width: auto;
aspect-ratio: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--board-gap);
padding: var(--board-padding);
border: 1px solid rgb(78 41 32 / 9%);
border: 0;
border-radius: 21px;
background: #80564c;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 17px 28px rgb(101 52 38 / 18%);
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 14px 28px rgb(101 52 38 / 15%);
transform: translateY(-47%);
}
.number-merge-cell {
@@ -638,25 +654,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-weight: 800;
}
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 12px; text-align: center; }
.number-merge-controls {
display: grid;
grid-template-columns: repeat(4, 38px);
justify-content: center;
gap: 7px;
}
.number-merge-controls button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgb(97 56 44 / 10%);
border-radius: 12px;
color: #71483b;
background: rgb(255 255 255 / 38%);
.number-merge-hint {
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #8e675b;
background: rgb(255 241 216 / 52%);
backdrop-filter: blur(10px);
font-size: 10px;
text-align: center;
pointer-events: none;
}
.number-merge-confirm {
+245
View File
@@ -17,6 +17,8 @@ import {
kPreloader,
kRange,
kSearchbar,
kSegmented,
kSegmentedButton,
kToast,
kToggle,
} from 'konsta/vue'
@@ -45,6 +47,7 @@ import {
import { PHONE_FRAME_COLORS } from '@/config/appearance'
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import PhonePasscode from '@/components/PhonePasscode.vue'
import { useAccountStore } from '@/stores/account'
import type {
LaunchablePhoneAppDefinition,
@@ -74,6 +77,7 @@ import {
type SettingsView =
| 'root'
| 'account'
| 'security'
| 'notifications'
| 'notification-detail'
| 'sounds'
@@ -82,6 +86,14 @@ type SettingsView =
| 'wallpaper'
type RootToggleKey = 'airplaneMode' | 'streamerMode'
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
type PasscodeFlow =
| 'set-new'
| 'set-confirm'
| 'change-current'
| 'change-new'
| 'change-confirm'
| 'disable'
| null
const FACTORY_RESET_DURATION_MS = 60_000
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
@@ -110,6 +122,13 @@ const accountPassword = ref('')
const accountConfirm = ref('')
const accountSubmitting = ref(false)
const accountToast = ref('')
const passcodeBusy = ref(false)
const passcodeCurrent = ref('')
const passcodeError = ref('')
const passcodeFirst = ref('')
const passcodeFlow = ref<PasscodeFlow>(null)
const passcodeLength = ref<4 | 6>(6)
const passcodeResetKey = ref(0)
const removeDeviceImei = ref('')
const removeDevicePassword = ref('')
const removeDeviceOpened = ref(false)
@@ -152,6 +171,12 @@ const serviceRows = [
},
]
const preferenceRows = [
{
key: 'security',
view: 'security' as const,
icon: KeyRound,
iconColor: '#34c759',
},
{
key: 'general',
view: 'general' as const,
@@ -204,6 +229,18 @@ const activeTitle = computed(() => {
}
return phone.t(`Apps.settings.${activeView.value}`)
})
const passcodeTitle = computed(() => {
if (passcodeFlow.value === 'set-confirm') {
return phone.t('Apps.settings.passcode.confirmNew')
}
if (passcodeFlow.value === 'change-current' || passcodeFlow.value === 'disable') {
return phone.t('Apps.settings.passcode.enterCurrent')
}
if (passcodeFlow.value === 'change-confirm') {
return phone.t('Apps.settings.passcode.confirmNew')
}
return phone.t('Apps.settings.passcode.enterNew')
})
function matchesSearch(key: string): boolean {
return (
@@ -220,6 +257,9 @@ function updateSearch(event: Event): void {
}
function openView(view: SubmenuView): void {
if (view === 'security') {
passcodeLength.value = phone.security.length ?? 6
}
activeView.value = view
scrollPageToTop()
}
@@ -248,6 +288,115 @@ function toggleRootSetting(key: RootToggleKey): void {
phone.setPreference(key, !phone.preferences.settings[key])
}
function resetPasscodeInput(): void {
passcodeError.value = ''
passcodeResetKey.value += 1
}
function beginSetPasscode(): void {
passcodeLength.value = phone.security.length ?? passcodeLength.value
passcodeFirst.value = ''
passcodeCurrent.value = ''
passcodeFlow.value = 'set-new'
resetPasscodeInput()
}
function beginChangePasscode(): void {
passcodeFirst.value = ''
passcodeCurrent.value = ''
passcodeFlow.value = 'change-current'
resetPasscodeInput()
}
function beginDisablePasscode(): void {
passcodeLength.value = phone.security.length ?? 6
passcodeCurrent.value = ''
passcodeFlow.value = 'disable'
resetPasscodeInput()
}
function cancelPasscodeFlow(): void {
if (passcodeBusy.value) return
passcodeFlow.value = null
passcodeFirst.value = ''
passcodeCurrent.value = ''
resetPasscodeInput()
}
function passcodeRequestError(error?: string): string {
if (error === 'invalid_passcode') {
return phone.t('Apps.settings.passcode.incorrect')
}
if (error === 'passcode_locked') {
return phone.t('Apps.settings.passcode.locked')
}
if (error === 'rate_limited') {
return phone.t('Apps.settings.passcode.rateLimited')
}
return phone.t('Apps.settings.passcode.failed')
}
async function submitSettingsPasscode(passcode: string): Promise<void> {
if (passcodeBusy.value || !passcodeFlow.value) return
if (passcodeFlow.value === 'set-new') {
passcodeFirst.value = passcode
passcodeFlow.value = 'set-confirm'
resetPasscodeInput()
return
}
if (passcodeFlow.value === 'change-current') {
passcodeCurrent.value = passcode
passcodeFlow.value = 'change-new'
resetPasscodeInput()
return
}
if (passcodeFlow.value === 'change-new') {
passcodeFirst.value = passcode
passcodeFlow.value = 'change-confirm'
resetPasscodeInput()
return
}
if (
(passcodeFlow.value === 'set-confirm' ||
passcodeFlow.value === 'change-confirm') &&
passcode !== passcodeFirst.value
) {
passcodeError.value = phone.t('Apps.settings.passcode.mismatch')
passcodeResetKey.value += 1
return
}
passcodeBusy.value = true
const response =
passcodeFlow.value === 'set-confirm'
? await phone.setPasscode(passcode)
: passcodeFlow.value === 'change-confirm'
? await phone.changePasscode(passcodeCurrent.value, passcode)
: await phone.disablePasscode(passcode)
passcodeBusy.value = false
if (!response.success) {
passcodeError.value = passcodeRequestError(response.error)
if (
passcodeFlow.value === 'change-confirm' &&
response.error === 'invalid_passcode'
) {
passcodeFlow.value = 'change-current'
passcodeCurrent.value = ''
passcodeFirst.value = ''
}
passcodeResetKey.value += 1
return
}
accountToast.value = phone.t(
passcodeFlow.value === 'disable'
? 'Apps.settings.passcode.disabled'
: 'Apps.settings.passcode.saved',
)
cancelPasscodeFlow()
}
function updateNumberPreference(
key:
| 'notificationDurationSeconds'
@@ -551,6 +700,15 @@ onBeforeUnmount(() => {
<component :is="row.icon" :size="17" :stroke-width="2.25" />
</span>
</template>
<template v-if="row.key === 'security'" #after>
{{
phone.t(
phone.security.enabled
? 'Apps.settings.on'
: 'Apps.settings.off',
)
}}
</template>
</k-list-item>
</k-list>
</template>
@@ -715,6 +873,81 @@ onBeforeUnmount(() => {
</template>
</template>
<template v-else-if="activeView === 'security'">
<k-block class="text-sm leading-5 opacity-70">
{{ phone.t('Apps.settings.passcode.description') }}
</k-block>
<template v-if="!phone.security.enabled">
<k-block-title>{{ phone.t('Apps.settings.passcode.codeLength') }}</k-block-title>
<k-block>
<k-segmented strong rounded>
<k-segmented-button
:active="passcodeLength === 6"
@click="passcodeLength = 6"
>
{{ phone.t('Apps.settings.passcode.sixDigit') }}
</k-segmented-button>
<k-segmented-button
:active="passcodeLength === 4"
@click="passcodeLength = 4"
>
{{ phone.t('Apps.settings.passcode.fourDigit') }}
</k-segmented-button>
</k-segmented>
</k-block>
<k-list strong inset>
<k-list-button @click="beginSetPasscode">
{{ phone.t('Apps.settings.passcode.turnOn') }}
</k-list-button>
</k-list>
</template>
<template v-else>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.passcode.status')"
:after="phone.t('Apps.settings.on')"
/>
<k-list-item
:title="phone.t('Apps.settings.passcode.codeLength')"
:after="
phone.t(
phone.security.length === 4
? 'Apps.settings.passcode.fourDigit'
: 'Apps.settings.passcode.sixDigit',
)
"
/>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.passcode.codeLength') }}</k-block-title>
<k-block>
<k-segmented strong rounded>
<k-segmented-button
:active="passcodeLength === 6"
@click="passcodeLength = 6"
>
{{ phone.t('Apps.settings.passcode.sixDigit') }}
</k-segmented-button>
<k-segmented-button
:active="passcodeLength === 4"
@click="passcodeLength = 4"
>
{{ phone.t('Apps.settings.passcode.fourDigit') }}
</k-segmented-button>
</k-segmented>
</k-block>
<k-list strong inset>
<k-list-button @click="beginChangePasscode">
{{ phone.t('Apps.settings.passcode.change') }}
</k-list-button>
<k-list-button class="!text-red-500" @click="beginDisablePasscode">
{{ phone.t('Apps.settings.passcode.turnOff') }}
</k-list-button>
</k-list>
</template>
</template>
<template v-else-if="activeView === 'notifications'">
<k-list strong inset>
<k-list-item
@@ -1102,6 +1335,18 @@ onBeforeUnmount(() => {
</template>
</k-page>
<PhonePasscode
v-if="passcodeFlow"
:busy="passcodeBusy"
:error="passcodeError"
:length="passcodeLength"
:reset-key="passcodeResetKey"
:subtitle="phone.t('Apps.settings.passcode.screenSubtitle')"
:title="passcodeTitle"
@cancel="cancelPasscodeFlow"
@complete="submitSettingsPasscode"
/>
<div
v-if="factoryResetting"
class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black px-8 text-center text-white"
+18 -6
View File
@@ -106,8 +106,15 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="flappy-app" :class="`flappy-app--${flappy.design}`" :aria-label="phone.t('Apps.skyFlappy.name')">
<header class="flappy-header">
<main
class="flappy-app"
:class="[
`flappy-app--${flappy.design}`,
{ 'flappy-app--playing': !flappy.menuOpen && game },
]"
:aria-label="phone.t('Apps.skyFlappy.name')"
>
<header v-if="flappy.menuOpen" class="flappy-header">
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
@@ -160,10 +167,11 @@ onBeforeUnmount(() => {
<style scoped>
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
.flappy-game{position:relative;height:calc(100% - 55px)}.flappy-toolbar{height:47px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-stage{position:relative;width:100%;height:500px;display:block;overflow:hidden;padding:0;border:1px solid #ffffff1c;border-radius:22px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55,0 16px 30px #10193477;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{margin-top:8px;text-align:center}.flappy-overlay{position:absolute;z-index:12;inset:47px 0 25px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;border-radius:22px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
.flappy-menu__tower::after{border-color:var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff45,0 0 14px var(--tower-glow)}
.flappy-menu__copy>span{color:#ffdf7d;font-size:14px}
@@ -178,9 +186,13 @@ onBeforeUnmount(() => {
.flappy-primary,.flappy-secondary{min-height:50px;gap:8px;font-size:18px}
.flappy-secondary{border-color:#ffffff2c;background:#ffffff12}
.flappy-menu>p,.flappy-game__hint{color:#e1e9f6;font-size:16px;font-weight:700;line-height:1.35}
.flappy-toolbar span{color:#e0eafa;font-size:12px;letter-spacing:.35px}
.flappy-toolbar strong{font-size:21px}
.flappy-stage{border:2px solid #ffffff70;box-shadow:inset 0 0 35px #15244c38,0 16px 30px #10193477}
.flappy-toolbar span{color:#e0eafa;font-size:11px;line-height:10px;letter-spacing:.35px}
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
.flappy-toolbar button{width:32px;height:32px}
.flappy-clouds{top:110px}
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
.flappy-obstacle span::after{right:-5px;left:-5px;height:17px;border:2px solid var(--tower-dark);background:linear-gradient(180deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff55,0 0 14px var(--tower-glow)}
.flappy-ready{padding:11px 18px;border:2px solid #ffffff55;border-radius:19px;background:#15284fe6;font-size:16px;font-weight:850}
+91 -106
View File
@@ -1,13 +1,10 @@
<script setup lang="ts">
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Pause,
Play,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import {
SNAKE_BOARD_HEIGHT,
@@ -23,17 +20,7 @@ import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const snake = useSnakeStore()
const touchStart = ref<SnakePoint | null>(null)
const speedOptions: SnakeSpeed[] = ['relaxed', 'normal', 'fast']
const directionButtons: Array<{
direction: SnakeDirection
icon: typeof ChevronUp
}> = [
{ direction: 'up', icon: ChevronUp },
{ direction: 'left', icon: ChevronLeft },
{ direction: 'down', icon: ChevronDown },
{ direction: 'right', icon: ChevronRight },
]
const game = computed(() => snake.game)
const boardMotionStyle = computed(() => ({
'--snake-motion-duration': `${Math.min(
@@ -90,7 +77,9 @@ function stopGameTimer(): void {
function syncGameTimer(): void {
stopGameTimer()
if (snake.game?.status === 'playing') {
gameTimer = setInterval(() => snake.tick(), snake.tickMilliseconds)
gameTimer = setInterval(() => {
snake.tick()
}, snake.tickMilliseconds)
}
}
@@ -99,16 +88,16 @@ function returnToMenu(): void {
snake.showMenu()
}
function startGame(): void {
snake.start()
}
function handleKeydown(event: KeyboardEvent): void {
const directionByKey: Partial<Record<string, SnakeDirection>> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
a: 'left',
d: 'right',
s: 'down',
w: 'up',
}
const direction = directionByKey[event.key]
@@ -125,28 +114,6 @@ function handleKeydown(event: KeyboardEvent): void {
}
}
function beginSwipe(event: TouchEvent): void {
const touch = event.changedTouches[0]
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
}
function endSwipe(event: TouchEvent): void {
const start = touchStart.value
const touch = event.changedTouches[0]
touchStart.value = null
if (!start || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return
if (Math.abs(deltaX) > Math.abs(deltaY)) {
snake.turn(deltaX > 0 ? 'right' : 'left')
} else {
snake.turn(deltaY > 0 ? 'down' : 'up')
}
}
snake.hydrate()
watch(
() => [snake.game?.status, snake.tickMilliseconds],
@@ -162,8 +129,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="snake-app" :aria-label="phone.t('Apps.snake.name')">
<header class="snake-header">
<main
class="snake-app"
:class="{ 'snake-app--playing': game }"
:aria-label="phone.t('Apps.snake.name')"
>
<header v-if="!game" class="snake-header">
<span class="snake-brand">{{ phone.t('Apps.snake.name') }}</span>
<div class="snake-score-card">
<span>{{ phone.t('Apps.snake.highScore') }}</span>
@@ -226,7 +197,7 @@ onBeforeUnmount(() => {
{{ phone.t(`Apps.snake.speeds.${speed}`) }}
</button>
</fieldset>
<button type="button" class="snake-primary" @click="snake.start">
<button type="button" class="snake-primary" @click="startGame">
<Play :size="18" fill="currentColor" />
{{ phone.t('Apps.snake.start') }}
</button>
@@ -243,8 +214,10 @@ onBeforeUnmount(() => {
>
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
</button>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
<div>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
</div>
<button
v-if="game.status !== 'game-over'"
type="button"
@@ -267,8 +240,6 @@ onBeforeUnmount(() => {
class="snake-board"
:style="boardMotionStyle"
:aria-label="phone.t('Apps.snake.board')"
@touchstart.passive="beginSwipe"
@touchend.passive="endSwipe"
>
<span
v-for="(_, index) in game.body.slice(1)"
@@ -306,7 +277,7 @@ onBeforeUnmount(() => {
<template v-else>
<span class="snake-overline">{{ phone.t('Apps.snake.score') }} {{ game.score }}</span>
<h2>{{ phone.t('Apps.snake.gameOver') }}</h2>
<button type="button" class="snake-primary" @click="snake.start">
<button type="button" class="snake-primary" @click="startGame">
{{ phone.t('Apps.snake.restart') }}
</button>
<button type="button" class="snake-secondary" @click="returnToMenu">
@@ -317,18 +288,6 @@ onBeforeUnmount(() => {
</div>
<p class="snake-hint">{{ phone.t('Apps.snake.swipeHint') }}</p>
<div class="snake-controls" :aria-label="phone.t('Apps.snake.controls')">
<button
v-for="control in directionButtons"
:key="control.direction"
type="button"
:class="`snake-control--${control.direction}`"
:aria-label="phone.t(`Apps.snake.directions.${control.direction}`)"
@click="snake.turn(control.direction)"
>
<component :is="control.icon" :size="24" :stroke-width="2.6" />
</button>
</div>
</section>
</main>
</template>
@@ -348,6 +307,10 @@ onBeforeUnmount(() => {
touch-action: none;
}
.snake-app--playing {
padding: 0;
}
.snake-header,
.snake-game__meta {
display: flex;
@@ -507,53 +470,89 @@ onBeforeUnmount(() => {
}
.snake-game {
padding-top: 8px;
position: absolute;
inset: 0;
}
.snake-game__meta {
height: 43px;
justify-content: flex-start;
gap: 8px;
position: absolute;
z-index: 7;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 32px;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 21px;
background: rgb(8 22 18 / 58%);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.snake-game__meta div {
height: 32px;
display: grid;
grid-template-rows: 10px 20px;
align-content: center;
justify-items: center;
}
.snake-game__meta span {
color: #9fb3a9;
font-size: 11px;
font-weight: 700;
line-height: 10px;
text-transform: uppercase;
}
.snake-game__meta strong {
font-size: 22px;
display: block;
font-size: 19px;
line-height: 20px;
}
.snake-game__meta button {
width: 34px;
height: 34px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border: 0;
border-radius: 50%;
color: #dff6d9;
background: rgb(255 255 255 / 7%);
background: rgb(255 255 255 / 8%);
}
.snake-game__meta .snake-game__pause { margin-left: auto; }
.snake-game__meta .snake-game__back { flex: 0 0 auto; }
.snake-game__meta .snake-game__pause { justify-self: end; }
.snake-game__meta .snake-game__back { justify-self: start; }
.snake-board {
position: relative;
width: 100%;
aspect-ratio: 16 / 18;
position: absolute;
top: 118px;
right: 18px;
bottom: 62px;
left: 18px;
overflow: hidden;
border: 1px solid rgb(167 231 157 / 16%);
border: 1px solid rgb(145 228 117 / 24%);
border-radius: 18px;
background-color: #162d26;
background-image:
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
background-size: calc(100% / 16) calc(100% / 18);
box-shadow: inset 0 0 35px rgb(0 0 0 / 20%), 0 17px 35px rgb(0 0 0 / 20%);
linear-gradient(rgb(173 233 160 / 4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(173 233 160 / 4%) 1px, transparent 1px),
radial-gradient(circle at 50% 42%, rgb(94 191 91 / 8%), transparent 68%);
background-size:
calc(100% / 16) calc(100% / 30),
calc(100% / 16) calc(100% / 30),
100% 100%;
box-shadow:
inset 0 0 55px rgb(0 0 0 / 24%),
inset 0 0 0 1px rgb(213 255 199 / 3%),
0 12px 30px rgb(0 0 0 / 20%),
0 0 18px rgb(89 196 82 / 7%);
}
.snake-head,
@@ -679,36 +678,22 @@ onBeforeUnmount(() => {
}
.snake-hint {
margin: 9px 0 6px;
color: #71867c;
position: absolute;
z-index: 6;
right: 50px;
bottom: 27px;
left: 50px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #9eb2a8;
background: rgb(8 22 18 / 48%);
backdrop-filter: blur(10px);
font-size: 10px;
text-align: center;
pointer-events: none;
}
.snake-controls {
position: relative;
width: 132px;
height: 91px;
margin: 0 auto;
}
.snake-controls button {
position: absolute;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 13px;
color: #cae6d1;
background: rgb(255 255 255 / 7%);
}
.snake-control--up { left: 45px; top: 0; }
.snake-control--left { left: 0; top: 45px; }
.snake-control--down { left: 45px; top: 45px; }
.snake-control--right { right: 0; top: 45px; }
button:active {
transform: scale(0.96);
}
+41 -29
View File
@@ -27,7 +27,7 @@ import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const tower = useTowerStackStore()
const game = computed(() => tower.game)
const visibleBlocks = computed(() => game.value?.blocks.slice(-9) ?? [])
const visibleBlocks = computed(() => game.value?.blocks.slice(-6) ?? [])
const placementEffect = ref<'missed' | 'perfect' | 'placed' | null>(null)
const fallingBlock = ref<TowerActiveBlock | null>(null)
const fallingStyle = ref<CSSProperties>({})
@@ -175,8 +175,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="tower-app" :aria-label="phone.t('Apps.towerStack.name')">
<header class="tower-header">
<main
class="tower-app"
:class="{ 'tower-app--playing': !tower.menuOpen && game }"
:aria-label="phone.t('Apps.towerStack.name')"
>
<header v-if="tower.menuOpen" class="tower-header">
<div>
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
@@ -214,7 +218,13 @@ onBeforeUnmount(() => {
<Play :size="17" fill="currentColor" />
{{ phone.t('Apps.towerStack.resume') }}
</button>
<button type="button" class="tower-secondary" @click="startGame">
<button
type="button"
:class="game?.status === 'paused' ? 'tower-secondary' : 'tower-primary'"
@click="startGame"
>
<RotateCcw v-if="game" :size="16" aria-hidden="true" />
<Play v-else :size="16" fill="currentColor" aria-hidden="true" />
{{ phone.t(game ? 'Apps.towerStack.newGame' : 'Apps.towerStack.start') }}
</button>
<p class="tower-menu__hint">{{ phone.t('Apps.towerStack.tapHint') }}</p>
@@ -305,32 +315,34 @@ onBeforeUnmount(() => {
<style scoped>
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
.tower-header { height: 55px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 14px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 1px 0 0; font-size: 32px; line-height: 1; letter-spacing: -0.8px; }
.tower-app--playing { padding: 0; }
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-menu { height: calc(100% - 55px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; }
.tower-menu__preview { position: relative; width: 190px; height: 210px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc(var(--preview-index) * 25px); left: 25px; height: 29px; border-radius: 7px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 4px 0 #ffffff35, 0 8px 15px #08091c5c; transform: perspective(200px) rotateX(5deg); }
.tower-menu__preview i:nth-child(even) { right: 25px; left: 12px; }
.tower-menu__preview span { position: absolute; top: 4px; left: 2px; width: 120px; height: 29px; border-radius: 7px; background: #ff6b68; box-shadow: 0 0 24px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
.tower-menu__copy > span { color: #ffcf59; font-size: 14px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.tower-menu__copy h2 { margin: 4px 0 7px; font-size: 30px; }
.tower-menu__copy p { max-width: 295px; margin: 0; color: #e0dcf2; font-size: 18px; font-weight: 550; line-height: 1.4; }
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
.tower-menu__preview i:nth-child(even) { right: 23px; left: 12px; }
.tower-menu__preview span { position: absolute; top: 0; left: 4px; width: 100px; height: 21px; border-radius: 6px; background: #ff6b68; box-shadow: 0 0 20px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
.tower-menu__copy > span { color: #ffcf59; font-size: 10px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.tower-menu__copy h2 { max-width: 270px; margin: 3px auto 5px; font-size: 24px; line-height: 1.08; }
.tower-menu__copy p { max-width: 285px; margin: 0; color: #d8d3eb; font-size: 12px; font-weight: 550; line-height: 1.35; }
.tower-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tower-records div { display: grid; gap: 3px; padding: 12px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; }
.tower-records span { color: #d1caea; font-size: 15px; font-weight: 850; text-transform: uppercase; }
.tower-records strong { font-size: 26px; }
.tower-primary, .tower-secondary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 14px; font-size: 18px; font-weight: 850; }
.tower-records div { display: grid; gap: 1px; padding: 8px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; backdrop-filter: blur(8px); }
.tower-records span { color: #d1caea; font-size: 9px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
.tower-records strong { font-size: 20px; }
.tower-primary, .tower-secondary { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 14px; font-size: 13px; font-weight: 850; }
.tower-primary { border: 0; color: #1e1839; background: linear-gradient(135deg, #ffca4f, #ff8760); box-shadow: 0 8px 18px #ff895330; }
.tower-secondary { border: 1px solid #ffffff16; color: #eeeaff; background: #ffffff0a; }
.tower-menu__hint, .tower-game__hint { margin: 0; color: #e0daf0; font-size: 16px; font-weight: 700; line-height: 1.35; }
.tower-game { position: relative; height: calc(100% - 55px); }
.tower-toolbar { height: 47px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
.tower-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
.tower-toolbar span { color: #c9c2e9; font-size: 12px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
.tower-toolbar strong { font-size: 21px; }
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff35; border-radius: 22px; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80, 0 16px 28px #08091c66; touch-action: manipulation; }
.tower-menu__hint, .tower-game__hint { margin: 0; color: #c9c2e0; font-size: 10px; font-weight: 700; line-height: 1.35; }
.tower-game { position: absolute; inset: 0; }
.tower-toolbar { position: absolute; z-index: 10; top: 66px; right: 18px; left: 18px; height: 42px; display: grid; grid-template-columns: 32px 1fr 1fr 32px; align-items: center; gap: 4px; padding: 4px; border: 1px solid #ffffff1a; border-radius: 21px; background: #292451a8; box-shadow: 0 8px 24px #08091c52; backdrop-filter: blur(14px); box-sizing: border-box; }
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
.tower-sky i:nth-child(1) { top: 8%; left: 12%; } .tower-sky i:nth-child(2) { top: 17%; left: 72%; } .tower-sky i:nth-child(3) { top: 28%; left: 42%; } .tower-sky i:nth-child(4) { top: 37%; left: 88%; } .tower-sky i:nth-child(5) { top: 46%; left: 18%; } .tower-sky i:nth-child(6) { top: 58%; left: 64%; } .tower-sky i:nth-child(7) { top: 70%; left: 31%; } .tower-sky i:nth-child(8) { top: 11%; left: 91%; } .tower-sky i:nth-child(9) { top: 22%; left: 25%; } .tower-sky i:nth-child(10) { top: 50%; left: 78%; } .tower-sky i:nth-child(11) { top: 64%; left: 8%; } .tower-sky i:nth-child(12) { top: 77%; left: 92%; } .tower-sky i:nth-child(13) { top: 33%; left: 58%; }
@@ -343,13 +355,13 @@ onBeforeUnmount(() => {
.tower-perfect { position: absolute; z-index: 8; top: 24%; left: 50%; padding: 8px 17px; border-radius: 18px; color: #332149; background: #ffdc65; box-shadow: 0 0 24px #ffcf64aa; font-size: 13px; font-weight: 950; letter-spacing: .8px; transform: translateX(-50%); animation: tower-perfect-pop 650ms ease-out forwards; }
.tower-stage--perfect { animation: tower-perfect-glow 500ms ease-out; }
.tower-stage--missed { animation: tower-stage-shake 500ms ease-out; }
.tower-game__hint { margin-top: 8px; text-align: center; }
.tower-overlay { position: absolute; z-index: 15; inset: 47px 0 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border-radius: 22px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
.tower-game__hint { position: absolute; z-index: 6; right: 45px; bottom: 27px; left: 45px; margin: 0; padding: 7px 10px; border-radius: 999px; color: #e0daf0; background: #29245191; backdrop-filter: blur(10px); text-align: center; pointer-events: none; }
.tower-overlay { position: absolute; z-index: 15; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
.tower-overlay > svg { color: #ffbd4d; }
.tower-overlay > span { color: #ffad78; font-size: 12px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
.tower-overlay h2 { margin: 0; font-size: 25px; }
.tower-overlay p { margin: -3px 0 5px; color: #c7c1e4; font-size: 14px; }
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(66px) rotate(2deg); } }
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(48px) rotate(2deg); } }
@keyframes tower-land { from { filter: brightness(1.8); transform: translateY(-8px) scaleY(.85); } to { filter: brightness(1); transform: translateY(0) scaleY(1); } }
@keyframes tower-active-pulse { from { filter: saturate(1.2) brightness(1.08); } to { filter: saturate(1.45) brightness(1.35); } }
@keyframes tower-fall { 0% { opacity: 1; transform: translate(0) rotate(0); } 100% { opacity: 0; transform: translate(45px, 390px) rotate(145deg); } }
+47
View File
@@ -738,6 +738,8 @@ const deviceData = {
revision: 1,
},
}
let mockPasscode = ''
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
let mockContacts = [
{
created_at: isoTime(-14 * 86_400_000),
@@ -1517,6 +1519,7 @@ app.post('/api/:endpoint', (request, response) => {
},
},
notes: mockNotes,
security: mockSecurity,
token: 'development',
},
})
@@ -1749,12 +1752,56 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: { revision } })
return
}
if (endpoint === 'security:unlock') {
response.json(
!mockSecurity.enabled || request.body.passcode === mockPasscode
? { success: true, data: { security: mockSecurity } }
: { success: false, error: 'invalid_passcode' },
)
return
}
if (endpoint === 'security:set-passcode') {
mockPasscode = String(request.body.passcode)
mockSecurity = {
enabled: true,
length: mockPasscode.length,
lockedUntil: 0,
}
response.json({ success: true, data: { security: mockSecurity } })
return
}
if (endpoint === 'security:change-passcode') {
if (request.body.currentPasscode !== mockPasscode) {
response.json({ success: false, error: 'invalid_passcode' })
return
}
mockPasscode = String(request.body.newPasscode)
mockSecurity = {
enabled: true,
length: mockPasscode.length,
lockedUntil: 0,
}
response.json({ success: true, data: { security: mockSecurity } })
return
}
if (endpoint === 'security:disable-passcode') {
if (request.body.passcode !== mockPasscode) {
response.json({ success: false, error: 'invalid_passcode' })
return
}
mockPasscode = ''
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
response.json({ success: true, data: { security: mockSecurity } })
return
}
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockNotes = []
mockMedia = []
calendarEvents = []
mockPasscode = ''
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
for (const key of Object.keys(deviceData)) delete deviceData[key]
response.json({ success: true })
return
+42 -1
View File
@@ -15,10 +15,17 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
DevelopmentCommand = true,
DevelopmentCommand = false,
DeviceName = "iFruit Phone",
}
Config.Security = {
PasscodePepperConvar = "sky_phone_passcode_pepper",
MaximumAttempts = 5,
LockSeconds = 30,
AttemptsPerMinute = 12,
}
Config.Sim = {
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
@@ -84,6 +91,40 @@ Config.Radio = {
},
}
Config.Animations = {
Enabled = true,
PropModel = "prop_npc_phone_02",
PropBone = 28422,
LoadTimeoutMs = 5000,
ContextPollMs = 250,
Dictionaries = {
OnFoot = "cellphone@",
Driver = "anim@cellphone@in_car@ds",
Passenger = "anim@cellphone@in_car@ps",
Selfie = "anim@mp_player_intuppertake_selfie",
},
Clips = {
TextIn = "cellphone_text_in",
TextRead = "cellphone_text_read_base",
TextOut = "cellphone_text_out",
TextToCall = "cellphone_text_to_call",
CallListen = "cellphone_call_listen_base",
CallToText = "cellphone_call_to_text",
CallOut = "cellphone_call_out",
Selfie = "idle_a",
},
Transforms = {
Portrait = {
position = { x = 0.0, y = 0.0, z = 0.0 },
rotation = { x = 0.0, y = 0.0, z = 0.0 },
},
Landscape = {
position = { x = 0.0, y = 0.0, z = 0.0 },
rotation = { x = 0.0, y = 0.0, z = 90.0 },
},
},
}
Config.Messages = {
BodyMaxLength = 2000,
ConversationScanLimit = 1000,
+32 -5
View File
@@ -29,10 +29,15 @@ Locales["en"] = {
Notifications = { now = "now" },
LockScreen = {
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
passcode = {
enter = "Enter Passcode", unlockSubtitle = "Enter the passcode for this phone.", cancel = "Cancel", delete = "Delete digit",
incorrect = "Incorrect passcode", locked = "Too many attempts. Try again in {seconds} seconds.",
tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.",
},
},
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", page = "Page", pages = "Home screen pages",
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", addToHome = "Add {app} to Home Screen", addPage = "Add Home Screen page", deletePage = "Delete current Home Screen page", removedFromHome = "Removed from Home Screen", page = "Page", pages = "Home screen pages",
groups = {
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
@@ -44,6 +49,19 @@ Locales["en"] = {
battery = { label = "Phone" },
media = { title = "Night Drive", artist = "Sky Radio", play = "Play", pause = "Pause" },
},
widgetSystem = {
galleryTitle = "Widgets", search = "Search Widgets", size = "Size", add = "Add Widget", addWidget = "Add Widget",
editWidget = "Edit Widget", removeWidget = "Remove Widget", remove = "Remove widget", configure = "Configure Widget", noResults = "No widgets found",
sizes = { small = "Small", medium = "Medium", large = "Large" },
categories = { essentials = "Essentials", information = "Information", media = "Media", finance = "Finance", people = "People" },
clock = { name = "Clock", description = "The current time, with an optional date.", showDate = "Show Date" },
date = { name = "Date", description = "Weekday, month, and date at a glance." },
weather = { name = "Weather", description = "Current conditions, location, and high and low." },
music = { name = "Now Playing", description = "Music controls and the current track." },
wallet = { name = "Wallet", description = "Your current bank or cash balance.", balance = "Displayed Balance", bank = "Bank", cash = "Cash" },
transactions = { name = "Transactions", description = "Your latest incoming and outgoing payments." },
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
},
},
Apps = {
darkchat = {
@@ -240,7 +258,7 @@ Locales["en"] = {
readyBody = "Collect fruit, grow longer, and stay clear of every wall.", readyTitle = "Ready to play?",
restart = "Play Again", resume = "Resume game", score = "Score", speed = "Speed",
speeds = { fast = "Fast", normal = "Normal", relaxed = "Relaxed" }, start = "Start Game",
swipeHint = "Swipe, tap the controls, or use arrow keys / WASD",
swipeHint = "Use the arrow keys to steer",
},
memory = {
name = "Memory", backToMenu = "Back to board selection", board = "Memory card board",
@@ -266,7 +284,7 @@ Locales["en"] = {
keepPlaying = "Keep Playing", mainMenu = "Main Menu",
menuBody = "Slide matching numbers together and work your way up to 2048.", menuTitle = "Build the 2048 tile",
mute = "Mute game sounds", newGame = "New Game", score = "Score",
swipeHint = "Swipe the board, use the buttons, or press arrow keys / WASD",
swipeHint = "Swipe the board or use arrow keys / WASD",
unmute = "Turn on game sounds",
wonBody = "You reached the legendary tile. Continue climbing or begin again.", wonTitle = "You made 2048!",
},
@@ -435,7 +453,7 @@ Locales["en"] = {
reportReasons = { prohibited = "Prohibited item", fraud = "Suspected fraud", spam = "Spam", offensive = "Offensive content", other = "Other" },
searchPlaceholder = "What are you looking for?", allCategories = "All categories", allDistricts = "All districts",
sortNewest = "Newest first", sortPriceAsc = "Lowest price", sortPriceDesc = "Highest price",
freshOffers = "Fresh offers", offers = "offers", noListings = "No offers found",
freshOffers = "Fresh offers", offers = "offers", compactView = "Compact view", largeView = "Large view", noListings = "No offers found",
noListingsBody = "Try another search or category.", noDistrict = "No district",
free = "Free", negotiablePrice = "${price} negotiable", money = "${price}",
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
@@ -559,7 +577,7 @@ Locales["en"] = {
accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active",
accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases",
accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics",
general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications",
general = "General Settings", security = "Passcode & Security", appearance = "Appearance", allowNotifications = "Allow Notifications",
notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds",
ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
@@ -573,6 +591,15 @@ Locales["en"] = {
removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out",
factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.",
factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.",
passcode = {
description = "A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.",
status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code",
turnOn = "Turn Passcode On", turnOff = "Turn Passcode Off", change = "Change Passcode",
enterNew = "Enter New Passcode", confirmNew = "Verify New Passcode", enterCurrent = "Enter Current Passcode",
screenSubtitle = "Use 4 or 6 numbers.", incorrect = "Incorrect passcode.", mismatch = "The passcodes did not match.",
locked = "Too many incorrect attempts. Try again later.", rateLimited = "Too many attempts. Please wait.",
failed = "The passcode could not be updated.", saved = "Passcode saved.", disabled = "Passcode turned off.",
},
accountErrors = {
invalid_email = "Choose a valid 332 character iFruit address.", invalid_password = "Password must be 664 characters.",
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.",
+1
View File
@@ -24,6 +24,7 @@ client_scripts {
'config/locales/*.lua',
'source/bridge/client/framework.lua',
'source/bridge/client/callbacks.lua',
'source/client/animations.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/bridge/client/radio.lua',
+446
View File
@@ -0,0 +1,446 @@
local MODE_HIDDEN = "hidden"
local MODE_PHONE_READ = "phone_read"
local MODE_CALL = "call"
local MODE_CAMERA_REAR = "camera_rear"
local MODE_CAMERA_SELFIE = "camera_selfie"
local LOOPED_UPPER_BODY_FLAGS = 49
local TRANSITION_UPPER_BODY_FLAGS = 48
local animation_state = {
call_direction = nil,
call_state = nil,
camera_active = false,
camera_front = false,
camera_landscape = false,
current_animation = nil,
current_mode = MODE_HIDDEN,
ped = nil,
phone_open = false,
prop = nil,
revision = 0,
watcher_active = false,
}
local reevaluate
local function load_model(model_hash)
RequestModel(model_hash)
local deadline = GetGameTimer() + Config.Animations.LoadTimeoutMs
while not HasModelLoaded(model_hash) do
if GetGameTimer() >= deadline then
Bridge.Debug(
"error",
"[sky_phone] Timed out loading phone prop model '%s'.",
Config.Animations.PropModel
)
return false
end
Wait(0)
end
return true
end
local function load_animation_dictionary(dictionary)
RequestAnimDict(dictionary)
local deadline = GetGameTimer() + Config.Animations.LoadTimeoutMs
while not HasAnimDictLoaded(dictionary) do
if GetGameTimer() >= deadline then
Bridge.Debug(
"error",
"[sky_phone] Timed out loading animation dictionary '%s'.",
dictionary
)
return false
end
Wait(0)
end
return true
end
local function can_animate(ped)
if not DoesEntityExist(ped) or IsEntityDead(ped) then
return false
end
if IsPedRagdoll(ped) or IsPedFalling(ped) or IsPedClimbing(ped) then
return false
end
if IsPedSwimming(ped) or IsPedSwimmingUnderWater(ped) or IsPedInParachuteFreeFall(ped) then
return false
end
return true
end
local function get_phone_dictionary(ped)
if not IsPedInAnyVehicle(ped, false) then
return Config.Animations.Dictionaries.OnFoot, "on_foot"
end
local vehicle = GetVehiclePedIsIn(ped, false)
if GetPedInVehicleSeat(vehicle, -1) == ped then
return Config.Animations.Dictionaries.Driver, "driver:" .. tostring(vehicle)
end
return Config.Animations.Dictionaries.Passenger, "passenger:" .. tostring(vehicle)
end
local function derive_mode()
if not Config.Animations.Enabled then
return MODE_HIDDEN
end
if animation_state.call_state == "connected" then
return MODE_CALL
end
if animation_state.call_state == "ringing" and animation_state.call_direction == "outgoing" then
return MODE_CALL
end
if animation_state.camera_active and animation_state.camera_front then
return MODE_CAMERA_SELFIE
end
if animation_state.camera_active then
return MODE_CAMERA_REAR
end
if animation_state.call_state == "ringing" or animation_state.phone_open then
return MODE_PHONE_READ
end
return MODE_HIDDEN
end
local function stop_current_animation()
local current = animation_state.current_animation
if not current then
return
end
local ped = animation_state.ped
if ped and DoesEntityExist(ped) then
StopAnimTask(ped, current.dictionary, current.clip, 3.0)
end
animation_state.current_animation = nil
end
local function delete_phone_prop()
local prop = animation_state.prop
if not prop then
animation_state.ped = nil
return true
end
if not DoesEntityExist(prop) then
animation_state.prop = nil
animation_state.ped = nil
return true
end
DetachEntity(prop, true, true)
SetEntityAsMissionEntity(prop, true, true)
DeleteEntity(prop)
if DoesEntityExist(prop) then
Bridge.Debug("error", "[sky_phone] Failed to delete the attached phone prop.")
return false
end
animation_state.prop = nil
animation_state.ped = nil
return true
end
local function cleanup_phone()
stop_current_animation()
delete_phone_prop()
end
local function ensure_phone_prop(ped, revision)
if animation_state.prop and DoesEntityExist(animation_state.prop) and animation_state.ped == ped then
return true
end
if animation_state.prop then
delete_phone_prop()
end
local model_hash = joaat(Config.Animations.PropModel)
if not load_model(model_hash) then
return false
end
if animation_state.revision ~= revision then
SetModelAsNoLongerNeeded(model_hash)
return false
end
local coords = GetEntityCoords(ped)
local prop = CreateObject(model_hash, coords.x, coords.y, coords.z, true, true, false)
SetModelAsNoLongerNeeded(model_hash)
if not prop or prop == 0 or not DoesEntityExist(prop) then
Bridge.Debug("error", "[sky_phone] Failed to create the phone prop.")
return false
end
SetEntityCollision(prop, false, false)
animation_state.prop = prop
animation_state.ped = ped
return true
end
local function get_transform(mode)
if (mode == MODE_CAMERA_REAR or mode == MODE_CAMERA_SELFIE) and animation_state.camera_landscape then
return Config.Animations.Transforms.Landscape
end
return Config.Animations.Transforms.Portrait
end
local function attach_phone_prop(ped, mode)
local transform = get_transform(mode)
local position = transform.position
local rotation = transform.rotation
AttachEntityToEntity(
animation_state.prop,
ped,
GetPedBoneIndex(ped, Config.Animations.PropBone),
position.x,
position.y,
position.z,
rotation.x,
rotation.y,
rotation.z,
true,
false,
false,
false,
2,
true
)
end
local function play_animation(ped, dictionary, clip, looped, revision)
if not load_animation_dictionary(dictionary) or animation_state.revision ~= revision then
return nil
end
local duration = -1
local flags = LOOPED_UPPER_BODY_FLAGS
if not looped then
local duration_seconds = GetAnimDuration(dictionary, clip)
if duration_seconds <= 0.0 then
Bridge.Debug(
"error",
"[sky_phone] Animation '%s' was not found in dictionary '%s'.",
clip,
dictionary
)
return nil
end
duration = math.max(1, math.floor(duration_seconds * 1000.0))
flags = TRANSITION_UPPER_BODY_FLAGS
end
stop_current_animation()
TaskPlayAnim(ped, dictionary, clip, 4.0, -4.0, duration, flags, 1.0, false, false, false)
animation_state.current_animation = {
clip = clip,
dictionary = dictionary,
}
return duration
end
local function get_base_animation(ped, mode)
local dictionary = get_phone_dictionary(ped)
if mode == MODE_CALL then
return dictionary, Config.Animations.Clips.CallListen
end
if mode == MODE_CAMERA_SELFIE and not IsPedInAnyVehicle(ped, false) then
return Config.Animations.Dictionaries.Selfie, Config.Animations.Clips.Selfie
end
return dictionary, Config.Animations.Clips.TextRead
end
local function get_transition_clip(previous_mode, mode)
if previous_mode == MODE_HIDDEN then
return Config.Animations.Clips.TextIn
end
if previous_mode == MODE_CALL and mode ~= MODE_CALL then
return Config.Animations.Clips.CallToText
end
if previous_mode ~= MODE_CALL and mode == MODE_CALL then
return Config.Animations.Clips.TextToCall
end
return nil
end
local function apply_visible_mode(previous_mode, mode, revision)
local ped = PlayerPedId()
if not can_animate(ped) or not ensure_phone_prop(ped, revision) then
return
end
attach_phone_prop(ped, mode)
local transition_clip = get_transition_clip(previous_mode, mode)
if transition_clip then
local dictionary = get_phone_dictionary(ped)
local duration = play_animation(ped, dictionary, transition_clip, false, revision)
if not duration then
cleanup_phone()
return
end
Wait(duration)
if animation_state.revision ~= revision or not can_animate(ped) then
return
end
attach_phone_prop(ped, mode)
end
local dictionary, clip = get_base_animation(ped, mode)
if not play_animation(ped, dictionary, clip, true, revision) then
cleanup_phone()
end
end
local function apply_hidden_mode(previous_mode, revision)
local ped = animation_state.ped
if not ped or not DoesEntityExist(ped) or not animation_state.prop or not DoesEntityExist(animation_state.prop) then
cleanup_phone()
return
end
local clip = previous_mode == MODE_CALL and Config.Animations.Clips.CallOut or Config.Animations.Clips.TextOut
local dictionary = get_phone_dictionary(ped)
local duration = play_animation(ped, dictionary, clip, false, revision)
if not duration then
cleanup_phone()
return
end
Wait(duration)
if animation_state.revision == revision then
cleanup_phone()
end
end
local function apply_mode(previous_mode, mode, revision)
if mode == MODE_HIDDEN then
apply_hidden_mode(previous_mode, revision)
return
end
apply_visible_mode(previous_mode, mode, revision)
end
local function ensure_context_watcher()
if animation_state.watcher_active or derive_mode() == MODE_HIDDEN then
return
end
animation_state.watcher_active = true
CreateThread(function()
local observed_ped = nil
local observed_context = nil
local was_available = false
while derive_mode() ~= MODE_HIDDEN do
local ped = PlayerPedId()
local available = can_animate(ped)
local context = "unavailable"
if available then
local _, current_context = get_phone_dictionary(ped)
context = current_context
end
if not available and (was_available or animation_state.prop) then
animation_state.revision = animation_state.revision + 1
animation_state.current_mode = MODE_HIDDEN
cleanup_phone()
elseif available and (not was_available or observed_ped ~= ped or observed_context ~= context) then
reevaluate(true)
end
observed_ped = ped
observed_context = context
was_available = available
Wait(Config.Animations.ContextPollMs)
end
animation_state.watcher_active = false
end)
end
reevaluate = function(force)
local mode = derive_mode()
local ped = PlayerPedId()
if mode ~= MODE_HIDDEN and not can_animate(ped) then
animation_state.revision = animation_state.revision + 1
animation_state.current_mode = MODE_HIDDEN
cleanup_phone()
ensure_context_watcher()
return
end
if mode == animation_state.current_mode and not force then
ensure_context_watcher()
return
end
local previous_mode = animation_state.current_mode
animation_state.current_mode = mode
animation_state.revision = animation_state.revision + 1
local revision = animation_state.revision
ensure_context_watcher()
CreateThread(function()
apply_mode(previous_mode, mode, revision)
end)
end
local function reset_animation_state()
animation_state.phone_open = false
animation_state.call_state = nil
animation_state.call_direction = nil
animation_state.camera_active = false
animation_state.camera_front = false
animation_state.camera_landscape = false
animation_state.current_mode = MODE_HIDDEN
animation_state.revision = animation_state.revision + 1
cleanup_phone()
end
AddEventHandler("sky_phone:animation:phone", function(open)
if type(open) ~= "boolean" then
Bridge.Debug("warn", "[sky_phone] Ignored invalid phone animation state.")
return
end
animation_state.phone_open = open
if not open then
animation_state.camera_active = false
animation_state.camera_front = false
animation_state.camera_landscape = false
if derive_mode() == MODE_HIDDEN then
animation_state.current_mode = MODE_HIDDEN
animation_state.revision = animation_state.revision + 1
cleanup_phone()
return
end
end
reevaluate(false)
end)
AddEventHandler("sky_phone:animation:call", function(data)
if type(data) ~= "table" or type(data.state) ~= "string" or type(data.direction) ~= "string" then
Bridge.Debug("warn", "[sky_phone] Ignored invalid call animation state.")
return
end
animation_state.call_state = data.state
animation_state.call_direction = data.direction
reevaluate(false)
end)
AddEventHandler("sky_phone:animation:camera", function(data)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
Bridge.Debug("warn", "[sky_phone] Ignored invalid camera animation state.")
return
end
animation_state.camera_active = data.active and animation_state.phone_open
animation_state.camera_front = data.front == true
animation_state.camera_landscape = data.landscape == true
reevaluate(true)
end)
AddEventHandler("sky_phone:animation:reset", reset_animation_state)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
reset_animation_state()
end
end)
+37
View File
@@ -14,6 +14,7 @@ local camera_state = {
focus_watcher = false,
front_camera = false,
front_camera_handle = nil,
landscape = false,
ultrawide_camera_handle = nil,
nui_focused = true,
previous_ped_view = nil,
@@ -181,6 +182,7 @@ local function set_camera_active(active)
camera_state.active = active
if active then
camera_state.front_camera = false
camera_state.landscape = false
camera_state.zoom = 1.0
clear_front_camera()
clear_ultrawide_camera()
@@ -190,6 +192,11 @@ local function set_camera_active(active)
DisplayRadar(false)
set_camera_focus(true)
apply_camera_view()
TriggerEvent("sky_phone:animation:camera", {
active = true,
front = camera_state.front_camera,
landscape = camera_state.landscape,
})
if camera_state.enforcing then
return
end
@@ -240,6 +247,7 @@ local function set_camera_active(active)
end
set_flash_enabled(false)
camera_state.front_camera = false
camera_state.landscape = false
clear_front_camera()
clear_ultrawide_camera()
restore_camera_view()
@@ -248,6 +256,11 @@ local function set_camera_active(active)
SetNuiFocusKeepInput(false)
SetNuiFocus(true, true)
end
TriggerEvent("sky_phone:animation:camera", {
active = false,
front = false,
landscape = false,
})
end
local function set_front_camera(active)
@@ -268,6 +281,25 @@ local function set_front_camera(active)
end
end
apply_camera_view()
TriggerEvent("sky_phone:animation:camera", {
active = true,
front = camera_state.front_camera,
landscape = camera_state.landscape,
})
end
local function set_camera_landscape(active)
if camera_state.landscape == active then
return
end
camera_state.landscape = active
if camera_state.active then
TriggerEvent("sky_phone:animation:camera", {
active = true,
front = camera_state.front_camera,
landscape = camera_state.landscape,
})
end
end
local function set_camera_zoom(zoom)
@@ -314,6 +346,11 @@ RegisterNUICallback("camera:setFacing", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("camera:setOrientation", function(data, cb)
set_camera_landscape(data and data.landscape == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setZoom", function(data, cb)
cb({ success = set_camera_zoom(tonumber(data and data.zoom)) })
end)
+10
View File
@@ -8,6 +8,10 @@ local call_channel = 0
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
local server_callbacks = {
"security:unlock",
"security:set-passcode",
"security:change-passcode",
"security:disable-passcode",
"device:save",
"device:factory-reset",
"account:login",
@@ -124,6 +128,7 @@ end
local function close_phone()
open_requested = false
TriggerEvent("sky_phone:animation:phone", false)
if not is_open then
return
end
@@ -193,6 +198,7 @@ RegisterNUICallback("ui:opened", function(_, cb)
is_open = true
notification_focus = false
SetNuiFocus(true, true)
TriggerEvent("sky_phone:animation:phone", true)
cb({ success = true })
end)
@@ -352,6 +358,7 @@ end)
RegisterNetEvent("sky_phone:device:invalidated", function()
open_requested = false
device_payload = nil
TriggerEvent("sky_phone:animation:reset")
close_phone()
end)
@@ -466,6 +473,7 @@ end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
notification_focus = true
SetNuiFocus(true, true)
TriggerEvent("sky_phone:animation:call", data)
SendNUIMessage({ type = "call:incoming", data = data })
end)
@@ -477,6 +485,7 @@ RegisterNetEvent("sky_phone:call:state", function(data)
elseif data.state ~= "ringing" then
leave_call_voice()
end
TriggerEvent("sky_phone:animation:call", data)
SendNUIMessage({ type = "call:state", data = data })
end)
@@ -495,6 +504,7 @@ AddEventHandler("onResourceStop", function(resource_name)
SetNuiFocus(false, false)
end
TriggerEvent("sky_phone:animation:reset")
leave_call_voice()
if Config.Phone.DevelopmentCommand then
+43 -2
View File
@@ -208,6 +208,39 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_device_security",
columns = {
{
name = "device_imei",
type = "CHAR(15) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "passcode_hash", type = "BINARY(32) NOT NULL" },
{
name = "passcode_salt",
type = "CHAR(32) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" },
{ name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "device_imei",
foreignKeys = {
{
column = "device_imei",
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_notes",
columns = {
@@ -436,7 +469,7 @@ local schema = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
@@ -619,7 +652,7 @@ local schema = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
@@ -859,6 +892,14 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_marketplace_images`
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_pages_images`
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
]], {})
Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
+18 -28
View File
@@ -1,7 +1,6 @@
Bridge.Database.AfterMigration("sky_phone", function()
local categories = {}
local districts = {}
local photo_gradients = {}
local item_conditions = { new = true, very_good = true, used = true, defective = true }
local price_types = { fixed = true, negotiable = true, free = true }
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
@@ -15,9 +14,6 @@ end
for _, district in ipairs(Config.Marketplace.Districts) do
districts[district] = true
end
for _, gradient in ipairs(Config.Marketplace.PhotoGradients) do
photo_gradients[gradient] = true
end
local function trim(value)
if type(value) ~= "string" then
@@ -96,7 +92,7 @@ local function load_images(listing_id)
]], { listing_id })
end
local function validate_images(source, imei, images)
local function validate_images(source, images)
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
return nil
end
@@ -104,30 +100,15 @@ local function validate_images(source, imei, images)
return {}
end
local owned_media = {
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
["city-lights"] = Config.Marketplace.PhotoGradients[3],
["desert-road"] = Config.Marketplace.PhotoGradients[4],
}
local rows = Bridge.Database.Query([[
SELECT `payload` FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'media'
LIMIT 1
]], { imei })
local media = rows[1] and json.decode(rows[1].payload) or nil
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
owned_media[capture.id] = capture.gradient
end
end
local normalized = {}
local seen = {}
for index, image in ipairs(images) do
local media_id = type(image) == "table" and image.id or nil
local gradient = media_id and owned_media[media_id] or nil
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
local numeric_id = tonumber(media_id)
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
or seen[normalized_id]
then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned marketplace image from source %s.",
@@ -135,8 +116,17 @@ local function validate_images(source, imei, images)
)
return nil
end
seen[media_id] = true
normalized[index] = { id = media_id, gradient = gradient }
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
if not url then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned marketplace image from source %s.",
tostring(source)
)
return nil
end
seen[normalized_id] = true
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
end
return normalized
end
@@ -168,7 +158,7 @@ local function validate_listing(source, account, data)
return nil, "phone_unavailable"
end
local images = validate_images(source, account.imei, data.images)
local images = validate_images(source, data.images)
if not images then
return nil, "invalid_images"
end
+14 -26
View File
@@ -1,10 +1,8 @@
Bridge.Database.AfterMigration("sky_phone", function()
local categories = {}
local districts = {}
local photo_gradients = {}
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
for _, value in ipairs(Config.Marketplace.PhotoGradients) do photo_gradients[value] = true end
local function trim(value)
if type(value) ~= "string" then return nil end
@@ -33,39 +31,29 @@ local function load_images(post_id)
]], { post_id })
end
local function validate_images(source, imei, images)
local function validate_images(source, images)
if type(images) ~= "table" or #images > Config.LocalPages.MaxImages then return nil end
if #images == 0 then return {} end
local owned_media = {
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
["city-lights"] = Config.Marketplace.PhotoGradients[3],
["desert-road"] = Config.Marketplace.PhotoGradients[4],
}
local rows = Bridge.Database.Query([[
SELECT `payload` FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'media'
LIMIT 1
]], { imei })
local media = rows[1] and json.decode(rows[1].payload) or nil
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
owned_media[capture.id] = capture.gradient
end
end
local normalized = {}
local seen = {}
for index, image in ipairs(images) do
local media_id = type(image) == "table" and image.id or nil
local gradient = media_id and owned_media[media_id] or nil
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
local numeric_id = tonumber(media_id)
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
or seen[normalized_id]
then
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
return nil
end
seen[media_id] = true
normalized[index] = { id = media_id, gradient = gradient }
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
if not url then
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
return nil
end
seen[normalized_id] = true
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
end
return normalized
end
@@ -189,7 +177,7 @@ Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
then
return { success = false, error = "invalid_post" }
end
local images = validate_images(source, account.imei, data.images)
local images = validate_images(source, data.images)
if not images then return { success = false, error = "invalid_images" } end
local id = new_id()
local statements = {{
+225 -2
View File
@@ -7,6 +7,15 @@ local sessions = {}
local auth_attempts = {}
local operation_attempts = {}
local max_device_data_bytes = 100000
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
if passcode_pepper == "" then
Bridge.Debug(
"warn",
"[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.",
Config.Security.PasscodePepperConvar,
{ always = true }
)
end
local allowed_device_namespaces = {
settings = true,
notifications = true,
@@ -14,6 +23,7 @@ local allowed_device_namespaces = {
alarms = true,
apps = true,
games = true,
widgets = true,
}
local function trim(value)
@@ -233,6 +243,98 @@ local function load_device_data(imei)
return data
end
local function load_device_security(imei)
local rows = Bridge.Database.Query([[
SELECT `passcode_length`, `failed_attempts`, `locked_until`
FROM `sky_phone_device_security`
WHERE `device_imei` = ?
LIMIT 1
]], { imei })
return rows[1]
end
local function security_status(imei)
local security = load_device_security(imei)
return {
enabled = security ~= nil,
length = security and tonumber(security.passcode_length) or nil,
lockedUntil = security and tonumber(security.locked_until) or 0,
}
end
local function valid_passcode(value)
return type(value) == "string"
and (#value == 4 or #value == 6)
and value:match("^%d+$") ~= nil
end
local function passcode_matches(imei, passcode)
local rows = Bridge.Database.Query([[
SELECT 1 AS `matches`
FROM `sky_phone_device_security`
WHERE `device_imei` = ?
AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256))
LIMIT 1
]], { imei, passcode_pepper, passcode })
return rows[1] ~= nil
end
local function verify_passcode(session, passcode)
if not valid_passcode(passcode) then
return false, { success = false, error = "invalid_passcode" }
end
local security = load_device_security(session.imei)
if not security then
return false, { success = false, error = "passcode_not_set" }
end
local now = os.time()
local locked_until = tonumber(security.locked_until) or 0
if locked_until > now then
return false, {
success = false,
error = "passcode_locked",
data = { retryAfter = locked_until - now },
}
end
if passcode_matches(session.imei, passcode) then
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = 0, `locked_until` = 0
WHERE `device_imei` = ?
]], { session.imei })
return true
end
local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1
if failed_attempts >= Config.Security.MaximumAttempts then
local next_unlock = now + Config.Security.LockSeconds
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = 0, `locked_until` = ?
WHERE `device_imei` = ?
]], { next_unlock, session.imei })
return false, {
success = false,
error = "passcode_locked",
data = { retryAfter = Config.Security.LockSeconds },
}
end
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = ?
WHERE `device_imei` = ?
]], { failed_attempts, session.imei })
return false, {
success = false,
error = "invalid_passcode",
data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts },
}
end
local function account_devices(account_id, current_imei)
local rows = Bridge.Database.Query([[
SELECT `imei`, `device_name`, `created_at`, `updated_at`
@@ -247,7 +349,7 @@ local function account_devices(account_id, current_imei)
end
local function bootstrap(source)
local session, error_response = SkyPhone.RequireSession(source)
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return nil, error_response
end
@@ -259,6 +361,7 @@ local function bootstrap(source)
return {
token = session.token,
security = security_status(device.imei),
device = {
imei = device.imei,
name = device.device_name,
@@ -416,7 +519,7 @@ local function authenticate(source, data, registering)
return link_account(source, accounts[1])
end
function SkyPhone.RequireSession(source)
function SkyPhone.RequireDeviceSession(source)
local session = sessions[source]
if not session then
return nil, { success = false, error = "device_not_open" }
@@ -432,6 +535,17 @@ function SkyPhone.RequireSession(source)
return session
end
function SkyPhone.RequireSession(source)
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return nil, error_response
end
if not session.unlocked then
return nil, { success = false, error = "device_locked" }
end
return session
end
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
local now = os.time()
operation_attempts[source] = operation_attempts[source] or {}
@@ -564,10 +678,12 @@ local function open_phone(source, used_item)
return false
end
local security = load_device_security(imei)
sessions[source] = {
imei = imei,
slot = slot.slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
local payload = bootstrap(source)
Bridge.Debug(
@@ -589,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei)
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
return false
end
local security = load_device_security(imei)
sessions[source] = {
imei = imei,
slot = matches[1].slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
@@ -618,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return error_response
end
if session.unlocked then
return { success = true, data = { security = security_status(session.imei) } }
end
local verified, verification_error = verify_passcode(session, data and data.passcode)
if not verified then
return verification_error
end
session.unlocked = true
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local passcode = data and data.passcode
if not valid_passcode(passcode) then
return { success = false, error = "invalid_passcode" }
end
if load_device_security(session.imei) then
return { success = false, error = "passcode_already_set" }
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then
error("[sky_phone] Database did not generate a valid passcode salt.")
end
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_device_security`
(`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`)
VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?)
]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode })
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local new_passcode = data and data.newPasscode
if not valid_passcode(new_passcode) then
return { success = false, error = "invalid_passcode" }
end
local verified, verification_error = verify_passcode(session, data and data.currentPasscode)
if not verified then
return verification_error
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then
error("[sky_phone] Database did not generate a valid passcode salt.")
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)),
`passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0
WHERE `device_imei` = ?
]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei })
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local verified, verification_error = verify_passcode(session, data and data.passcode)
if not verified then
return verification_error
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
{ session.imei }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
session.unlocked = true
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
if not Config.Phone.DevelopmentCommand then
return { success = false, error = "disabled" }
@@ -763,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
end
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
@@ -791,6 +1013,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
return { success = false, error = "request_failed" }
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
session.unlocked = true
refresh_source(source)
return { success = true }
end)
+12
View File
@@ -92,6 +92,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`passcode_hash` BINARY(32) NOT NULL,
`passcode_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`passcode_length` TINYINT UNSIGNED NOT NULL,
`failed_attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`locked_until` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`device_imei`),
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
`id` VARCHAR(64) NOT NULL,
`account_id` BIGINT UNSIGNED NULL,