mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
1249 lines
30 KiB
Vue
1249 lines
30 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Camera,
|
|
CarFront,
|
|
Check,
|
|
House,
|
|
KeyRound,
|
|
LocateFixed,
|
|
Lock,
|
|
LockOpen,
|
|
Plus,
|
|
Router,
|
|
Share2,
|
|
UserRound,
|
|
UsersRound,
|
|
WifiOff,
|
|
} from 'lucide-vue-next'
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
|
import { usePullToRefresh } from '@/composables/usePullToRefresh'
|
|
import { useEasyShareStore } from '@/stores/easyshare'
|
|
import { useHousingStore } from '@/stores/housing'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import type {
|
|
HousingKey,
|
|
HousingKeyCandidate,
|
|
HousingProperty,
|
|
} from '@/types/housing'
|
|
import {
|
|
SkyAppPage,
|
|
SkyBlockTitle,
|
|
SkyButton,
|
|
SkyCard,
|
|
SkyDialog,
|
|
SkyDialogButton,
|
|
SkyList,
|
|
SkyListItem,
|
|
SkyNavbar,
|
|
SkySheet,
|
|
SkySpinner,
|
|
SkyNotification,
|
|
} from '@/ui'
|
|
|
|
const phone = usePhoneStore()
|
|
const housing = useHousingStore()
|
|
const easyShare = useEasyShareStore()
|
|
const selectedPropertyId = ref<string | null>(null)
|
|
const candidatesOpened = ref(false)
|
|
const revokeCandidate = ref<HousingKey | null>(null)
|
|
const toastOpened = ref(false)
|
|
const toastText = ref('')
|
|
const houseScroll = ref<HTMLElement | null>(null)
|
|
let toastTimer: number | undefined
|
|
|
|
const {
|
|
finishPull,
|
|
movePull,
|
|
pullDistance,
|
|
pullThreshold,
|
|
pullWithWheel: updatePullWithWheel,
|
|
refresh,
|
|
startPull,
|
|
} = usePullToRefresh({
|
|
isAtTop: () => (houseScroll.value?.scrollTop ?? 0) <= 0,
|
|
refresh: async () => {
|
|
const loaded = await housing.load(true)
|
|
if (!loaded && housing.error === 'reload_cooldown') {
|
|
showToast(translatedError(housing.error))
|
|
}
|
|
},
|
|
})
|
|
|
|
const properties = computed(() => housing.overview?.properties ?? [])
|
|
const ownedCount = computed(
|
|
() =>
|
|
properties.value.filter((property) => property.access === 'owner').length,
|
|
)
|
|
const sharedCount = computed(() => properties.value.length - ownedCount.value)
|
|
const lockedCount = computed(
|
|
() => properties.value.filter((property) => property.locked).length,
|
|
)
|
|
const selectedProperty = computed(
|
|
() =>
|
|
properties.value.find(
|
|
(property) => property.id === selectedPropertyId.value,
|
|
) ?? null,
|
|
)
|
|
|
|
function translatedError(error: string): string {
|
|
const key = `Apps.house.errors.${error}`
|
|
const translated = phone.t(key)
|
|
return translated === key ? phone.t('Apps.house.errors.default') : translated
|
|
}
|
|
|
|
function showToast(message: string): void {
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
toastText.value = message
|
|
toastOpened.value = true
|
|
toastTimer = window.setTimeout(() => {
|
|
toastOpened.value = false
|
|
}, 2400)
|
|
}
|
|
|
|
function isPending(action: string, property: HousingProperty): boolean {
|
|
return housing.pendingAction === `${action}:${property.id}`
|
|
}
|
|
|
|
function pullWithWheel(event: WheelEvent): void {
|
|
if (!updatePullWithWheel(event)) return
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
}
|
|
|
|
async function runCommand(
|
|
action: 'open_cctv' | 'set_waypoint' | 'toggle_lock',
|
|
property: HousingProperty,
|
|
): Promise<void> {
|
|
if (!(await housing.command(action, property.id))) {
|
|
showToast(translatedError(housing.error))
|
|
return
|
|
}
|
|
const successKeys = {
|
|
open_cctv: 'cameraStarting',
|
|
set_waypoint: 'waypointSuccess',
|
|
toggle_lock: 'lockSuccess',
|
|
} as const
|
|
showToast(phone.t(`Apps.house.${successKeys[action]}`))
|
|
}
|
|
|
|
async function openKeyCandidates(property: HousingProperty): Promise<void> {
|
|
candidatesOpened.value = true
|
|
if (!(await housing.loadKeyCandidates(property.id))) {
|
|
showToast(translatedError(housing.error))
|
|
}
|
|
}
|
|
|
|
async function grantKey(candidate: HousingKeyCandidate): Promise<void> {
|
|
const property = selectedProperty.value
|
|
if (!property) return
|
|
if (
|
|
!(await housing.command('grant_key', property.id, { target: candidate.id }))
|
|
) {
|
|
showToast(translatedError(housing.error))
|
|
return
|
|
}
|
|
candidatesOpened.value = false
|
|
showToast(phone.t('Apps.house.keyGranted'))
|
|
}
|
|
|
|
async function revokeKey(): Promise<void> {
|
|
const property = selectedProperty.value
|
|
const key = revokeCandidate.value
|
|
if (!property || !key) return
|
|
if (
|
|
!(await housing.command('revoke_key', property.id, {
|
|
identifier: key.identifier,
|
|
}))
|
|
) {
|
|
showToast(translatedError(housing.error))
|
|
return
|
|
}
|
|
revokeCandidate.value = null
|
|
showToast(phone.t('Apps.house.keyRevoked'))
|
|
}
|
|
|
|
function accessLabel(property: HousingProperty): string {
|
|
return phone.t(
|
|
property.access === 'owner' ? 'Apps.house.owner' : 'Apps.house.keyholder',
|
|
)
|
|
}
|
|
|
|
function propertySubtitle(property: HousingProperty): string {
|
|
const details = [accessLabel(property)]
|
|
if (property.garage?.enabled) {
|
|
details.push(
|
|
phone.t('Apps.house.storedVehicles', {
|
|
count: String(property.garage.storedVehicles),
|
|
}),
|
|
)
|
|
}
|
|
return details.join(' · ')
|
|
}
|
|
|
|
function shareProperty(property: HousingProperty): void {
|
|
easyShare.open({
|
|
appId: 'house',
|
|
copyText: property.name,
|
|
id: property.id,
|
|
kind: 'document',
|
|
link: `skyphone://house/property/${property.id}`,
|
|
subtitle: accessLabel(property),
|
|
title: property.name,
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
void housing.load()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<sky-app-page component="main" class="house-page">
|
|
<sky-navbar
|
|
class="house-navbar"
|
|
:subtitle="phone.t('Apps.house.subtitle')"
|
|
:title="phone.t('Apps.house.name')"
|
|
/>
|
|
|
|
<div v-if="housing.isLoading && !housing.overview" class="house-state">
|
|
<sky-spinner />
|
|
<span>{{ phone.t('Common.loading') }}</span>
|
|
</div>
|
|
|
|
<div v-else-if="!housing.overview" class="house-state">
|
|
<span class="house-state__icon"><WifiOff :size="30" /></span>
|
|
<strong>{{ phone.t('Apps.house.unavailable') }}</strong>
|
|
<p>{{ translatedError(housing.error) }}</p>
|
|
<sky-button
|
|
rounded
|
|
style="--sky-app-accent: var(--house-accent)"
|
|
@click="refresh"
|
|
>
|
|
{{ phone.t('Apps.house.tryAgain') }}
|
|
</sky-button>
|
|
</div>
|
|
|
|
<div v-else-if="!housing.overview.available" class="house-state">
|
|
<span class="house-state__icon"><Router :size="31" /></span>
|
|
<strong>{{ phone.t('Apps.house.offline') }}</strong>
|
|
<p>{{ phone.t('Apps.house.offlineBody') }}</p>
|
|
<sky-button
|
|
rounded
|
|
style="--sky-app-accent: var(--house-accent)"
|
|
@click="refresh"
|
|
>
|
|
{{ phone.t('Apps.house.tryAgain') }}
|
|
</sky-button>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
ref="houseScroll"
|
|
class="house-scroll"
|
|
@touchend="finishPull"
|
|
@touchmove.passive="movePull"
|
|
@touchstart.passive="startPull"
|
|
@wheel="pullWithWheel"
|
|
>
|
|
<div
|
|
class="house-pull-refresh"
|
|
:class="{ 'is-visible': pullDistance > 0 }"
|
|
:style="{ transform: `translateY(${pullDistance - pullThreshold}px)` }"
|
|
aria-live="polite"
|
|
>
|
|
<sky-spinner />
|
|
</div>
|
|
<section v-if="properties.length" class="house-properties">
|
|
<div class="house-overview" :aria-label="phone.t('Apps.house.myHomes')">
|
|
<span
|
|
><b>{{ ownedCount }}</b
|
|
>{{ phone.t('Apps.house.owned') }}</span
|
|
>
|
|
<span
|
|
><b>{{ sharedCount }}</b
|
|
>{{ phone.t('Apps.house.shared') }}</span
|
|
>
|
|
<span
|
|
><b>{{ lockedCount }}</b
|
|
>{{ phone.t('Apps.house.locked') }}</span
|
|
>
|
|
</div>
|
|
|
|
<sky-list inset strong class="house-property-list">
|
|
<sky-list-item
|
|
v-for="property in properties"
|
|
:key="property.id"
|
|
link
|
|
chevron
|
|
:title="property.name"
|
|
:subtitle="propertySubtitle(property)"
|
|
:aria-label="`${phone.t('Apps.house.openDetails')}: ${property.name}`"
|
|
@click="selectedPropertyId = property.id"
|
|
>
|
|
<template #media>
|
|
<span class="house-property__icon"><House :size="21" /></span>
|
|
</template>
|
|
<template #after>
|
|
<span
|
|
v-if="property.capabilities.lock"
|
|
class="house-property__status"
|
|
:class="{ 'is-locked': property.locked }"
|
|
>
|
|
<Lock v-if="property.locked" :size="13" />
|
|
<LockOpen v-else :size="13" />
|
|
{{
|
|
phone.t(
|
|
property.locked
|
|
? 'Apps.house.locked'
|
|
: 'Apps.house.unlocked',
|
|
)
|
|
}}
|
|
</span>
|
|
</template>
|
|
</sky-list-item>
|
|
</sky-list>
|
|
</section>
|
|
|
|
<section v-else class="house-empty">
|
|
<span class="house-empty__icon"><House :size="32" /></span>
|
|
<small>{{ phone.t('Apps.house.myHomes') }}</small>
|
|
<h1>{{ phone.t('Apps.house.empty') }}</h1>
|
|
<p>{{ phone.t('Apps.house.emptyBody') }}</p>
|
|
<sky-button
|
|
inline
|
|
rounded
|
|
style="--sky-app-accent: var(--house-accent)"
|
|
@click="refresh"
|
|
>
|
|
{{ phone.t('Apps.house.refresh') }}
|
|
</sky-button>
|
|
</section>
|
|
</div>
|
|
|
|
<sky-sheet
|
|
:opened="Boolean(selectedProperty)"
|
|
class="house-detail-sheet"
|
|
grabber-clickable
|
|
:grabber-label="phone.t('Common.close')"
|
|
swipe-to-close
|
|
@backdropclick="selectedPropertyId = null"
|
|
@escape="selectedPropertyId = null"
|
|
@grabberclick="selectedPropertyId = null"
|
|
@swipeclose="selectedPropertyId = null"
|
|
>
|
|
<section v-if="selectedProperty" class="house-detail">
|
|
<span class="house-detail__mark"><House :size="29" /></span>
|
|
<small>{{ accessLabel(selectedProperty) }}</small>
|
|
<h2>{{ selectedProperty.name }}</h2>
|
|
<span
|
|
v-if="selectedProperty.capabilities.lock"
|
|
class="house-detail__status"
|
|
:class="{ 'is-locked': selectedProperty.locked }"
|
|
>
|
|
<Lock v-if="selectedProperty.locked" :size="14" />
|
|
<LockOpen v-else :size="14" />
|
|
{{
|
|
phone.t(
|
|
selectedProperty.locked
|
|
? 'Apps.house.locked'
|
|
: 'Apps.house.unlocked',
|
|
)
|
|
}}
|
|
</span>
|
|
|
|
<sky-button
|
|
v-if="selectedProperty.capabilities.lock"
|
|
large
|
|
rounded
|
|
class="house-lock-button"
|
|
:disabled="isPending('toggle_lock', selectedProperty)"
|
|
@click="runCommand('toggle_lock', selectedProperty)"
|
|
>
|
|
<LockOpen v-if="selectedProperty.locked" :size="19" />
|
|
<Lock v-else :size="19" />
|
|
{{
|
|
phone.t(
|
|
selectedProperty.locked
|
|
? 'Apps.house.unlockDoor'
|
|
: 'Apps.house.lockDoor',
|
|
)
|
|
}}
|
|
</sky-button>
|
|
|
|
<sky-block-title>{{ phone.t('Apps.house.actions') }}</sky-block-title>
|
|
<sky-list inset strong class="house-action-list">
|
|
<sky-list-item
|
|
link
|
|
chevron
|
|
:title="phone.t('Apps.house.setWaypoint')"
|
|
:disabled="isPending('set_waypoint', selectedProperty)"
|
|
@click="runCommand('set_waypoint', selectedProperty)"
|
|
>
|
|
<template #media><LocateFixed :size="19" /></template>
|
|
</sky-list-item>
|
|
<sky-list-item
|
|
:link="selectedProperty.capabilities.cctv"
|
|
:chevron="selectedProperty.capabilities.cctv"
|
|
:title="phone.t('Apps.house.viewCamera')"
|
|
:subtitle="
|
|
phone.t(
|
|
selectedProperty.capabilities.cctv
|
|
? 'Apps.house.cameraAvailable'
|
|
: 'Apps.house.cameraUnavailable',
|
|
)
|
|
"
|
|
:disabled="
|
|
!selectedProperty.capabilities.cctv ||
|
|
isPending('open_cctv', selectedProperty)
|
|
"
|
|
@click="
|
|
selectedProperty.capabilities.cctv &&
|
|
runCommand('open_cctv', selectedProperty)
|
|
"
|
|
>
|
|
<template #media><Camera :size="19" /></template>
|
|
</sky-list-item>
|
|
<sky-list-item
|
|
link
|
|
chevron
|
|
:title="phone.t('Apps.easyShare.share')"
|
|
@click="shareProperty(selectedProperty)"
|
|
>
|
|
<template #media><Share2 :size="19" /></template>
|
|
</sky-list-item>
|
|
</sky-list>
|
|
|
|
<sky-block-title>{{ phone.t('Apps.house.status') }}</sky-block-title>
|
|
<sky-list inset strong class="house-facts">
|
|
<sky-list-item
|
|
:title="phone.t('Apps.house.access')"
|
|
:after="accessLabel(selectedProperty)"
|
|
>
|
|
<template #media><UserRound :size="17" /></template>
|
|
</sky-list-item>
|
|
<sky-list-item
|
|
:title="phone.t('Apps.house.camera')"
|
|
:after="
|
|
phone.t(
|
|
selectedProperty.capabilities.cctv
|
|
? 'Apps.house.cameraAvailable'
|
|
: 'Apps.house.cameraUnavailable',
|
|
)
|
|
"
|
|
>
|
|
<template #media><Camera :size="17" /></template>
|
|
</sky-list-item>
|
|
<sky-list-item
|
|
v-if="selectedProperty.garage"
|
|
:title="phone.t('Apps.house.garage')"
|
|
:subtitle="
|
|
phone.t('Apps.house.storedVehicles', {
|
|
count: String(selectedProperty.garage.storedVehicles),
|
|
})
|
|
"
|
|
:after="
|
|
phone.t(
|
|
selectedProperty.garage.enabled
|
|
? 'Apps.house.garageEnabled'
|
|
: 'Apps.house.garageDisabled',
|
|
)
|
|
"
|
|
>
|
|
<template #media><CarFront :size="17" /></template>
|
|
</sky-list-item>
|
|
</sky-list>
|
|
|
|
<section v-if="selectedProperty.capabilities.keys" class="house-keys">
|
|
<header>
|
|
<span
|
|
><small>{{ phone.t('Apps.house.access') }}</small
|
|
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
|
|
>
|
|
<sky-button
|
|
rounded
|
|
small
|
|
inline
|
|
@click="openKeyCandidates(selectedProperty)"
|
|
>
|
|
<Plus :size="15" />{{ phone.t('Apps.house.addKey') }}
|
|
</sky-button>
|
|
</header>
|
|
<sky-list
|
|
v-if="selectedProperty.keys?.length"
|
|
inset
|
|
strong
|
|
class="house-key-list"
|
|
>
|
|
<sky-list-item
|
|
v-for="key in selectedProperty.keys"
|
|
:key="key.identifier"
|
|
:title="key.name"
|
|
:subtitle="
|
|
phone.t(
|
|
key.online ? 'Apps.house.onlineKey' : 'Apps.house.offlineKey',
|
|
)
|
|
"
|
|
>
|
|
<template #media><KeyRound :size="17" /></template>
|
|
<template #after>
|
|
<sky-button
|
|
clear
|
|
rounded
|
|
small
|
|
:disabled="key.revocable === false"
|
|
@click.stop="revokeCandidate = key"
|
|
>
|
|
{{ phone.t('Apps.house.revokeKey') }}
|
|
</sky-button>
|
|
</template>
|
|
</sky-list-item>
|
|
</sky-list>
|
|
<sky-card v-else class="house-keys__empty">
|
|
<KeyRound :size="25" />
|
|
<strong>{{ phone.t('Apps.house.noKeys') }}</strong>
|
|
<p>{{ phone.t('Apps.house.noKeysBody') }}</p>
|
|
</sky-card>
|
|
</section>
|
|
</section>
|
|
</sky-sheet>
|
|
|
|
<sky-sheet
|
|
:opened="candidatesOpened"
|
|
class="house-candidates-sheet"
|
|
grabber-clickable
|
|
:grabber-label="phone.t('Common.close')"
|
|
swipe-to-close
|
|
@backdropclick="candidatesOpened = false"
|
|
@escape="candidatesOpened = false"
|
|
@grabberclick="candidatesOpened = false"
|
|
@swipeclose="candidatesOpened = false"
|
|
>
|
|
<section class="house-candidates">
|
|
<UsersRound :size="34" />
|
|
<h2>{{ phone.t('Apps.house.chooseResident') }}</h2>
|
|
<p>{{ phone.t('Apps.house.chooseResidentBody') }}</p>
|
|
<div
|
|
v-if="housing.isLoadingCandidates"
|
|
class="house-candidates__loading"
|
|
>
|
|
<sky-spinner />
|
|
</div>
|
|
<sky-list v-else-if="housing.candidates.length" inset strong>
|
|
<sky-list-item
|
|
v-for="candidate in housing.candidates"
|
|
:key="candidate.id"
|
|
link
|
|
chevron
|
|
:title="candidate.name"
|
|
@click="grantKey(candidate)"
|
|
>
|
|
<template #media><UserRound :size="17" /></template>
|
|
</sky-list-item>
|
|
</sky-list>
|
|
<sky-card v-else class="house-keys__empty">
|
|
<UsersRound :size="25" />
|
|
<strong>{{ phone.t('Apps.house.noCandidates') }}</strong>
|
|
<p>{{ phone.t('Apps.house.noCandidatesBody') }}</p>
|
|
</sky-card>
|
|
</section>
|
|
</sky-sheet>
|
|
|
|
<sky-dialog
|
|
:opened="Boolean(revokeCandidate)"
|
|
@backdropclick="revokeCandidate = null"
|
|
>
|
|
<template #title>{{ phone.t('Apps.house.revokeTitle') }}</template>
|
|
<p v-if="revokeCandidate" class="house-revoke-copy">
|
|
{{ phone.t('Apps.house.revokeBody', { name: revokeCandidate.name }) }}
|
|
</p>
|
|
<template #buttons>
|
|
<sky-dialog-button @click="revokeCandidate = null">{{
|
|
phone.t('Apps.house.cancel')
|
|
}}</sky-dialog-button>
|
|
<sky-dialog-button strong @click="revokeKey"
|
|
><Check :size="15" />{{
|
|
phone.t('Apps.house.confirm')
|
|
}}</sky-dialog-button
|
|
>
|
|
</template>
|
|
</sky-dialog>
|
|
|
|
<SkyNotification
|
|
:opened="toastOpened"
|
|
:text="toastText"
|
|
@click="toastOpened = false"
|
|
/>
|
|
</sky-app-page>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.house-page {
|
|
--house-accent: #f47a38;
|
|
--house-accent-soft: #ffb054;
|
|
--house-bg: #f3f3f7;
|
|
--house-panel: rgb(255 255 255/0.84);
|
|
--house-text: #161619;
|
|
--house-muted: #72727a;
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
isolation: isolate;
|
|
background:
|
|
radial-gradient(circle at 15% 4%, #ffd39b 0, transparent 32%),
|
|
linear-gradient(165deg, #f4e9dd 0, #f3f3f7 47%, #e8edf3 100%);
|
|
color: var(--house-text);
|
|
}
|
|
:global(.phone-app.dark .house-page) {
|
|
--house-bg: #101114;
|
|
--house-panel: rgb(39 39 43/0.78);
|
|
--house-text: #fafafa;
|
|
--house-muted: #aaaab2;
|
|
background:
|
|
radial-gradient(circle at 15% 4%, #7a3b1f 0, transparent 34%),
|
|
linear-gradient(165deg, #26201d 0, #111216 48%, #171d25 100%);
|
|
}
|
|
.house-navbar {
|
|
--sky-navbar-glass: transparent;
|
|
position: absolute;
|
|
z-index: 5;
|
|
top: 0;
|
|
right: 0;
|
|
left: 0;
|
|
background: transparent !important;
|
|
}
|
|
.house-navbar::after {
|
|
opacity: 0;
|
|
}
|
|
.house-navbar :deep(.sky-navbar__heading) {
|
|
width: 100%;
|
|
justify-content: center;
|
|
grid-column: 1 / -1;
|
|
gap: 1px;
|
|
padding: 0 var(--sky-page-gutter);
|
|
}
|
|
.house-navbar :deep(.sky-navbar__title) {
|
|
line-height: 24px;
|
|
}
|
|
.house-navbar :deep(.sky-navbar__subtitle) {
|
|
max-width: 100%;
|
|
margin-top: 0;
|
|
overflow: visible;
|
|
line-height: 15px;
|
|
text-overflow: clip;
|
|
white-space: normal;
|
|
}
|
|
.house-scroll {
|
|
position: absolute;
|
|
inset: calc(
|
|
var(--sky-safe-area-top) + var(--sky-navbar-height) + var(--sky-space-3)
|
|
)
|
|
0 25px;
|
|
padding: 9px 13px 34px;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
}
|
|
.house-pull-refresh {
|
|
position: absolute;
|
|
z-index: 4;
|
|
top: 4px;
|
|
right: 0;
|
|
left: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
color: var(--house-accent);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition:
|
|
opacity 160ms ease,
|
|
transform 160ms ease;
|
|
}
|
|
.house-pull-refresh.is-visible {
|
|
opacity: 1;
|
|
}
|
|
.house-state {
|
|
position: absolute;
|
|
inset: calc(
|
|
var(--sky-safe-area-top) + var(--sky-navbar-height) + var(--sky-space-3)
|
|
)
|
|
0 25px;
|
|
padding: 30px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
}
|
|
.house-state__icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
margin-bottom: 13px;
|
|
border-radius: 22px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--house-panel);
|
|
color: var(--house-accent);
|
|
box-shadow: 0 12px 30px #5b2f1822;
|
|
}
|
|
.house-state strong {
|
|
font-size: 18px;
|
|
}
|
|
.house-state p {
|
|
max-width: 250px;
|
|
margin: 6px 0 17px;
|
|
color: var(--house-muted);
|
|
font-size: 10px;
|
|
line-height: 1.45;
|
|
}
|
|
.house-hero {
|
|
padding: 16px;
|
|
border: 1px solid #fff8;
|
|
border-radius: 24px;
|
|
background: linear-gradient(
|
|
135deg,
|
|
rgb(255 255 255/0.85),
|
|
rgb(255 247 239/0.52)
|
|
);
|
|
box-shadow: 0 14px 34px #7338121c;
|
|
}
|
|
:global(.phone-app.dark .house-hero) {
|
|
border-color: #ffffff17;
|
|
background: linear-gradient(135deg, #ffffff1d, #ff8a4210);
|
|
}
|
|
.house-hero__title {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.house-hero__title small,
|
|
.house-hero__title strong {
|
|
display: block;
|
|
}
|
|
.house-hero__title small {
|
|
color: var(--house-muted);
|
|
font-size: 9px;
|
|
font-weight: 750;
|
|
text-transform: uppercase;
|
|
}
|
|
.house-hero__title strong {
|
|
font-size: 34px;
|
|
line-height: 1;
|
|
}
|
|
.house-hero__title i {
|
|
width: 51px;
|
|
height: 51px;
|
|
border-radius: 18px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(
|
|
145deg,
|
|
var(--house-accent-soft),
|
|
var(--house-accent)
|
|
);
|
|
color: #fff;
|
|
box-shadow: 0 9px 18px #c64c2535;
|
|
}
|
|
.house-summary {
|
|
margin-top: 15px;
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 7px;
|
|
}
|
|
.house-summary span {
|
|
padding: 8px;
|
|
border-radius: 13px;
|
|
background: #fff8;
|
|
color: var(--house-muted);
|
|
font-size: 8px;
|
|
}
|
|
:global(.phone-app.dark .house-summary span) {
|
|
background: #ffffff0c;
|
|
}
|
|
.house-summary b {
|
|
display: block;
|
|
color: var(--house-text);
|
|
font-size: 16px;
|
|
}
|
|
.house-properties h2 {
|
|
margin: 19px 3px 9px;
|
|
font-size: 16px;
|
|
}
|
|
.house-property {
|
|
width: 100%;
|
|
margin-bottom: 8px;
|
|
padding: 11px;
|
|
border: 1px solid #ffffff80;
|
|
border-radius: 19px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: var(--house-panel);
|
|
color: var(--house-text);
|
|
text-align: left;
|
|
box-shadow: 0 8px 20px #24252b12;
|
|
cursor: pointer;
|
|
transition: opacity 100ms ease;
|
|
}
|
|
:global(.phone-app.dark .house-property) {
|
|
border-color: #ffffff12;
|
|
}
|
|
.house-property__icon {
|
|
width: 47px;
|
|
height: 47px;
|
|
border-radius: 16px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(145deg, #ffb45d, #f26d35);
|
|
color: #fff;
|
|
}
|
|
.house-property__content {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
.house-property__content > strong {
|
|
display: block;
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.house-property__content > small {
|
|
margin: 2px 0 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
color: var(--house-muted);
|
|
font-size: 8px;
|
|
}
|
|
.house-property__chips {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.house-property__chips i {
|
|
padding: 3px 6px;
|
|
border-radius: 99px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
background: #e8f8ed;
|
|
color: #267c42;
|
|
font-size: 7px;
|
|
font-style: normal;
|
|
font-weight: 750;
|
|
}
|
|
.house-property__chips i.is-locked {
|
|
background: #fff0e7;
|
|
color: #c8522b;
|
|
}
|
|
:global(.phone-app.dark .house-property__chips i) {
|
|
background: #2a4b35;
|
|
color: #8ce1a7;
|
|
}
|
|
:global(.phone-app.dark .house-property__chips i.is-locked) {
|
|
background: #5a3023;
|
|
color: #ffb392;
|
|
}
|
|
.house-empty,
|
|
.house-keys__empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
text-align: center;
|
|
color: var(--house-muted);
|
|
}
|
|
.house-empty {
|
|
margin-top: 18px;
|
|
padding: 35px 20px;
|
|
}
|
|
.house-empty strong,
|
|
.house-keys__empty strong {
|
|
margin-top: 7px;
|
|
color: var(--house-text);
|
|
}
|
|
.house-empty p,
|
|
.house-keys__empty p {
|
|
margin: 4px 0;
|
|
font-size: 9px;
|
|
}
|
|
.house-provider {
|
|
margin: 16px 0 0;
|
|
color: var(--house-muted);
|
|
font-size: 8px;
|
|
text-align: center;
|
|
}
|
|
:global(.house-detail-sheet),
|
|
:global(.house-candidates-sheet) {
|
|
--sky-sheet-background: var(--house-bg);
|
|
}
|
|
:global(.house-detail-sheet .sky-sheet__panel),
|
|
:global(.house-candidates-sheet .sky-sheet__panel) {
|
|
height: 88%;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
border-radius: 28px 28px 0 0;
|
|
}
|
|
.house-detail {
|
|
position: relative;
|
|
min-height: calc(100% - 32px);
|
|
padding: 12px 14px 40px;
|
|
text-align: center;
|
|
}
|
|
.house-detail__mark {
|
|
width: 83px;
|
|
height: 83px;
|
|
margin: 45px auto 8px;
|
|
border-radius: 27px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(145deg, #ffb75f, #ef6b34);
|
|
color: #fff;
|
|
box-shadow: 0 15px 30px #b5452538;
|
|
}
|
|
.house-detail > small {
|
|
color: var(--house-accent);
|
|
font-size: 8px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
.house-detail h2 {
|
|
margin: 2px 0 7px;
|
|
font-size: 21px;
|
|
}
|
|
.house-detail__status {
|
|
width: max-content;
|
|
margin: 0 auto 13px;
|
|
padding: 5px 9px;
|
|
border-radius: 99px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
background: #e8f8ed;
|
|
color: #267c42;
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
}
|
|
.house-detail__status.is-locked {
|
|
background: #fff0e7;
|
|
color: #c8522b;
|
|
}
|
|
.house-lock-button {
|
|
--sky-app-accent: var(--house-accent);
|
|
width: 100%;
|
|
margin-bottom: 18px;
|
|
gap: 6px;
|
|
}
|
|
.house-detail h3 {
|
|
margin: 0 0 8px;
|
|
text-align: left;
|
|
font-size: 13px;
|
|
}
|
|
.house-actions {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 7px;
|
|
}
|
|
.house-actions button {
|
|
min-height: 76px;
|
|
border: 1px solid #ffffff6b;
|
|
border-radius: 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
padding: 12px;
|
|
background: var(--house-panel);
|
|
color: var(--house-text);
|
|
font-size: 13px;
|
|
line-height: 1.2;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
transition: opacity 100ms ease;
|
|
}
|
|
.house-actions svg {
|
|
color: var(--house-accent);
|
|
}
|
|
.house-actions button:disabled {
|
|
cursor: default;
|
|
opacity: 0.42;
|
|
}
|
|
.house-property:active,
|
|
.house-actions button:not(:disabled):active {
|
|
opacity: 0.78;
|
|
}
|
|
.house-facts {
|
|
margin: 14px 0 !important;
|
|
text-align: left;
|
|
}
|
|
.house-keys {
|
|
text-align: left;
|
|
}
|
|
.house-keys header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.house-keys header small,
|
|
.house-keys header strong {
|
|
display: block;
|
|
}
|
|
.house-keys header small {
|
|
color: var(--house-muted);
|
|
font-size: 10px;
|
|
line-height: 1.2;
|
|
}
|
|
.house-keys header strong {
|
|
font-size: 17px;
|
|
line-height: 1.2;
|
|
}
|
|
.house-keys__empty {
|
|
padding: 18px;
|
|
}
|
|
.house-candidates {
|
|
min-height: calc(100% - 32px);
|
|
padding: 12px 14px 35px;
|
|
text-align: center;
|
|
}
|
|
.house-candidates > svg {
|
|
display: block;
|
|
margin: 0 auto;
|
|
color: var(--house-accent);
|
|
}
|
|
.house-candidates h2 {
|
|
margin: 6px 0 3px;
|
|
}
|
|
.house-candidates > p {
|
|
margin: 0 auto 12px;
|
|
max-width: 250px;
|
|
color: var(--house-muted);
|
|
font-size: 9px;
|
|
}
|
|
.house-candidates__loading {
|
|
padding: 35px;
|
|
}
|
|
.house-revoke-copy {
|
|
padding: 0 16px;
|
|
color: var(--house-muted);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* Native, task-first House layout. These overrides intentionally replace the
|
|
old dashboard treatment while preserving the existing detail interactions. */
|
|
.house-page {
|
|
--house-bg: #f2f2f7;
|
|
--house-panel: #ffffff;
|
|
--house-text: #161619;
|
|
--house-muted: #6f6f78;
|
|
background: var(--house-bg);
|
|
}
|
|
:global(.phone-app.dark .house-page) {
|
|
--house-bg: #000000;
|
|
--house-panel: #1c1c1e;
|
|
--house-text: #fafafa;
|
|
--house-muted: #a1a1aa;
|
|
background: var(--house-bg);
|
|
}
|
|
.house-navbar {
|
|
--sky-navbar-glass: var(--house-bg);
|
|
}
|
|
.house-scroll {
|
|
padding: 0 0 46px;
|
|
overscroll-behavior: contain;
|
|
}
|
|
.house-state {
|
|
padding: 42px 28px 86px;
|
|
}
|
|
.house-state__icon {
|
|
width: 62px;
|
|
height: 62px;
|
|
margin-bottom: 16px;
|
|
border-radius: 19px;
|
|
box-shadow: 0 8px 24px #00000014;
|
|
}
|
|
.house-state strong {
|
|
font-size: 20px;
|
|
line-height: 1.2;
|
|
}
|
|
.house-state p {
|
|
margin: 8px 0 20px;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.house-properties {
|
|
padding-top: 4px;
|
|
}
|
|
.house-overview {
|
|
margin: 0 16px 16px;
|
|
padding: 12px 6px;
|
|
border-radius: 18px;
|
|
display: flex;
|
|
background: var(--house-panel);
|
|
}
|
|
.house-overview span {
|
|
min-width: 0;
|
|
flex: 1;
|
|
padding: 0 8px;
|
|
color: var(--house-muted);
|
|
font-size: 11px;
|
|
line-height: 1.25;
|
|
}
|
|
.house-overview span + span {
|
|
border-left: 1px solid #8e8e9338;
|
|
}
|
|
.house-overview b {
|
|
display: block;
|
|
margin-bottom: 2px;
|
|
color: var(--house-text);
|
|
font-size: 17px;
|
|
line-height: 1;
|
|
}
|
|
.house-property-list {
|
|
margin: 0 16px !important;
|
|
}
|
|
.house-property__icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 12px;
|
|
background: var(--house-accent);
|
|
}
|
|
.house-property__status {
|
|
padding: 4px 7px;
|
|
border-radius: 999px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: #e8f8ed;
|
|
color: #267c42;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
}
|
|
.house-property__status.is-locked {
|
|
background: #fff0e7;
|
|
color: #c8522b;
|
|
}
|
|
:global(.phone-app.dark .house-property__status) {
|
|
background: #2a4b35;
|
|
color: #8ce1a7;
|
|
}
|
|
:global(.phone-app.dark .house-property__status.is-locked) {
|
|
background: #5a3023;
|
|
color: #ffb392;
|
|
}
|
|
.house-empty {
|
|
min-height: calc(100% - 16px);
|
|
margin: 0;
|
|
padding: 34px 31px 90px;
|
|
justify-content: center;
|
|
}
|
|
.house-empty__icon {
|
|
width: 68px;
|
|
height: 68px;
|
|
margin-bottom: 16px;
|
|
border-radius: 21px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--house-accent);
|
|
color: #fff;
|
|
box-shadow: 0 10px 30px #f47a383d;
|
|
}
|
|
.house-empty > small {
|
|
color: var(--house-accent);
|
|
font-size: 11px;
|
|
font-weight: 750;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}
|
|
.house-empty h1 {
|
|
margin: 5px 0 0;
|
|
color: var(--house-text);
|
|
font-size: 22px;
|
|
line-height: 1.2;
|
|
}
|
|
.house-empty p,
|
|
.house-keys__empty p {
|
|
margin: 8px 0 20px;
|
|
max-width: 260px;
|
|
font-size: 13px;
|
|
line-height: 1.42;
|
|
}
|
|
.house-detail {
|
|
min-height: calc(100% - 32px);
|
|
padding: 12px 0 42px;
|
|
}
|
|
.house-detail__mark {
|
|
width: 58px;
|
|
height: 58px;
|
|
margin: 0 auto 10px;
|
|
border-radius: 18px;
|
|
background: var(--house-accent);
|
|
}
|
|
.house-detail > small {
|
|
font-size: 11px;
|
|
font-weight: 750;
|
|
}
|
|
.house-detail h2 {
|
|
margin: 3px 32px 8px;
|
|
font-size: 22px;
|
|
line-height: 1.2;
|
|
}
|
|
.house-detail__status {
|
|
font-size: 11px;
|
|
}
|
|
.house-lock-button {
|
|
width: calc(100% - 32px);
|
|
margin: 0 16px 20px;
|
|
}
|
|
.house-detail :deep(.sky-block-title) {
|
|
margin-top: 18px;
|
|
margin-bottom: 7px;
|
|
padding: 0 20px;
|
|
color: var(--house-muted);
|
|
text-align: left;
|
|
}
|
|
.house-action-list,
|
|
.house-facts {
|
|
margin: 0 16px !important;
|
|
text-align: left;
|
|
}
|
|
.house-action-list svg {
|
|
color: var(--house-accent);
|
|
}
|
|
.house-keys {
|
|
padding: 0 16px;
|
|
}
|
|
.house-key-list {
|
|
--sky-list-outer-left: 0px;
|
|
--sky-list-outer-right: 0px;
|
|
width: 100%;
|
|
margin-top: 0;
|
|
margin-bottom: 0;
|
|
}
|
|
.house-keys header {
|
|
margin: 20px 4px 8px;
|
|
}
|
|
.house-keys header small {
|
|
font-size: 11px;
|
|
}
|
|
.house-keys header strong {
|
|
font-size: 16px;
|
|
}
|
|
.house-candidates {
|
|
min-height: calc(100% - 32px);
|
|
padding: 12px 14px 35px;
|
|
}
|
|
.house-candidates h2 {
|
|
margin: 8px 0 5px;
|
|
}
|
|
.house-candidates > p {
|
|
margin: 0 auto 16px;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.house-revoke-copy {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
</style>
|