MERGE - resolve dev integration conflicts

This commit is contained in:
Dominik
2026-08-09 05:36:10 +02:00
54 changed files with 8188 additions and 307 deletions
+40 -5
View File
@@ -23,6 +23,7 @@ let renderFrameId: number | undefined
let lastRenderAt = 0
let recorder: MediaRecorder | null = null
let stream: MediaStream | null = null
let microphoneStream: MediaStream | null = null
let chunks: RecordingChunk[] = []
let lastChunkAt = 0
let lastChunkTimecode: number | null = null
@@ -95,7 +96,9 @@ function resetRecording(): void {
function stopTracks(): void {
stream?.getTracks().forEach((track) => track.stop())
microphoneStream?.getTracks().forEach((track) => track.stop())
stream = null
microphoneStream = null
}
function cleanupRecording(): void {
@@ -109,7 +112,7 @@ function cleanupRecording(): void {
postRecordState(false)
}
function startRecording(data: Record<string, unknown>): void {
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (recorder) return
if (typeof MediaRecorder === 'undefined') {
window.postMessage(
@@ -127,13 +130,45 @@ function startRecording(data: Record<string, unknown>): void {
}
startRenderLoop()
resetRecording()
stream = canvasRef.value?.captureStream(captureFps) ?? null
if (!stream) {
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
if (!videoStream) {
cleanupRecording()
return
}
if (data.microphoneEnabled === true) {
try {
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
} catch {
videoStream.getTracks().forEach((track) => track.stop())
cleanupRecording()
window.postMessage(
{
data: { error: 'microphone_unavailable', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
}
stream = new MediaStream([
...videoStream.getVideoTracks(),
...(microphoneStream?.getAudioTracks() ?? []),
])
const mimeType = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
].find((type) => MediaRecorder.isTypeSupported(type))
recorder = new MediaRecorder(stream, {
mimeType: 'video/webm',
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
recorder.ondataavailable = (event) => {
@@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void {
type?: string
}
if (message.type === 'camera:recordStart') {
startRecording(message.data ?? {})
void startRecording(message.data ?? {})
} else if (message.type === 'camera:recordStop') {
void stopRecording(message.data ?? {})
} else if (message.type === 'camera:recordCancel') {
+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>
+161 -35
View File
@@ -111,6 +111,9 @@ const forecastLow = computed(() =>
const weatherIcon = computed(
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
)
const visibleHourlyWeather = computed(
() => weather.forecast.value?.hourly.slice(0, 5) ?? [],
)
const balance = computed(() =>
props.instance.settings.balanceSource === 'cash'
? bank.overview.value.cash
@@ -148,6 +151,13 @@ function avatar(name: string): string {
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
}
function formatForecastHour(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
}).format(timestamp)
}
function clearHold(): void {
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
holdTimer = undefined
@@ -326,6 +336,13 @@ onBeforeUnmount(() => {
<small v-if="instance.size !== 'small'" class="widget-weather-range">
H: {{ forecastHigh ?? '--' }}° &nbsp; L: {{ forecastLow ?? '--' }}°
</small>
<div v-if="instance.size !== 'small'" class="widget-weather-hourly">
<div v-for="hour in visibleHourlyWeather" :key="hour.timestamp">
<time>{{ formatForecastHour(hour.timestamp) }}</time>
<component :is="weatherIcons[hour.condition]" :size="21" />
<strong>{{ hour.temperature }}°</strong>
</div>
</div>
</template>
<template v-else-if="instance.kind === 'music'">
@@ -492,30 +509,46 @@ onBeforeUnmount(() => {
.home-widget {
width: 100%;
height: 100%;
padding: 14px;
padding: 15px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 22px;
border: 0.75px solid rgb(255 255 255 / 17%);
border-radius: 25px;
outline: none;
color: #fff;
background: rgb(28 28 30 / 76%);
background: rgb(25 25 27 / 91%);
box-shadow:
0 8px 24px rgb(0 0 0 / 24%),
inset 0 0.5px rgb(255 255 255 / 18%);
0 10px 25px rgb(0 0 0 / 28%),
inset 0 0.75px rgb(255 255 255 / 15%);
backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.home-widget-shell--small .home-widget {
padding: 13px;
border-radius: 23px;
}
.home-widget-shell--large .home-widget {
padding: 18px;
border-radius: 28px;
}
.home-widget:active {
filter: brightness(1.08);
}
.home-widget small,
.widget-eyebrow {
color: rgb(255 255 255 / 68%);
color: rgb(255 255 255 / 62%);
font-size: 11px;
font-weight: 500;
line-height: 1.25;
}
@@ -528,25 +561,30 @@ onBeforeUnmount(() => {
justify-content: flex-end;
}
.home-widget--clock {
background: rgb(12 12 13 / 94%);
}
.widget-clock {
margin: 1px 0;
font-size: 31px;
font-weight: 400;
letter-spacing: -1.6px;
margin: 2px 0;
font-size: 28px;
font-weight: 500;
letter-spacing: -1.8px;
line-height: 1;
white-space: nowrap;
}
.home-widget-shell--medium .widget-clock {
font-size: 45px;
font-size: 48px;
}
.home-widget--date {
background: rgb(247 247 247 / 88%);
color: #111;
background: rgb(24 24 26 / 94%);
color: #fff;
}
.home-widget--date small {
color: #6e6e73;
color: rgb(255 255 255 / 58%);
}
.widget-date-month {
@@ -557,8 +595,8 @@ onBeforeUnmount(() => {
}
.widget-date-day {
font-size: 48px;
font-weight: 300;
font-size: 52px;
font-weight: 400;
letter-spacing: -2px;
line-height: 0.95;
}
@@ -567,7 +605,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
justify-content: space-between;
background: rgb(25 69 112 / 82%);
background: rgb(34 42 78 / 95%);
}
.widget-weather-top {
@@ -582,14 +620,72 @@ onBeforeUnmount(() => {
}
.widget-weather-top strong {
font-size: 34px;
font-weight: 300;
font-size: 38px;
font-weight: 400;
letter-spacing: -1.5px;
line-height: 1;
}
.widget-weather-range {
margin-top: 4px;
margin-top: 3px;
color: rgb(255 255 255 / 86%) !important;
font-weight: 600 !important;
}
.widget-weather-hourly {
display: grid;
margin-top: 12px;
padding-top: 11px;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 0.5px solid rgb(255 255 255 / 18%);
}
.widget-weather-hourly > div {
display: grid;
min-width: 0;
justify-items: center;
gap: 6px;
}
.widget-weather-hourly time {
color: rgb(255 255 255 / 58%);
font-size: 9px;
font-weight: 600;
}
.widget-weather-hourly strong {
font-size: 12px;
font-weight: 650;
}
.home-widget-shell--medium .home-widget--weather {
padding: 13px 15px;
}
.home-widget-shell--medium .widget-weather-top strong {
font-size: 32px;
}
.home-widget-shell--medium .widget-weather-hourly {
margin-top: 7px;
padding-top: 7px;
}
.home-widget-shell--medium .widget-weather-hourly > div {
gap: 3px;
}
.home-widget-shell--medium .widget-weather-hourly svg {
width: 18px;
height: 18px;
}
.home-widget-shell--medium .widget-weather-hourly time {
font-size: 8px;
}
.home-widget-shell--medium .widget-weather-hourly strong {
font-size: 11px;
}
.home-widget--music {
@@ -597,6 +693,7 @@ onBeforeUnmount(() => {
align-items: center;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
background: rgb(25 25 28 / 94%);
}
.home-widget-shell--large .home-widget--music {
@@ -610,10 +707,12 @@ onBeforeUnmount(() => {
width: 58px;
height: 58px;
place-items: center;
border-radius: 13px;
border-radius: 14px;
color: #fff;
background: #b33a3a;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
background: #5653b8;
box-shadow:
inset 0 0 0 0.5px rgb(255 255 255 / 25%),
0 5px 13px rgb(0 0 0 / 24%);
font-size: 13px;
font-weight: 800;
letter-spacing: 1.5px;
@@ -640,6 +739,10 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.widget-music-copy strong {
font-size: 13px;
}
.widget-music-controls {
display: flex;
align-items: center;
@@ -655,7 +758,11 @@ onBeforeUnmount(() => {
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(255 255 255 / 13%);
background: rgb(255 255 255 / 11%);
}
.home-widget--wallet {
background: rgb(25 26 29 / 94%);
}
.widget-wallet-icon {
@@ -667,14 +774,14 @@ onBeforeUnmount(() => {
height: 36px;
place-items: center;
border-radius: 11px;
color: #0a84ff;
background: rgb(10 132 255 / 15%);
color: #64d2ff;
background: rgb(100 210 255 / 13%);
}
.widget-balance {
margin: 2px 0;
font-size: 28px;
font-weight: 600;
font-size: 30px;
font-weight: 650;
letter-spacing: -1.2px;
}
@@ -684,14 +791,26 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.home-widget--transactions {
background: rgb(29 29 31 / 94%);
}
.home-widget--contacts {
background: rgb(25 26 29 / 94%);
}
.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;
color: #64d2ff;
font-size: 13px;
font-weight: 700;
}
.home-widget--transactions .widget-list-header {
color: #30d158;
}
.widget-transaction {
@@ -701,7 +820,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: space-between;
border: 0;
border-top: 0.5px solid rgb(255 255 255 / 12%);
border-top: 0.5px solid rgb(255 255 255 / 11%);
color: #fff;
background: transparent;
text-align: left;
@@ -715,6 +834,12 @@ onBeforeUnmount(() => {
.widget-transaction strong {
font-size: 12px;
font-weight: 650;
}
.widget-transaction small {
margin-top: 1px;
font-size: 9px;
}
.widget-transaction b {
@@ -756,6 +881,7 @@ onBeforeUnmount(() => {
border-radius: 50%;
color: #fff;
background: #5e5ce6;
box-shadow: inset 0 1px rgb(255 255 255 / 20%);
font-size: 16px;
font-weight: 650;
}
@@ -784,8 +910,8 @@ onBeforeUnmount(() => {
.widget-contacts button {
width: 25px;
height: 25px;
color: #0a84ff;
background: rgb(10 132 255 / 14%);
color: #64d2ff;
background: rgb(100 210 255 / 12%);
}
.home-widget-remove {
@@ -164,6 +164,8 @@ watch(
:key="contact.id"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="contact.name"
:subtitle="contact.phone_number"
@click="toggleContact(contact.id)"
@@ -183,6 +183,8 @@ watch(
:key="definition.kind"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="phone.t(definition.labelKey)"
:subtitle="phone.t(definition.descriptionKey)"
@click="selectWidget(definition)"