mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - merge latest dev into weather app
Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
@@ -6,6 +6,7 @@ const viewSource = readFileSync(
|
||||
new URL('./SpringboardView.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const appIconSource = readFileSync(
|
||||
new URL('../components/AppIcon.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -19,6 +20,26 @@ const mainCss = readFileSync(
|
||||
'utf8',
|
||||
)
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('replaces the home status row with the edit controls', () => {
|
||||
expect(viewSource).toContain("emit('editModeChange', editing)")
|
||||
expect(viewSource).toContain("emit('editModeChange', false)")
|
||||
expect(appSource).toContain(
|
||||
'@edit-mode-change="springboardEditing = $event"',
|
||||
)
|
||||
expect(appSource).toMatch(
|
||||
/v-if="\s*!isLocked && !\(isHomeRoute && springboardEditing\)\s*"/,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard-edit-add\s*\{[^}]*top:\s*14px;[^}]*left:\s*28px;[^}]*width:\s*58px;[^}]*height:\s*30px;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard-edit-done\s*\{[^}]*top:\s*14px;[^}]*right:\s*28px;[^}]*width:\s*58px;[^}]*height:\s*30px;/s,
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'<Check :size="20" :stroke-width="3" aria-hidden="true" />',
|
||||
)
|
||||
})
|
||||
|
||||
it('allows page swipes to start on home apps and widget surfaces', () => {
|
||||
expect(viewSource).toContain(
|
||||
'.springboard-page--apps .app-icon-button, .home-widget',
|
||||
@@ -29,6 +50,15 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps blank-surface page swipes available while editing', () => {
|
||||
expect(viewSource).not.toMatch(
|
||||
/if \(editMode\.value\) return\s+dragging\.value/,
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'if (editMode.value || pageSwipeSurface) return',
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses app and folder activation after pointer movement', () => {
|
||||
expect(appIconSource).toContain('springboardSwipeIntent')
|
||||
expect(folderIconSource).toContain('springboardSwipeIntent')
|
||||
@@ -36,42 +66,40 @@ describe('Springboard page swipe contract', () => {
|
||||
expect(folderIconSource).toContain('suppressClick.value = true')
|
||||
})
|
||||
|
||||
it('separates animated page compensation from immediate pointer movement', () => {
|
||||
it('keeps app and folder drags alive independently of element capture', () => {
|
||||
expect(appIconSource).toContain('bindPointerDragSession(window, pointerId')
|
||||
expect(folderIconSource).toContain(
|
||||
'bindPointerDragSession(window, pointerId',
|
||||
)
|
||||
expect(appIconSource).not.toContain('@lostpointercapture')
|
||||
expect(folderIconSource).not.toContain('@lostpointercapture')
|
||||
})
|
||||
|
||||
it('converts pointer movement from viewport pixels using measured page geometry', () => {
|
||||
expect(appIconSource).toContain('springboardPageDragCompensation')
|
||||
expect(folderIconSource).toContain('springboardPageDragCompensation')
|
||||
expect(appIconSource).toContain('readSpringboardDragMetrics')
|
||||
expect(folderIconSource).toContain('readSpringboardDragMetrics')
|
||||
expect(appIconSource).toContain(':style="dragPointerStyle"')
|
||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||
})
|
||||
|
||||
it('keeps a phone-wide drag ghost outside the moving page track', () => {
|
||||
const trackStart = viewSource.indexOf('class="springboard-track"')
|
||||
const dragLayerStart = viewSource.indexOf('ref="homeDragLayer"')
|
||||
|
||||
expect(trackStart).toBeGreaterThan(-1)
|
||||
expect(dragLayerStart).toBeGreaterThan(trackStart)
|
||||
expect(viewSource.slice(trackStart, dragLayerStart)).toContain('</div>')
|
||||
expect(viewSource).toContain('source.cloneNode(true)')
|
||||
expect(viewSource).toContain("ghost.classList.add('home-drag-ghost')")
|
||||
expect(viewSource).toContain('springboardViewportToLocal')
|
||||
expect(viewSource).toContain('springboardViewportDeltaToLocal')
|
||||
expect(viewSource).toContain('event.target instanceof Element')
|
||||
expect(viewSource).toContain('dropGhost?.getBoundingClientRect()')
|
||||
expect(viewSource.match(/:external-drag-visual/g)).toHaveLength(4)
|
||||
expect(viewSource.match(/startHomeDrag\([^\n]+\$event\)/g)).toHaveLength(4)
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain("'app-icon-item--drag-source'")
|
||||
expect(folderIconSource).toContain("'app-icon-item--drag-source'")
|
||||
expect(mainCss).toMatch(
|
||||
/\.home-drag-layer\s*\{[^}]*z-index:\s*70;[^}]*pointer-events:\s*none;/s,
|
||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
||||
expect(viewSource).not.toContain(':external-drag-visual')
|
||||
expect(viewSource).toContain(
|
||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
||||
)
|
||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(appIconSource).not.toContain('externalDragVisual')
|
||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
||||
expect(mainCss).not.toContain('.home-drag-layer')
|
||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(
|
||||
/\.home-drag-ghost\s*\{[^}]*position:\s*absolute;[^}]*will-change:\s*transform;/s,
|
||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.app-icon-item--drag-source\s*\{[^}]*opacity:\s*0;/s,
|
||||
)
|
||||
expect(mainCss).not.toContain('.springboard--home-dragging')
|
||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||
@@ -84,8 +112,11 @@ describe('Springboard page swipe contract', () => {
|
||||
expect(viewSource).toContain('nearestGridDropTarget')
|
||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||
})
|
||||
|
||||
it('resolves occupied dock slots from the dock bounds rather than the event target', () => {
|
||||
@@ -102,4 +133,15 @@ describe('Springboard page swipe contract', () => {
|
||||
expect(viewSource).toContain('resolveSpringboardHomeEdgeTurn')
|
||||
expect(viewSource).toContain('onBeforeUnmount')
|
||||
})
|
||||
|
||||
it('materializes page-local app positions before widget layout changes', () => {
|
||||
expect(viewSource).toContain('function applyWidgetLayout')
|
||||
expect(viewSource).toContain('appStore.applyWidgetGridCapacities')
|
||||
expect(viewSource).toContain('homeGridCapacitiesFor(widgets.layout)')
|
||||
expect(viewSource).toContain('homeGridCapacitiesFor(nextLayout)')
|
||||
expect(viewSource).toContain('previewWidgetAdd')
|
||||
expect(viewSource).toContain('previewWidgetMove')
|
||||
expect(viewSource).toContain('previewWidgetRemove')
|
||||
expect(viewSource).toContain('previewWidgetResize')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
|
||||
import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
|
||||
import { Check, Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
|
||||
import { kButton, kGlass } from 'konsta/vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -15,7 +15,19 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
||||
import type {
|
||||
WidgetKind,
|
||||
WidgetLayout,
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
SkyActionButton,
|
||||
SkyActionGroup,
|
||||
SkyActionSheet,
|
||||
SkyActionsLabel,
|
||||
SkyProvider,
|
||||
} from '@/ui'
|
||||
import {
|
||||
deleteHomePage as previewHomePageDelete,
|
||||
getHomeFolder,
|
||||
@@ -32,15 +44,18 @@ import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||
import {
|
||||
maximumRenderedWidgetPage,
|
||||
nearestSpringboardRectIndex,
|
||||
resolveSpringboardEdgeTurn,
|
||||
resolveSpringboardHomeEdgeTurn,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportDeltaToLocal,
|
||||
springboardViewportToLocal,
|
||||
} from '@/utils/springboardDrag'
|
||||
import {
|
||||
addWidget as previewWidgetAdd,
|
||||
deleteWidgetPage as previewWidgetPageDelete,
|
||||
moveWidget as previewWidgetMove,
|
||||
removeWidget as previewWidgetRemove,
|
||||
resizeWidget as previewWidgetResize,
|
||||
WIDGET_GRID_COLUMNS,
|
||||
widgetKeyboardTarget,
|
||||
widgetOccupiedCells,
|
||||
@@ -54,6 +69,10 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
|
||||
'shopping',
|
||||
]
|
||||
const HOME_FOLDER_HOVER_DURATION = 900
|
||||
const HOME_EDGE_PAGE_TURN_DELAY = 420
|
||||
const emit = defineEmits<{
|
||||
editModeChange: [editing: boolean]
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const widgets = useWidgetsStore()
|
||||
@@ -87,10 +106,9 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
const openedFolderId = ref<string | null>(null)
|
||||
const folderDraggingOutside = ref(false)
|
||||
const renameFolderOnOpenId = ref<string | null>(null)
|
||||
const folderHoverTarget = ref('')
|
||||
let pointerStart = 0
|
||||
@@ -107,8 +125,6 @@ let edgePageDirection = 0
|
||||
let edgePageLocked = false
|
||||
let folderHoverTimer: number | undefined
|
||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||
let homeDragGhost: HTMLElement | null = null
|
||||
let homeDragGrip: { x: number; y: number } | null = null
|
||||
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||
@@ -121,6 +137,17 @@ const hiddenApps = computed(() =>
|
||||
appStore.homeLayout.hidden.includes(app.id),
|
||||
),
|
||||
)
|
||||
|
||||
function homeGridCapacitiesFor(layout: WidgetLayout): number[] {
|
||||
return Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
|
||||
Math.max(
|
||||
0,
|
||||
HOME_GRID_PAGE_SIZE -
|
||||
widgetOccupiedCells(layout.instances, pageIndex + 1).size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const previewWidgetLayout = computed(() => {
|
||||
const preview = widgetDragPreview.value
|
||||
return preview
|
||||
@@ -133,24 +160,13 @@ const previewWidgetLayout = computed(() => {
|
||||
)
|
||||
: widgets.layout
|
||||
})
|
||||
const homeGridCapacities = computed(() =>
|
||||
Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
|
||||
Math.max(
|
||||
0,
|
||||
HOME_GRID_PAGE_SIZE -
|
||||
widgetOccupiedCells(widgets.layout.instances, pageIndex + 1).size,
|
||||
),
|
||||
),
|
||||
)
|
||||
const homeGridCapacities = computed(() => homeGridCapacitiesFor(widgets.layout))
|
||||
const appPages = computed(() => {
|
||||
const maxWidgetPage = maximumRenderedWidgetPage(
|
||||
widgets.layout.instances.map((instance) => instance.page),
|
||||
previewWidgetLayout.value.instances.map((instance) => instance.page),
|
||||
)
|
||||
const persistedHomePages = Math.max(
|
||||
1,
|
||||
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
|
||||
)
|
||||
const persistedHomePages = appStore.homeLayout.pageCount
|
||||
const lastHomePage = Math.max(
|
||||
maxWidgetPage,
|
||||
persistedHomePages,
|
||||
@@ -173,6 +189,7 @@ const appPages = computed(() => {
|
||||
appStore.homeLayout.grid,
|
||||
occupiedByPage,
|
||||
lastHomePage,
|
||||
(item) => isHomeFolder(item) || installedAppsById.value.has(item),
|
||||
).map((page) => ({
|
||||
...page,
|
||||
cells: page.cells.map((cell, cellIndex) => {
|
||||
@@ -209,13 +226,10 @@ 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,
|
||||
() => appStore.homeLayout.pageCount < 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 persistedHomePageCount = computed(() => appStore.homeLayout.pageCount)
|
||||
const canDeleteCurrentPage = computed(() => {
|
||||
if (phone.currentPage < 1 || persistedHomePageCount.value <= 1) return false
|
||||
const remainingPages = persistedHomePageCount.value - 1
|
||||
@@ -244,6 +258,9 @@ const openedFolder = computed(() =>
|
||||
? getHomeFolder(appStore.homeLayout, openedFolderId.value)
|
||||
: null,
|
||||
)
|
||||
const folderOverlayVisible = computed(
|
||||
() => openedFolder.value !== null && !folderDraggingOutside.value,
|
||||
)
|
||||
const openedFolderApps = computed(() =>
|
||||
openedFolder.value
|
||||
? openedFolder.value.apps.flatMap((appId, index) => {
|
||||
@@ -364,13 +381,12 @@ function onPointerDown(event: PointerEvent): void {
|
||||
pointerStartedAt = Date.now()
|
||||
pointerPageStart = phone.currentPage
|
||||
blankTapCandidate = editMode.value
|
||||
if (editMode.value) return
|
||||
dragging.value = true
|
||||
if (!pageSwipeSurface) {
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
clearBackgroundHold()
|
||||
if (pageSwipeSurface) return
|
||||
if (editMode.value || pageSwipeSurface) return
|
||||
backgroundHoldTimer = window.setTimeout(() => {
|
||||
enterEditMode()
|
||||
backgroundHoldTimer = undefined
|
||||
@@ -457,22 +473,22 @@ function updateWidgetDragPreview(event: {
|
||||
`[data-widget-page="${page}"]`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return
|
||||
if (!grid || !pageElement) 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 scaleX =
|
||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||
const scaleY =
|
||||
grid.offsetHeight > 0 ? renderedGridBounds.height / grid.offsetHeight : 1
|
||||
const columnGap = (Number.parseFloat(gridStyle.columnGap) || 0) * scaleX
|
||||
const rowGap = (Number.parseFloat(gridStyle.rowGap) || 0) * scaleY
|
||||
const columnWidth =
|
||||
(renderedGridBounds.width - columnGap * (WIDGET_GRID_COLUMNS - 1)) /
|
||||
WIDGET_GRID_COLUMNS
|
||||
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows)
|
||||
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows) * scaleY
|
||||
const column = Math.round(
|
||||
(event.clientX - grip.x - gridLeft) / (columnWidth + columnGap),
|
||||
(event.clientX - grip.x - renderedGridBounds.left) /
|
||||
(columnWidth + columnGap),
|
||||
)
|
||||
const row = Math.round(
|
||||
(event.clientY - grip.y - renderedGridBounds.top) / (rowHeight + rowGap),
|
||||
@@ -504,7 +520,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
||||
if (keepCurrentPage) return
|
||||
const lastPage = Math.max(
|
||||
1,
|
||||
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
|
||||
appStore.homeLayout.pageCount,
|
||||
...widgets.layout.instances.map((instance) => instance.page),
|
||||
)
|
||||
if (phone.currentPage > lastPage) changePage(lastPage)
|
||||
@@ -527,7 +543,7 @@ function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||
}
|
||||
|
||||
function queueEdgePageTurn(
|
||||
event: PointerEvent,
|
||||
event: { clientX: number; clientY: number },
|
||||
dragType: 'app' | 'widget',
|
||||
): void {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
@@ -581,8 +597,11 @@ function queueEdgePageTurn(
|
||||
}
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = direction
|
||||
edgePageLocked = true
|
||||
}, 520)
|
||||
edgePageLocked = dragType === 'widget'
|
||||
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
|
||||
queueEdgePageTurn(lastHomePointer, 'app')
|
||||
}
|
||||
}, HOME_EDGE_PAGE_TURN_DELAY)
|
||||
}
|
||||
|
||||
function queueWidgetDragPreview(event: PointerEvent): void {
|
||||
@@ -610,6 +629,19 @@ function clearWidgetDragPreview(): void {
|
||||
widgetDragPreview.value = null
|
||||
}
|
||||
|
||||
function applyWidgetLayout(nextLayout: WidgetLayout): boolean {
|
||||
if (nextLayout === widgets.layout) return true
|
||||
if (
|
||||
!appStore.applyWidgetGridCapacities(
|
||||
homeGridCapacitiesFor(widgets.layout),
|
||||
homeGridCapacitiesFor(nextLayout),
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return widgets.applyLayout(nextLayout)
|
||||
}
|
||||
|
||||
function finishWidgetDrag(event: PointerEvent): void {
|
||||
const id = draggingWidgetId.value
|
||||
if (!id) return
|
||||
@@ -617,7 +649,15 @@ function finishWidgetDrag(event: PointerEvent): void {
|
||||
updateWidgetDragPreview(lastWidgetPointer)
|
||||
const preview = widgetDragPreview.value
|
||||
if (preview?.id === id) {
|
||||
widgets.move(id, preview.page, preview.column, preview.row)
|
||||
applyWidgetLayout(
|
||||
previewWidgetMove(
|
||||
widgets.layout,
|
||||
id,
|
||||
preview.page,
|
||||
preview.column,
|
||||
preview.row,
|
||||
),
|
||||
)
|
||||
}
|
||||
draggingWidgetId.value = null
|
||||
clearWidgetDragPreview()
|
||||
@@ -633,18 +673,33 @@ function reorderWidget(id: string, direction: ReorderDirection): void {
|
||||
if (!instance) return
|
||||
const target = widgetKeyboardTarget(instance, direction)
|
||||
if (!target) return
|
||||
widgets.move(id, instance.page, target.column, target.row)
|
||||
applyWidgetLayout(
|
||||
previewWidgetMove(
|
||||
widgets.layout,
|
||||
id,
|
||||
instance.page,
|
||||
target.column,
|
||||
target.row,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function removeWidget(id: string): void {
|
||||
widgets.remove(id)
|
||||
if (!applyWidgetLayout(previewWidgetRemove(widgets.layout, id))) return
|
||||
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)
|
||||
const previousIds = new Set(
|
||||
widgets.layout.instances.map((instance) => instance.id),
|
||||
)
|
||||
const nextLayout = previewWidgetAdd(widgets.layout, kind, size, targetPage)
|
||||
if (nextLayout === widgets.layout || !applyWidgetLayout(nextLayout)) return
|
||||
const addedId = widgets.layout.instances.find(
|
||||
(instance) => !previousIds.has(instance.id),
|
||||
)?.id
|
||||
if (!addedId) return
|
||||
widgetPickerOpened.value = false
|
||||
editMode.value = true
|
||||
@@ -666,7 +721,8 @@ async function saveWidgetConfig(
|
||||
): Promise<void> {
|
||||
if (!widgetConfigId.value) return
|
||||
const id = widgetConfigId.value
|
||||
widgets.resize(id, size)
|
||||
const resized = previewWidgetResize(widgets.layout, id, size)
|
||||
if (!applyWidgetLayout(resized)) return
|
||||
widgets.updateSettings(id, settings)
|
||||
widgetConfigId.value = null
|
||||
editMode.value = true
|
||||
@@ -677,122 +733,10 @@ async function saveWidgetConfig(
|
||||
if (configured) changePage(configured.page)
|
||||
}
|
||||
|
||||
function homeDragLocalPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): {
|
||||
x: number
|
||||
y: number
|
||||
} | null {
|
||||
const layer = homeDragLayer.value
|
||||
if (!layer) return null
|
||||
const bounds = layer.getBoundingClientRect()
|
||||
return springboardViewportToLocal(
|
||||
clientX,
|
||||
clientY,
|
||||
bounds.left,
|
||||
bounds.top,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
layer.offsetWidth,
|
||||
layer.offsetHeight,
|
||||
)
|
||||
}
|
||||
|
||||
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||
homeDragGhost?.remove()
|
||||
homeDragGhost = null
|
||||
homeDragGrip = null
|
||||
homeDragVisualActive.value = false
|
||||
}
|
||||
|
||||
function updateHomeDragGhost(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}): void {
|
||||
const point = homeDragLocalPoint(event.clientX, event.clientY)
|
||||
if (!homeDragGhost || !homeDragGrip || !point) return
|
||||
homeDragGhost.style.transform = `translate3d(${point.x - homeDragGrip.x}px, ${point.y - homeDragGrip.y}px, 0)`
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null
|
||||
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||
const layer = homeDragLayer.value
|
||||
if (!source || !layer) return
|
||||
|
||||
const layerBounds = layer.getBoundingClientRect()
|
||||
const sourceBounds = source.getBoundingClientRect()
|
||||
const sourceOrigin = springboardViewportToLocal(
|
||||
sourceBounds.left,
|
||||
sourceBounds.top,
|
||||
layerBounds.left,
|
||||
layerBounds.top,
|
||||
layerBounds.width,
|
||||
layerBounds.height,
|
||||
layer.offsetWidth,
|
||||
layer.offsetHeight,
|
||||
)
|
||||
const pointer = homeDragLocalPoint(event.clientX, event.clientY)
|
||||
if (!pointer) return
|
||||
|
||||
const ghost = source.cloneNode(true) as HTMLElement
|
||||
ghost.classList.remove(
|
||||
'app-icon-item--dragging',
|
||||
'app-icon-item--editing',
|
||||
'app-icon-item--drag-source',
|
||||
'app-icon-item--drop-settling',
|
||||
'home-folder-item--dragging',
|
||||
'home-item--folder-hover',
|
||||
)
|
||||
ghost.classList.add('home-drag-ghost')
|
||||
ghost.removeAttribute('style')
|
||||
ghost.setAttribute('aria-hidden', 'true')
|
||||
ghost.querySelector('.app-icon-remove')?.remove()
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
'a, button, input, select, textarea, [tabindex]',
|
||||
)) {
|
||||
control.tabIndex = -1
|
||||
}
|
||||
|
||||
const localScaleX =
|
||||
layerBounds.width > 0 ? layer.offsetWidth / layerBounds.width : 1
|
||||
const localScaleY =
|
||||
layerBounds.height > 0 ? layer.offsetHeight / layerBounds.height : 1
|
||||
ghost.style.width = `${sourceBounds.width * localScaleX}px`
|
||||
ghost.style.height = `${sourceBounds.height * localScaleY}px`
|
||||
homeDragGhost = ghost
|
||||
homeDragGrip = {
|
||||
x: pointer.x - sourceOrigin.x,
|
||||
y: pointer.y - sourceOrigin.y,
|
||||
}
|
||||
layer.appendChild(ghost)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
function startHomeDrag(area: HomeArea, index: number): void {
|
||||
clearTemporaryHomePage()
|
||||
lastHomePointer = null
|
||||
draggingHomeApp.value = { area, index }
|
||||
createHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function clearFolderHover(): void {
|
||||
@@ -878,7 +822,6 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
if (folderId) {
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
@@ -889,7 +832,6 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
function moveHomeDrag(event: PointerEvent): void {
|
||||
if (!draggingHomeApp.value) return
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (resolveHomeEdgeTurn(event)) {
|
||||
clearFolderHover()
|
||||
queueEdgePageTurn(event, 'app')
|
||||
@@ -982,35 +924,25 @@ function nearestGridDropTarget(event: {
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
if (!grid || !pageElement) return null
|
||||
const pageBounds = pageElement.getBoundingClientRect()
|
||||
const springboardBounds = springboard.getBoundingClientRect()
|
||||
if (
|
||||
event.clientX < springboardBounds.left ||
|
||||
event.clientX > springboardBounds.right ||
|
||||
event.clientY < springboardBounds.top ||
|
||||
event.clientY > springboardBounds.bottom
|
||||
event.clientX < pageBounds.left ||
|
||||
event.clientX > pageBounds.right ||
|
||||
event.clientY < pageBounds.top ||
|
||||
event.clientY > pageBounds.bottom
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const slots = Array.from(
|
||||
grid.querySelectorAll<HTMLElement>('[data-home-target-offset]'),
|
||||
)
|
||||
const distanceToSlot = (slot: HTMLElement): number => {
|
||||
const bounds = slot.getBoundingClientRect()
|
||||
const centerX =
|
||||
springboardBounds.left +
|
||||
(bounds.left - pageBounds.left) +
|
||||
bounds.width / 2
|
||||
const centerY =
|
||||
springboardBounds.top + (bounds.top - pageBounds.top) + bounds.height / 2
|
||||
return Math.hypot(event.clientX - centerX, event.clientY - centerY)
|
||||
}
|
||||
const closest = slots.reduce<HTMLElement | null>((nearest, slot) => {
|
||||
if (!nearest) return slot
|
||||
return distanceToSlot(slot) < distanceToSlot(nearest) ? slot : nearest
|
||||
}, null)
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
)
|
||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||
return Number.isInteger(targetOffset) ? { page, targetOffset } : null
|
||||
}
|
||||
@@ -1030,37 +962,29 @@ function nearestDockDropTarget(event: {
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return Array.from(
|
||||
const slots = Array.from(
|
||||
dock.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="dock"][data-home-index]',
|
||||
),
|
||||
).reduce<HTMLElement | null>((nearest, slot) => {
|
||||
if (!nearest) return slot
|
||||
const bounds = slot.getBoundingClientRect()
|
||||
const nearestBounds = nearest.getBoundingClientRect()
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
)
|
||||
const nearestDistance = Math.hypot(
|
||||
event.clientX - (nearestBounds.left + nearestBounds.width / 2),
|
||||
event.clientY - (nearestBounds.top + nearestBounds.height / 2),
|
||||
)
|
||||
return distance < nearestDistance ? slot : nearest
|
||||
}, null)
|
||||
)
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
)
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
|
||||
function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
if (!dragged) return
|
||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = dropGhost?.getBoundingClientRect()
|
||||
const draggedElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
||||
)
|
||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
||||
const dockTarget = nearestDockDropTarget(event)
|
||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||
let dropArea = dragged.area
|
||||
@@ -1098,14 +1022,7 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||
if (moved && draggedItem && dropOrigin) {
|
||||
void animateHomeItemDrop(
|
||||
draggedItem,
|
||||
dropArea,
|
||||
dropIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
} else {
|
||||
clearHomeDragGhost()
|
||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1114,7 +1031,6 @@ function stopHomeDrag(): void {
|
||||
clearFolderHover()
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
@@ -1183,10 +1099,12 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
|
||||
|
||||
function openFolder(folderId: string): void {
|
||||
pageTransitioning.value = false
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
|
||||
function closeFolder(): void {
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
@@ -1208,9 +1126,11 @@ function extractOpenedFolderApp(
|
||||
const folder = openedFolder.value
|
||||
if (!folder) return
|
||||
const appId = folder.apps[sourceIndex]
|
||||
const sourceElement = (event.currentTarget as HTMLElement | null)?.closest(
|
||||
'.app-icon-item',
|
||||
)
|
||||
const sourceElement = document
|
||||
.querySelector<HTMLElement>(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
@@ -1237,15 +1157,20 @@ function extractOpenedFolderApp(
|
||||
)
|
||||
return distance < closestDistance ? candidate : closest
|
||||
}, null)
|
||||
if (!target) return
|
||||
if (!target) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
|
||||
const area = target.dataset.homeArea as HomeArea
|
||||
const targetIndex = Number(target.dataset.homeIndex)
|
||||
if (
|
||||
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
|
||||
) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
closeFolder()
|
||||
if (sourceBounds) {
|
||||
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
|
||||
if (extractedIndex !== null) {
|
||||
@@ -1269,6 +1194,7 @@ watch(isEditablePage, (visible) => {
|
||||
if (!visible) editMode.value = false
|
||||
})
|
||||
watch(editMode, (editing) => {
|
||||
emit('editModeChange', editing)
|
||||
if (editing) return
|
||||
stopHomeDrag()
|
||||
stopWidgetDrag()
|
||||
@@ -1277,6 +1203,7 @@ watch(openedFolder, (folder) => {
|
||||
if (!folder) closeFolder()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
emit('editModeChange', false)
|
||||
clearBackgroundHold()
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
@@ -1285,7 +1212,6 @@ onBeforeUnmount(() => {
|
||||
pendingWidgetPointer = null
|
||||
lastWidgetPointer = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
@@ -1300,7 +1226,8 @@ onBeforeUnmount(() => {
|
||||
{
|
||||
'springboard--dragging': dragging,
|
||||
'springboard--editing': editMode,
|
||||
'springboard--folder-open': openedFolder !== null,
|
||||
'springboard--folder-open': folderOverlayVisible,
|
||||
'springboard--home-dragging': draggingHomeApp !== null,
|
||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||
},
|
||||
]"
|
||||
@@ -1376,7 +1303,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||
@@ -1384,7 +1310,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1399,7 +1325,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="cell.folder"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1408,7 +1333,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(cell.folder.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1559,11 +1484,9 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage && !openedFolder"
|
||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||
component="button"
|
||||
class="springboard-edit-add"
|
||||
type="button"
|
||||
@@ -1576,13 +1499,14 @@ onBeforeUnmount(() => {
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage && !openedFolder"
|
||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||
component="button"
|
||||
class="springboard-edit-done"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.done')"
|
||||
@click="editMode = false"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
<Check :size="20" :stroke-width="3" aria-hidden="true" />
|
||||
</k-glass>
|
||||
</Transition>
|
||||
|
||||
@@ -1606,7 +1530,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="`app-${slot.app.id}`"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1615,7 +1538,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(slot.app.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
@@ -1629,7 +1552,6 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="slot.folder.id"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="slot.folder"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
@@ -1639,7 +1561,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(slot.folder.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
@@ -1701,6 +1623,7 @@ onBeforeUnmount(() => {
|
||||
:folder="openedFolder"
|
||||
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
|
||||
@close="closeFolder"
|
||||
@drag-outside-change="folderDraggingOutside = $event"
|
||||
@edit="enterEditMode"
|
||||
@extract="extractOpenedFolderApp"
|
||||
@move="moveOpenedFolderApp"
|
||||
@@ -1709,50 +1632,56 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="widget-action-sheet">
|
||||
<k-sheet
|
||||
<SkyProvider
|
||||
class="widget-action-provider"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
>
|
||||
<SkyActionSheet
|
||||
class="widget-action-sheet"
|
||||
:opened="widgetActionId !== null"
|
||||
:aria-label="
|
||||
activeWidget
|
||||
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
|
||||
: phone.t('Home.widgets.label')
|
||||
"
|
||||
@backdropclick="widgetActionId = null"
|
||||
@escape="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"
|
||||
content-class="w-full"
|
||||
:title="phone.t('Home.widgetSystem.editWidget')"
|
||||
<SkyActionGroup>
|
||||
<SkyActionsLabel>
|
||||
{{
|
||||
activeWidget
|
||||
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
|
||||
: phone.t('Home.widgets.label')
|
||||
}}
|
||||
</SkyActionsLabel>
|
||||
<SkyActionButton
|
||||
class="widget-action-button"
|
||||
@click="openWidgetConfig"
|
||||
>
|
||||
<template #media><Pencil :size="20" /></template>
|
||||
</k-list-item>
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
class="widget-action-remove"
|
||||
:title="phone.t('Home.widgetSystem.removeWidget')"
|
||||
<Pencil :size="20" />
|
||||
<span>{{ phone.t('Home.widgetSystem.editWidget') }}</span>
|
||||
</SkyActionButton>
|
||||
<SkyActionButton
|
||||
class="widget-action-button widget-action-remove"
|
||||
@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>
|
||||
</div>
|
||||
<Trash2 :size="20" />
|
||||
<span>{{ phone.t('Home.widgetSystem.removeWidget') }}</span>
|
||||
</SkyActionButton>
|
||||
</SkyActionGroup>
|
||||
<SkyActionGroup>
|
||||
<SkyActionButton
|
||||
bold
|
||||
class="widget-action-cancel"
|
||||
@click="widgetActionId = null"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyActionButton>
|
||||
</SkyActionGroup>
|
||||
</SkyActionSheet>
|
||||
</SkyProvider>
|
||||
|
||||
<WidgetPickerSheet
|
||||
:opened="widgetPickerOpened"
|
||||
@@ -1769,44 +1698,54 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-action-sheet {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.widget-action-sheet :deep(.k-sheet) {
|
||||
.widget-action-provider {
|
||||
position: absolute;
|
||||
z-index: 105;
|
||||
padding: 9px 0 30px;
|
||||
border-radius: 25px 25px 0 0;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-sheet-handle {
|
||||
width: 36px;
|
||||
height: 5px;
|
||||
margin: 0 auto 9px;
|
||||
border-radius: 999px;
|
||||
background: rgb(142 142 147 / 45%);
|
||||
.widget-action-sheet {
|
||||
--sky-overlay-layer: 105;
|
||||
}
|
||||
|
||||
.widget-action-sheet h3 {
|
||||
margin: 3px 18px 9px;
|
||||
color: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
.widget-action-sheet :deep(.sky-overlay-backdrop) {
|
||||
background: rgb(0 0 0 / 58%);
|
||||
}
|
||||
|
||||
.widget-action-list {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
.widget-action-sheet :deep(.sky-action-sheet__panel) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.widget-action-remove :deep([class*='title']),
|
||||
.widget-action-remove :deep(svg) {
|
||||
color: #ff3b30 !important;
|
||||
.widget-action-sheet :deep(.sky-action-group) {
|
||||
border-radius: var(--sky-radius-card);
|
||||
background: var(--sky-surface);
|
||||
}
|
||||
|
||||
.widget-action-cancel {
|
||||
width: calc(100% - 32px);
|
||||
margin: 0 16px;
|
||||
.widget-action-sheet :deep(.sky-actions-label) {
|
||||
color: var(--sky-muted);
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-action-sheet :deep(.sky-action-button) {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.widget-action-sheet :deep(.widget-action-button) {
|
||||
justify-content: flex-start;
|
||||
gap: var(--sky-space-3);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widget-action-sheet :deep(.widget-action-button svg) {
|
||||
width: 24px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.widget-action-sheet :deep(.widget-action-remove) {
|
||||
color: var(--sky-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./SpringboardView.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Springboard widget actions Sky UI contract', () => {
|
||||
it('uses the first-party action sheet without legacy Konsta sheet markup', () => {
|
||||
expect(source).not.toContain('<k-sheet')
|
||||
expect(source).not.toContain('<k-list')
|
||||
expect(source).not.toContain('<k-list-item')
|
||||
expect(source).toContain('<SkyProvider')
|
||||
expect(source).toContain('<SkyActionSheet')
|
||||
expect(source).toContain('<SkyActionGroup')
|
||||
expect(source).toContain('<SkyActionsLabel')
|
||||
expect(source).toContain('<SkyActionButton')
|
||||
})
|
||||
|
||||
it('anchors the action sheet to the phone-owned springboard', () => {
|
||||
expect(source).toMatch(
|
||||
/\.widget-action-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.widget-action-sheet\s*\{[^}]*--sky-overlay-layer:\s*105;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ordinary actions white and removal destructive', () => {
|
||||
expect(source).toMatch(
|
||||
/\.widget-action-sheet\s+:deep\(\.sky-action-button\)\s*\{[^}]*color:\s*#fff;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.widget-action-sheet\s+:deep\(\.widget-action-remove\)\s*\{[^}]*color:\s*var\(--sky-danger\);/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,22 @@ const source = readFileSync(
|
||||
new URL('./BankingApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const styles = readFileSync(
|
||||
new URL('../../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appSource = readFileSync(
|
||||
new URL('../../App.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const clientSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const serverSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/banking.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Banking app Sky UI migration', () => {
|
||||
it('uses Sky UI instead of Konsta components', () => {
|
||||
@@ -37,4 +53,60 @@ describe('Banking app Sky UI migration', () => {
|
||||
expect(source).not.toContain('handleSheetKeydown')
|
||||
expect(source).not.toContain('handleWindowKeydown')
|
||||
})
|
||||
|
||||
it('uses a comma-first text amount field without native stepper arrows', () => {
|
||||
expect(source).toContain('class="banking-amount-field"')
|
||||
expect(source).toContain('inputmode="decimal"')
|
||||
expect(source).toContain('type="text"')
|
||||
expect(source).not.toMatch(
|
||||
/input-id="banking-transfer-amount"[\s\S]*?type="number"/,
|
||||
)
|
||||
expect(source).toContain('normalizeBankingAmountInput')
|
||||
expect(source).toContain('parseBankingAmount')
|
||||
})
|
||||
|
||||
it('turns received phone transfers into routed Banking notifications', () => {
|
||||
expect(serverSource).toContain('kind = "transfer_in"')
|
||||
expect(serverSource).toContain('currency = Config.Banking.Currency')
|
||||
expect(clientSource).toContain(
|
||||
'SendNUIMessage({ type = "banking:changed", data = data })',
|
||||
)
|
||||
expect(appSource).toContain("data?.kind === 'transfer_in'")
|
||||
expect(appSource).toContain("appId: 'banking'")
|
||||
expect(appSource).toContain("route: '/apps/banking'")
|
||||
expect(appSource).toContain("phone.t('Apps.banking.notifications.received'")
|
||||
})
|
||||
|
||||
it('resets tab scroll and locks the background behind sheets', () => {
|
||||
expect(source).toContain('async function selectTab(nextTab: BankingTab)')
|
||||
expect(source).toContain('bankingScroll.value.scrollTop = 0')
|
||||
expect(source).toContain(':class="{ \'is-locked\': overlayOpened }"')
|
||||
expect(source).toContain('@click="selectTab(\'activity\')"')
|
||||
expect(source).toContain('@click="selectTab(\'home\')"')
|
||||
expect(styles).toMatch(
|
||||
/\.banking-scroll\s*\{[^}]*overscroll-behavior-y:\s*contain;[^}]*touch-action:\s*pan-y;/s,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.banking-scroll\.is-locked\s*\{[^}]*overflow-y:\s*hidden;[^}]*touch-action:\s*none;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens every transaction in an accessible Sky detail sheet', () => {
|
||||
expect(source).toContain(
|
||||
'const selectedTransaction = ref<BankingTransaction | null>(null)',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'function openTransaction(transaction: BankingTransaction)',
|
||||
)
|
||||
expect(source).toContain('@click="openTransaction(transaction)"')
|
||||
expect(source).toContain('link-component="button"')
|
||||
expect(source).toContain(':opened="overlayOpened"')
|
||||
expect(source).toContain('swipe-to-close')
|
||||
expect(source).toContain('grabber-clickable')
|
||||
expect(source).toContain('@swipeclose="closeAction"')
|
||||
expect(source).not.toContain('banking-modal__close')
|
||||
expect(source).toContain('id="banking-transaction-detail-title"')
|
||||
expect(source).toContain('banking-transaction-detail__amount')
|
||||
expect(source).toContain("phone.t('Apps.banking.transactionReference')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
Landmark,
|
||||
Send,
|
||||
WalletCards,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { usePullToRefresh } from '@/composables/usePullToRefresh'
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -23,6 +23,10 @@ import type {
|
||||
BankingTransactionKind,
|
||||
} from '@/types/banking'
|
||||
import type { PhoneContact } from '@/types/phone'
|
||||
import {
|
||||
normalizeBankingAmountInput,
|
||||
parseBankingAmount,
|
||||
} from '@/utils/bankingAmount'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
|
||||
import {
|
||||
@@ -50,20 +54,30 @@ const banking = useBankingStore()
|
||||
const calls = useCallsStore()
|
||||
const activeTab = ref<BankingTab>('home')
|
||||
const action = ref<BankingAction | null>(null)
|
||||
const selectedTransaction = ref<BankingTransaction | null>(null)
|
||||
const amount = ref('')
|
||||
const target = ref('')
|
||||
const formError = ref('')
|
||||
const bankingScroll = ref<HTMLElement | null>(null)
|
||||
const isRefreshing = ref(false)
|
||||
const pullDistance = ref(0)
|
||||
const cooldownToastOpened = ref(false)
|
||||
const overlayOpened = computed(() =>
|
||||
Boolean(action.value || selectedTransaction.value),
|
||||
)
|
||||
|
||||
const pullThreshold = 56
|
||||
let pullStartY = 0
|
||||
let isPulling = false
|
||||
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let cooldownToastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const {
|
||||
finishPull,
|
||||
movePull,
|
||||
pullDistance,
|
||||
pullThreshold,
|
||||
pullWithWheel,
|
||||
startPull,
|
||||
} = usePullToRefresh({
|
||||
isAtTop: () => (bankingScroll.value?.scrollTop ?? 0) <= 0,
|
||||
refresh: () => banking.load(true),
|
||||
})
|
||||
|
||||
function closeCooldownToast(): void {
|
||||
cooldownToastOpened.value = false
|
||||
if (banking.error === 'reload_cooldown') banking.error = ''
|
||||
@@ -145,11 +159,15 @@ function transactionTitle(transaction: BankingTransaction): string {
|
||||
return phone.t(`Apps.banking.transactions.${transaction.kind}`)
|
||||
}
|
||||
|
||||
async function selectTab(nextTab: BankingTab): Promise<void> {
|
||||
if (activeTab.value === nextTab) return
|
||||
activeTab.value = nextTab
|
||||
await nextTick()
|
||||
if (bankingScroll.value) bankingScroll.value.scrollTop = 0
|
||||
}
|
||||
|
||||
function openAction(nextAction: BankingAction): void {
|
||||
if (banking.cooldownUntil > Date.now()) {
|
||||
banking.error = 'reload_cooldown'
|
||||
return
|
||||
}
|
||||
selectedTransaction.value = null
|
||||
action.value = nextAction
|
||||
amount.value = ''
|
||||
target.value = ''
|
||||
@@ -158,8 +176,14 @@ function openAction(nextAction: BankingAction): void {
|
||||
}
|
||||
|
||||
function closeAction(): void {
|
||||
if (banking.isLoading) return
|
||||
if (action.value && banking.isLoading) return
|
||||
action.value = null
|
||||
selectedTransaction.value = null
|
||||
}
|
||||
|
||||
function openTransaction(transaction: BankingTransaction): void {
|
||||
action.value = null
|
||||
selectedTransaction.value = transaction
|
||||
}
|
||||
|
||||
function updateTarget(event: Event): void {
|
||||
@@ -186,59 +210,14 @@ function updateAmount(event: Event): void {
|
||||
console.error('[banking] Amount input emitted without an input target.')
|
||||
return
|
||||
}
|
||||
amount.value = event.target.value
|
||||
const normalizedAmount = normalizeBankingAmountInput(event.target.value)
|
||||
if (event.target.value !== normalizedAmount) {
|
||||
event.target.value = normalizedAmount
|
||||
}
|
||||
amount.value = normalizedAmount
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (isRefreshing.value) return
|
||||
isRefreshing.value = true
|
||||
pullDistance.value = pullThreshold
|
||||
await banking.load(true)
|
||||
isRefreshing.value = false
|
||||
pullDistance.value = 0
|
||||
}
|
||||
|
||||
function atTop(): boolean {
|
||||
return (bankingScroll.value?.scrollTop ?? 0) <= 0
|
||||
}
|
||||
|
||||
function startPull(event: TouchEvent): void {
|
||||
if (!atTop() || isRefreshing.value) return
|
||||
pullStartY = event.touches[0]?.clientY ?? 0
|
||||
isPulling = true
|
||||
}
|
||||
|
||||
function movePull(event: TouchEvent): void {
|
||||
if (!isPulling || isRefreshing.value) return
|
||||
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
|
||||
if (distance <= 0) {
|
||||
pullDistance.value = 0
|
||||
return
|
||||
}
|
||||
pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45)
|
||||
}
|
||||
|
||||
function finishPull(): void {
|
||||
if (!isPulling && pullDistance.value === 0) return
|
||||
isPulling = false
|
||||
if (pullDistance.value >= pullThreshold) {
|
||||
void refresh()
|
||||
return
|
||||
}
|
||||
pullDistance.value = 0
|
||||
}
|
||||
|
||||
function pullWithWheel(event: WheelEvent): void {
|
||||
if (!atTop() || isRefreshing.value || event.deltaY >= 0) return
|
||||
pullDistance.value = Math.min(
|
||||
pullThreshold + 20,
|
||||
pullDistance.value + Math.abs(event.deltaY) * 0.18,
|
||||
)
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
wheelRefreshTimeout = setTimeout(finishPull, 130)
|
||||
}
|
||||
|
||||
function errorMessage(code: string): string {
|
||||
return phone.t(`Apps.banking.errors.${code}`) ===
|
||||
`Apps.banking.errors.${code}`
|
||||
@@ -248,14 +227,10 @@ function errorMessage(code: string): string {
|
||||
|
||||
async function submitAction(): Promise<void> {
|
||||
if (!action.value) return
|
||||
const parsedAmount = Number(amount.value)
|
||||
const parsedAmount = parseBankingAmount(amount.value)
|
||||
const phoneNumber =
|
||||
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
|
||||
if (
|
||||
!Number.isSafeInteger(parsedAmount) ||
|
||||
parsedAmount <= 0 ||
|
||||
(action.value === 'transfer' && !phoneNumber)
|
||||
) {
|
||||
if (parsedAmount === null || (action.value === 'transfer' && !phoneNumber)) {
|
||||
formError.value = phone.t('Apps.banking.errors.invalid_request')
|
||||
return
|
||||
}
|
||||
@@ -293,7 +268,6 @@ watch(action, async (currentAction) => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
if (cooldownToastTimer) clearTimeout(cooldownToastTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -310,8 +284,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkyNavbar
|
||||
class="banking-navbar"
|
||||
:aria-hidden="Boolean(action)"
|
||||
:inert="Boolean(action)"
|
||||
:aria-hidden="overlayOpened"
|
||||
:inert="overlayOpened"
|
||||
:subtitle="phone.t('Apps.banking.welcome')"
|
||||
:title="banking.overview?.playerName ?? phone.t('Common.loading')"
|
||||
/>
|
||||
@@ -319,8 +293,8 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-if="!banking.overview && banking.isLoading"
|
||||
class="banking-loading"
|
||||
:aria-hidden="Boolean(action)"
|
||||
:inert="Boolean(action)"
|
||||
:aria-hidden="overlayOpened"
|
||||
:inert="overlayOpened"
|
||||
>
|
||||
<SkySpinner :label="phone.t('Common.loading')" />
|
||||
<span>{{ phone.t('Common.loading') }}</span>
|
||||
@@ -329,9 +303,9 @@ onBeforeUnmount(() => {
|
||||
<SkyEmptyState
|
||||
v-else-if="!banking.overview"
|
||||
class="banking-empty"
|
||||
:aria-hidden="Boolean(action)"
|
||||
:aria-hidden="overlayOpened"
|
||||
:body="errorMessage(banking.error)"
|
||||
:inert="Boolean(action)"
|
||||
:inert="overlayOpened"
|
||||
:title="phone.t('Apps.banking.unavailable')"
|
||||
>
|
||||
<template #icon><Landmark :size="34" /></template>
|
||||
@@ -346,8 +320,9 @@ onBeforeUnmount(() => {
|
||||
v-else
|
||||
ref="bankingScroll"
|
||||
class="banking-scroll"
|
||||
:aria-hidden="Boolean(action)"
|
||||
:inert="Boolean(action)"
|
||||
:class="{ 'is-locked': overlayOpened }"
|
||||
:aria-hidden="overlayOpened"
|
||||
:inert="overlayOpened"
|
||||
@touchend="finishPull"
|
||||
@touchmove.passive="movePull"
|
||||
@touchstart.passive="startPull"
|
||||
@@ -418,7 +393,7 @@ onBeforeUnmount(() => {
|
||||
<SkyLink
|
||||
component="button"
|
||||
type="button"
|
||||
@click="activeTab = 'activity'"
|
||||
@click="selectTab('activity')"
|
||||
>
|
||||
{{ phone.t('Apps.banking.viewAll') }}
|
||||
</SkyLink>
|
||||
@@ -434,6 +409,9 @@ onBeforeUnmount(() => {
|
||||
:key="transaction.id"
|
||||
:subtitle="formatDate(transaction.createdAt)"
|
||||
:title="transactionTitle(transaction)"
|
||||
link
|
||||
link-component="button"
|
||||
@click="openTransaction(transaction)"
|
||||
>
|
||||
<template #media>
|
||||
<component
|
||||
@@ -519,6 +497,9 @@ onBeforeUnmount(() => {
|
||||
:key="transaction.id"
|
||||
:subtitle="formatDate(transaction.createdAt)"
|
||||
:title="transactionTitle(transaction)"
|
||||
link
|
||||
link-component="button"
|
||||
@click="openTransaction(transaction)"
|
||||
>
|
||||
<template #media>
|
||||
<component
|
||||
@@ -550,43 +531,44 @@ onBeforeUnmount(() => {
|
||||
icons
|
||||
labels
|
||||
class="banking-tabbar"
|
||||
:aria-hidden="Boolean(action)"
|
||||
:inert="Boolean(action)"
|
||||
:aria-hidden="overlayOpened"
|
||||
:inert="overlayOpened"
|
||||
:label="phone.t('Apps.banking.navigation')"
|
||||
>
|
||||
<SkyTabButton
|
||||
:active="activeTab === 'home'"
|
||||
:label="phone.t('Apps.banking.home')"
|
||||
@click="activeTab = 'home'"
|
||||
@click="selectTab('home')"
|
||||
>
|
||||
<template #icon><House :size="25" /></template>
|
||||
</SkyTabButton>
|
||||
<SkyTabButton
|
||||
:active="activeTab === 'activity'"
|
||||
:label="phone.t('Apps.banking.activity')"
|
||||
@click="activeTab = 'activity'"
|
||||
@click="selectTab('activity')"
|
||||
>
|
||||
<template #icon><BarChart3 :size="25" /></template>
|
||||
</SkyTabButton>
|
||||
</SkyTabBar>
|
||||
|
||||
<SkySheet
|
||||
:opened="Boolean(action)"
|
||||
:ariaLabelledby="action ? `banking-${action}-title` : undefined"
|
||||
:opened="overlayOpened"
|
||||
:ariaLabelledby="
|
||||
action
|
||||
? `banking-${action}-title`
|
||||
: selectedTransaction
|
||||
? 'banking-transaction-detail-title'
|
||||
: undefined
|
||||
"
|
||||
swipe-to-close
|
||||
grabber-clickable
|
||||
:grabber-label="phone.t('Common.close')"
|
||||
@backdropclick="closeAction"
|
||||
@escape="closeAction"
|
||||
@grabberclick="closeAction"
|
||||
@swipeclose="closeAction"
|
||||
>
|
||||
<section v-if="action" class="banking-sheet__content">
|
||||
<SkyLink
|
||||
component="button"
|
||||
class="banking-modal__close"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
icon-only
|
||||
type="button"
|
||||
@click="closeAction"
|
||||
>
|
||||
<X :size="17" />
|
||||
</SkyLink>
|
||||
<span class="banking-modal__icon">
|
||||
<Send :size="23" />
|
||||
</span>
|
||||
@@ -606,14 +588,16 @@ onBeforeUnmount(() => {
|
||||
@input="updateTarget"
|
||||
/>
|
||||
<SkyField
|
||||
autocomplete="off"
|
||||
class="banking-amount-field"
|
||||
:label="phone.t('Apps.banking.amount')"
|
||||
:error="formError || false"
|
||||
input-id="banking-transfer-amount"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
inputmode="decimal"
|
||||
outline
|
||||
pattern="[0-9]+([,][0-9]{0,2})?"
|
||||
:placeholder="phone.t('Apps.banking.amountPlaceholder')"
|
||||
type="number"
|
||||
type="text"
|
||||
:value="amount"
|
||||
@input="updateAmount"
|
||||
@keydown.enter="handleEnterAction($event, submitAction)"
|
||||
@@ -652,6 +636,64 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyButton>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="selectedTransaction"
|
||||
class="banking-sheet__content banking-transaction-detail"
|
||||
>
|
||||
<span
|
||||
class="banking-modal__icon banking-transaction-detail__icon"
|
||||
:class="{
|
||||
'is-incoming': isIncoming(selectedTransaction.kind),
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="transactionIcons[selectedTransaction.kind]"
|
||||
:size="23"
|
||||
/>
|
||||
</span>
|
||||
<p class="banking-transaction-detail__eyebrow">
|
||||
{{ phone.t('Apps.banking.transactionDetails') }}
|
||||
</p>
|
||||
<h2 id="banking-transaction-detail-title">
|
||||
{{ transactionTitle(selectedTransaction) }}
|
||||
</h2>
|
||||
<strong
|
||||
class="banking-transaction-detail__amount"
|
||||
:class="{
|
||||
'is-incoming': isIncoming(selectedTransaction.kind),
|
||||
}"
|
||||
>
|
||||
{{
|
||||
formatMoney(
|
||||
isIncoming(selectedTransaction.kind)
|
||||
? selectedTransaction.amount
|
||||
: -selectedTransaction.amount,
|
||||
true,
|
||||
)
|
||||
}}
|
||||
</strong>
|
||||
<SkyList inset strong class="banking-transaction-detail__list">
|
||||
<SkyListItem
|
||||
:title="phone.t('Apps.banking.transactionDate')"
|
||||
:after="formatDate(selectedTransaction.createdAt)"
|
||||
/>
|
||||
<SkyListItem
|
||||
:title="phone.t('Apps.banking.transactionDirection')"
|
||||
:after="
|
||||
phone.t(
|
||||
isIncoming(selectedTransaction.kind)
|
||||
? 'Apps.banking.incoming'
|
||||
: 'Apps.banking.outgoing',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<SkyListItem
|
||||
v-if="selectedTransaction.reference"
|
||||
:title="phone.t('Apps.banking.transactionReference')"
|
||||
:subtitle="selectedTransaction.reference"
|
||||
/>
|
||||
</SkyList>
|
||||
</section>
|
||||
</SkySheet>
|
||||
|
||||
<SkyToast
|
||||
|
||||
@@ -14,6 +14,10 @@ const cameraClient = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/camera.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const focusClient = readFileSync(
|
||||
new URL('../../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const cameraConfig = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
@@ -39,7 +43,9 @@ describe('Camera app controls', () => {
|
||||
expect(cameraView).not.toContain('camera-zoom-slider')
|
||||
expect(cameraView).not.toContain('type="range"')
|
||||
expect(cameraView).toContain('@wheel.prevent.stop="zoomWithWheel"')
|
||||
expect(cameraView).toContain('Math.min(120, event.deltaY * deltaMultiplier)')
|
||||
expect(cameraView).toContain(
|
||||
'Math.min(120, event.deltaY * deltaMultiplier)',
|
||||
)
|
||||
expect(cameraView).toContain('wheelDelta * 0.00075')
|
||||
expect(cameraView).toContain("message.type === 'camera:zoom'")
|
||||
expect(cameraClient).toContain('mouse_wheel_zoom_step = 0.08')
|
||||
@@ -69,11 +75,13 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).toContain('INPUT_LOOK_UD')
|
||||
expect(cameraClient).toContain('first_person_view_mode = 4')
|
||||
expect(cameraClient).toContain(
|
||||
'SetFollowPedCamViewMode(first_person_view_mode)',
|
||||
'local view_mode = camera_state.front_camera and front_camera_view_mode or first_person_view_mode',
|
||||
)
|
||||
expect(cameraClient).toContain('SetFollowVehicleCamViewMode(view_mode)')
|
||||
expect(cameraClient).toContain('SetFollowPedCamViewMode(view_mode)')
|
||||
expect(cameraClient).toMatch(/while camera_state\.active do[\s\S]*apply_camera_view\(\)/)
|
||||
expect(cameraClient).toMatch(
|
||||
/while camera_state\.active do[\s\S]*apply_camera_view\(\)/,
|
||||
)
|
||||
expect(cameraClient).not.toContain('next_view_apply')
|
||||
expect(cameraClient).not.toContain('ultrawide_camera_handle')
|
||||
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
|
||||
@@ -91,9 +99,11 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).toContain('local front_camera_fov = 32.0')
|
||||
expect(cameraClient).toContain('local front_camera_distance = 1.05')
|
||||
expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
|
||||
expect(cameraClient).toContain('local dot = (to_camera.x * forward_vector.x)')
|
||||
expect(cameraClient).toContain('front_camera_target_height')
|
||||
expect(cameraClient).toContain(
|
||||
'local dot = (to_camera.x * forward_vector.x)',
|
||||
)
|
||||
expect(cameraClient).toContain('front_camera_target_height')
|
||||
expect(focusClient).toContain(
|
||||
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
|
||||
)
|
||||
expect(cameraClient).toContain('SetCamCoord(')
|
||||
|
||||
@@ -18,7 +18,12 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
|
||||
import type {
|
||||
MediaConfig,
|
||||
MediaType,
|
||||
PhoneMedia,
|
||||
UploadResult,
|
||||
} from '@/types/media'
|
||||
import { SkySegmented, SkySegmentedButton } from '@/ui'
|
||||
import { createGameView, type GameView } from '@/utils/gameView'
|
||||
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
|
||||
@@ -416,7 +421,7 @@ onMounted(() => {
|
||||
window.addEventListener('keyup', onKeyup)
|
||||
window.addEventListener('message', onMessage)
|
||||
void nuiCall('camera:setActive', { active: true })
|
||||
void nuiCall<{ videoBitrateKbps?: number }>('media:config').then(
|
||||
void nuiCall<MediaConfig>('media:config').then(
|
||||
(response) => {
|
||||
if (response.success && response.data?.videoBitrateKbps) {
|
||||
videoBitrateKbps.value = response.data.videoBitrateKbps
|
||||
|
||||
@@ -6,28 +6,42 @@ const clockView = readFileSync(
|
||||
new URL('./ClockApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const alarmEditorView = readFileSync(
|
||||
new URL('../../components/AlarmEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mainCss = readFileSync(
|
||||
new URL('../../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Clock app controls', () => {
|
||||
it('removes the empty navigation row from world clock and timer titles', () => {
|
||||
expect(clockView).toContain('class="clock-navbar"')
|
||||
it('only renders a title navbar for the alarm tab', () => {
|
||||
expect(clockView.match(/<sky-navbar/g)).toHaveLength(1)
|
||||
expect(clockView).toContain('v-if="tab === \'alarm\'"')
|
||||
expect(clockView).toContain('class="clock-navbar clock-navbar--alarm"')
|
||||
expect(clockView).toContain('variant="large"')
|
||||
expect(clockView).not.toContain('clock-navbar__empty-inner')
|
||||
expect(clockView).not.toContain('v-else-if="tab !== \'stopwatch\'"')
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-navbar\.sky-navbar--no-navigation\s*\{[^}]*--sky-navbar-large-title-height[^}]*padding-top:\s*var\(--sky-navbar-safe-area-top\)/s,
|
||||
/\.clock-navbar\.sky-navbar\s*\{[^}]*--sky-navbar-safe-area-top:\s*var\(--sky-space-2\);/s,
|
||||
)
|
||||
expect(mainCss).not.toContain('.clock-navbar.sky-navbar--no-navigation')
|
||||
})
|
||||
|
||||
it('keeps the single world clock centered with readable line boxes', () => {
|
||||
expect(clockView).toContain('class="clock-world-now"')
|
||||
expect(clockView).not.toContain('clock-world-list')
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-world-time\s*\{[^}]*font-size:\s*74px;[^}]*line-height:\s*1;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the danger color while stopwatch and timer controls are running', () => {
|
||||
expect(clockView).toMatch(
|
||||
/clock\.stopwatchStartedAt === null\s*\? 'primary'\s*:\s*'danger'/,
|
||||
)
|
||||
expect(clockView).toMatch(
|
||||
/:variant="clock\.timerStartedAt === null \? 'primary' : 'danger'"/,
|
||||
)
|
||||
it('uses the orange clock accent and state-specific circular actions', () => {
|
||||
expect(clockView).toContain('accent="#ff9f0a"')
|
||||
expect(clockView).toContain('clock-action-button--start')
|
||||
expect(clockView).toContain('clock-action-button--stop')
|
||||
expect(clockView).toContain('<Play')
|
||||
expect(clockView).toContain('<Pause')
|
||||
})
|
||||
|
||||
it('uses the shared full-width Sky tab bar', () => {
|
||||
@@ -35,4 +49,91 @@ describe('Clock app controls', () => {
|
||||
expect(clockView).toContain('<sky-tab-button')
|
||||
expect(clockView).not.toContain('<sky-segmented')
|
||||
})
|
||||
|
||||
it('does not nest a second SkyBlock around the timer layout', () => {
|
||||
expect(clockView).toContain(
|
||||
'<section v-else class="clock-tool clock-timer">',
|
||||
)
|
||||
expect(clockView).not.toContain(
|
||||
'<sky-block v-else nested class="clock-tool clock-timer">',
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-timer\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*align-items:\s*center;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(/\.clock-timer-actions\s*\{[^}]*width:\s*100%;/s)
|
||||
expect(mainCss).toMatch(/\.clock-timer-settings\s*\{[^}]*width:\s*100%;/s)
|
||||
})
|
||||
|
||||
it('keeps the stopwatch single-page and separates alarm actions', () => {
|
||||
expect(clockView).not.toContain('clock-stopwatch-pages')
|
||||
expect(clockView).not.toContain('clock-stopwatch-page')
|
||||
expect(clockView).toMatch(
|
||||
/<template #left>[\s\S]*?clock-navbar-action--icon[\s\S]*?<\/template>\s*<template #right>[\s\S]*?clock-navbar-action--edit/,
|
||||
)
|
||||
expect(clockView).not.toContain('clock-navbar-actions')
|
||||
expect(clockView).toMatch(
|
||||
/<sky-button\s+v-if="alarmsEditing"[\s\S]*?icon-only[\s\S]*?class="clock-alarm-remove"/,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-navbar--alarm \.sky-navbar__inner\s*\{[^}]*margin-bottom:\s*0;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-navbar--alarm \.sky-navbar__left\.sky-glass-surface,\s*\.clock-navbar--alarm \.sky-navbar__right\.sky-glass-surface\s*\{/s,
|
||||
)
|
||||
expect(mainCss).not.toMatch(/\.clock-navbar--alarm\s*\{[^}]*margin-top:/s)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-content\s*\{[^}]*--sky-block-gutter-left:\s*0px;[^}]*--sky-block-gutter-right:\s*0px;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-stopwatch\s*\{[^}]*--sky-block-gutter-left:\s*calc\(\s*var\(--sky-page-gutter\) \+ var\(--sky-safe-area-left\)\s*\);[^}]*--sky-block-gutter-right:\s*calc\(\s*var\(--sky-page-gutter\) \+ var\(--sky-safe-area-right\)\s*\);/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-item > \.sky-toggle\s*\{[^}]*grid-column:\s*3;[^}]*justify-self:\s*end;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-remove\.sky-button--icon-only\s*\{[^}]*width:\s*28px;[^}]*height:\s*28px;[^}]*min-height:\s*28px;[^}]*padding:\s*0;[^}]*border-radius:\s*50%;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the alarm editor as a compact Sky sheet over the alarm page', () => {
|
||||
expect(clockView).toMatch(/<AlarmEditor\s+v-if="alarmEditor"/)
|
||||
expect(clockView).not.toContain('v-else-if="alarmEditor"')
|
||||
expect(alarmEditorView).toContain('<k-sheet')
|
||||
expect(alarmEditorView).toContain(':show-grabber="false"')
|
||||
expect(alarmEditorView).toContain('swipe-to-close')
|
||||
expect(alarmEditorView).toContain('data-sky-sheet-drag-handle')
|
||||
expect(alarmEditorView).toContain(
|
||||
'<k-scroll-area padded class="clock-alarm-editor">',
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-sheet \.sky-sheet__panel\s*\{[^}]*height:\s*calc\(\s*100% - var\(--sky-safe-area-top\) - var\(--sky-space-2\)\s*\);[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--sky-radius-sheet\) var\(--sky-radius-sheet\) 0 0;[^}]*background:\s*var\(--sky-sheet-background\);/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-sheet\s*\{[^}]*--sky-sheet-background:\s*#1c1c1d;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-sheet \.sky-sheet__grabber\s*\{[^}]*display:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-sheet-surface\s*\{[^}]*height:\s*100%;[^}]*border-radius:\s*var\(--sky-radius-sheet\) var\(--sky-radius-sheet\) 0 0;[^}]*background:\s*var\(--sky-sheet-background\);/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-sheet-navbar\.sky-navbar,[\s\S]*?\.clock-alarm-sheet-surface > \.sky-navbar\s*\{[^}]*--sky-navbar-safe-area-top:\s*var\(--sky-space-3\);/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-sheet-navbar \.sky-navbar__blur,[\s\S]*?\.clock-alarm-sheet-navbar \.sky-navbar__background\s*\{[^}]*background:\s*transparent;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-save\.sky-link\s*\{[^}]*background:\s*#ff9f0a;[^}]*color:\s*#fff;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-editor\.sky-scroll-area--padded\s*\{[^}]*--sky-page-gutter:\s*12px;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-editor \.time-wheel-picker\s*\{[^}]*width:\s*100%;[^}]*margin-right:\s*0;[^}]*margin-left:\s*0;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.clock-alarm-editor \.sky-list--strong,[\s\S]*?\.clock-alarm-editor \.sky-list--inset\s*\{[^}]*background:\s*#2c2c2e;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
import {
|
||||
AlarmClock,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Globe2,
|
||||
Minus,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Timer,
|
||||
TimerReset,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -95,6 +98,18 @@ const currentTimeZone = computed(
|
||||
.formatToParts(now.value)
|
||||
.find((part) => part.type === 'timeZoneName')?.value ?? browserTimeZone,
|
||||
)
|
||||
const stopwatchDecimalSeparator = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(phone.lang)
|
||||
.formatToParts(1.1)
|
||||
.find((part) => part.type === 'decimal')?.value ?? '.',
|
||||
)
|
||||
const stopwatchDisplay = computed(() =>
|
||||
formatStopwatch(stopwatchValue.value).replace(
|
||||
'.',
|
||||
stopwatchDecimalSeparator.value,
|
||||
),
|
||||
)
|
||||
const selectedAlarm = computed(() => {
|
||||
const editor = alarmEditor.value
|
||||
return editor?.mode === 'edit'
|
||||
@@ -102,7 +117,7 @@ const selectedAlarm = computed(() => {
|
||||
: undefined
|
||||
})
|
||||
const tabs = [
|
||||
{ id: 'world', icon: Clock3 },
|
||||
{ id: 'world', icon: Globe2 },
|
||||
{ id: 'alarm', icon: AlarmClock },
|
||||
{ id: 'stopwatch', icon: Timer },
|
||||
{ id: 'timer', icon: TimerReset },
|
||||
@@ -207,7 +222,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<sky-app-page component="main" class="native-app clock-app">
|
||||
<sky-app-page
|
||||
component="main"
|
||||
accent="#ff9f0a"
|
||||
accent-soft="rgba(255, 159, 10, 0.16)"
|
||||
class="native-app clock-app"
|
||||
>
|
||||
<AlarmSoundMenu
|
||||
v-if="timerSoundMenuOpen"
|
||||
:back-label="phone.t('Apps.clock.tabs.timer')"
|
||||
@@ -216,42 +236,35 @@ onBeforeUnmount(() => {
|
||||
@select="clock.setTimerSound($event)"
|
||||
/>
|
||||
|
||||
<AlarmEditor
|
||||
v-else-if="alarmEditor"
|
||||
:alarm="selectedAlarm"
|
||||
@cancel="alarmEditor = null"
|
||||
@delete="deleteSelectedAlarm"
|
||||
@save="saveAlarm"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<sky-navbar
|
||||
v-if="tab !== 'stopwatch'"
|
||||
v-if="tab === 'alarm'"
|
||||
:title="phone.t(`Apps.clock.tabs.${tab}`)"
|
||||
variant="large"
|
||||
transparent
|
||||
class="clock-navbar"
|
||||
class="clock-navbar clock-navbar--alarm"
|
||||
>
|
||||
<template #left>
|
||||
<sky-link
|
||||
v-if="tab === 'alarm'"
|
||||
component="button"
|
||||
:link-props="{ type: 'button' }"
|
||||
@click="toggleAlarmEditing"
|
||||
>
|
||||
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
|
||||
</sky-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<sky-link
|
||||
v-if="tab === 'alarm'"
|
||||
component="button"
|
||||
icon-only
|
||||
class="clock-navbar-action clock-navbar-action--icon sky-glass-surface"
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('Apps.clock.add')"
|
||||
@click="openAlarmEditor()"
|
||||
>
|
||||
<Plus />
|
||||
<Plus aria-hidden="true" />
|
||||
</sky-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<sky-link
|
||||
v-if="clock.alarms.length"
|
||||
component="button"
|
||||
class="clock-navbar-action clock-navbar-action--edit sky-glass-surface"
|
||||
:link-props="{ type: 'button' }"
|
||||
@click="toggleAlarmEditing"
|
||||
>
|
||||
{{ phone.t(alarmsEditing ? 'Common.done' : 'Common.edit') }}
|
||||
</sky-link>
|
||||
</template>
|
||||
</sky-navbar>
|
||||
@@ -265,55 +278,70 @@ onBeforeUnmount(() => {
|
||||
<span class="clock-world-zone">{{ currentTimeZone }}</span>
|
||||
</sky-block>
|
||||
|
||||
<sky-list
|
||||
<section
|
||||
v-else-if="tab === 'alarm'"
|
||||
class="clock-konsta-list clock-alarm-list"
|
||||
class="clock-alarm-list"
|
||||
:aria-label="phone.t('Apps.clock.tabs.alarm')"
|
||||
>
|
||||
<sky-list-item
|
||||
<article
|
||||
v-for="alarm in clock.alarms"
|
||||
:key="alarm.id"
|
||||
link
|
||||
:chevron="alarmsEditing"
|
||||
:title="alarm.time"
|
||||
:subtitle="alarmSubtitle(alarm)"
|
||||
:strong-title="false"
|
||||
title-font-size-ios="text-[38px] font-light"
|
||||
class="clock-alarm-item"
|
||||
@click="openAlarmEditor(alarm.id)"
|
||||
:class="{ 'clock-alarm-item--disabled': !alarm.enabled }"
|
||||
>
|
||||
<template v-if="alarmsEditing" #media>
|
||||
<sky-button
|
||||
rounded
|
||||
small
|
||||
inline
|
||||
variant="danger"
|
||||
class="clock-alarm-remove"
|
||||
:aria-label="phone.t('Apps.clock.alarm.delete')"
|
||||
@click.stop="clock.deleteAlarm(alarm.id)"
|
||||
>
|
||||
<Minus aria-hidden="true" />
|
||||
</sky-button>
|
||||
</template>
|
||||
<template #after>
|
||||
<span v-if="!alarmsEditing" @click.stop>
|
||||
<sky-toggle
|
||||
:checked="alarm.enabled"
|
||||
style="--sky-app-accent: #30d158"
|
||||
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
|
||||
@change="clock.toggleAlarm(alarm.id)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</sky-list-item>
|
||||
</sky-list>
|
||||
<sky-button
|
||||
v-if="alarmsEditing"
|
||||
rounded
|
||||
small
|
||||
icon-only
|
||||
variant="danger"
|
||||
class="clock-alarm-remove"
|
||||
:aria-label="phone.t('Apps.clock.alarm.delete')"
|
||||
@click="clock.deleteAlarm(alarm.id)"
|
||||
>
|
||||
<Minus aria-hidden="true" />
|
||||
</sky-button>
|
||||
|
||||
<sky-block v-else-if="tab === 'stopwatch'" nested class="clock-tool">
|
||||
<div class="clock-digits">{{ formatStopwatch(stopwatchValue) }}</div>
|
||||
<div class="clock-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="clock-alarm-copy"
|
||||
@click="openAlarmEditor(alarm.id)"
|
||||
>
|
||||
<time class="clock-alarm-time">{{ alarm.time }}</time>
|
||||
<span class="clock-alarm-subtitle">{{
|
||||
alarmSubtitle(alarm)
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<ChevronRight
|
||||
v-if="alarmsEditing"
|
||||
class="clock-alarm-chevron"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<sky-toggle
|
||||
v-else
|
||||
:checked="alarm.enabled"
|
||||
style="--sky-app-accent: #30d158"
|
||||
:aria-label="`${alarm.time}, ${alarmSubtitle(alarm)}`"
|
||||
@change="clock.toggleAlarm(alarm.id)"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<sky-block
|
||||
v-else-if="tab === 'stopwatch'"
|
||||
nested
|
||||
class="clock-tool clock-stopwatch"
|
||||
>
|
||||
<time class="clock-digits">{{ stopwatchDisplay }}</time>
|
||||
<div class="clock-actions clock-stopwatch-actions">
|
||||
<sky-button
|
||||
rounded
|
||||
variant="secondary"
|
||||
class="clock-action-button"
|
||||
class="clock-action-button clock-action-button--secondary"
|
||||
:disabled="
|
||||
clock.stopwatchStartedAt === null && stopwatchValue <= 0
|
||||
"
|
||||
@click="
|
||||
clock.stopwatchStartedAt === null
|
||||
? clock.resetStopwatch()
|
||||
@@ -330,15 +358,13 @@ onBeforeUnmount(() => {
|
||||
</sky-button>
|
||||
<sky-button
|
||||
rounded
|
||||
:variant="
|
||||
clock.stopwatchStartedAt === null ? 'primary' : 'danger'
|
||||
"
|
||||
:style="
|
||||
clock.stopwatchStartedAt === null
|
||||
? { '--sky-app-accent': '#30d158' }
|
||||
: undefined
|
||||
"
|
||||
variant="secondary"
|
||||
class="clock-action-button"
|
||||
:class="
|
||||
clock.stopwatchStartedAt === null
|
||||
? 'clock-action-button--start'
|
||||
: 'clock-action-button--stop'
|
||||
"
|
||||
@click="
|
||||
clock.stopwatchStartedAt === null
|
||||
? clock.startStopwatch(Date.now())
|
||||
@@ -354,6 +380,7 @@ onBeforeUnmount(() => {
|
||||
}}
|
||||
</sky-button>
|
||||
</div>
|
||||
<div class="clock-tool-divider" aria-hidden="true" />
|
||||
<sky-list dividers class="clock-konsta-list clock-lap-list">
|
||||
<sky-list-item
|
||||
v-for="(lap, index) in clock.laps"
|
||||
@@ -364,7 +391,7 @@ onBeforeUnmount(() => {
|
||||
</sky-list>
|
||||
</sky-block>
|
||||
|
||||
<sky-block v-else nested class="clock-tool clock-timer">
|
||||
<section v-else class="clock-tool clock-timer">
|
||||
<div
|
||||
v-if="clock.timerStartedAt !== null"
|
||||
class="timer-ring"
|
||||
@@ -400,25 +427,47 @@ onBeforeUnmount(() => {
|
||||
:seconds-unit-label="phone.t('Apps.clock.timer.secondsShort')"
|
||||
/>
|
||||
|
||||
<sky-list
|
||||
strong
|
||||
inset
|
||||
class="clock-konsta-list clock-timer-settings clock-timer-note"
|
||||
>
|
||||
<div class="clock-actions clock-timer-actions">
|
||||
<sky-button
|
||||
rounded
|
||||
variant="secondary"
|
||||
class="clock-action-button clock-action-button--secondary clock-action-button--icon"
|
||||
:aria-label="phone.t('Common.reset')"
|
||||
@click="clock.resetTimer()"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</sky-button>
|
||||
<sky-button
|
||||
rounded
|
||||
variant="secondary"
|
||||
class="clock-action-button clock-action-button--start clock-action-button--icon"
|
||||
:disabled="clock.timerStartedAt === null && timerValue <= 0"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
clock.timerStartedAt === null
|
||||
? 'Common.start'
|
||||
: 'Common.pause',
|
||||
)
|
||||
"
|
||||
@click="
|
||||
clock.timerStartedAt === null
|
||||
? clock.startTimer(now)
|
||||
: clock.pauseTimer(now)
|
||||
"
|
||||
>
|
||||
<Play v-if="clock.timerStartedAt === null" aria-hidden="true" />
|
||||
<Pause v-else aria-hidden="true" />
|
||||
</sky-button>
|
||||
</div>
|
||||
|
||||
<sky-list strong inset class="clock-konsta-list clock-timer-settings">
|
||||
<sky-field
|
||||
:label="phone.t('Apps.clock.timer.note')"
|
||||
:label="phone.t('Apps.clock.timer.description')"
|
||||
:placeholder="phone.t('Apps.clock.timer.notePlaceholder')"
|
||||
:value="clock.timerNote"
|
||||
maxlength="80"
|
||||
@input="setTimerNote"
|
||||
/>
|
||||
</sky-list>
|
||||
|
||||
<sky-list
|
||||
strong
|
||||
inset
|
||||
class="clock-konsta-list clock-timer-settings clock-timer-sound"
|
||||
>
|
||||
<sky-list-item
|
||||
link
|
||||
:chevron="false"
|
||||
@@ -433,42 +482,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</sky-list-item>
|
||||
</sky-list>
|
||||
|
||||
<div class="clock-actions">
|
||||
<sky-button
|
||||
rounded
|
||||
variant="secondary"
|
||||
class="clock-action-button"
|
||||
@click="clock.resetTimer()"
|
||||
>
|
||||
{{ phone.t('Common.reset') }}
|
||||
</sky-button>
|
||||
<sky-button
|
||||
rounded
|
||||
:variant="clock.timerStartedAt === null ? 'primary' : 'danger'"
|
||||
:style="
|
||||
clock.timerStartedAt === null
|
||||
? { '--sky-app-accent': '#30d158' }
|
||||
: undefined
|
||||
"
|
||||
class="clock-action-button"
|
||||
:disabled="clock.timerStartedAt === null && timerValue <= 0"
|
||||
@click="
|
||||
clock.timerStartedAt === null
|
||||
? clock.startTimer(now)
|
||||
: clock.pauseTimer(now)
|
||||
"
|
||||
>
|
||||
{{
|
||||
phone.t(
|
||||
clock.timerStartedAt === null
|
||||
? 'Common.start'
|
||||
: 'Common.pause',
|
||||
)
|
||||
}}
|
||||
</sky-button>
|
||||
</div>
|
||||
</sky-block>
|
||||
</section>
|
||||
</sky-block>
|
||||
|
||||
<sky-tab-bar
|
||||
@@ -492,6 +506,14 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</sky-tab-button>
|
||||
</sky-tab-bar>
|
||||
|
||||
<AlarmEditor
|
||||
v-if="alarmEditor"
|
||||
:alarm="selectedAlarm"
|
||||
@cancel="alarmEditor = null"
|
||||
@delete="deleteSelectedAlarm"
|
||||
@save="saveAlarm"
|
||||
/>
|
||||
</template>
|
||||
</sky-app-page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./CompaniesApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const scriptSource = source.slice(0, source.indexOf('</script>'))
|
||||
const requestBranchStart = source.indexOf(
|
||||
'<template v-else-if="screen === \'request\'">',
|
||||
)
|
||||
const requestBranchEnd = source.indexOf(
|
||||
"<SkyScrollArea\n v-else-if=\"screen === 'manager'",
|
||||
requestBranchStart,
|
||||
)
|
||||
const requestBranchSource = source.slice(requestBranchStart, requestBranchEnd)
|
||||
const requestScrollStart = requestBranchSource.indexOf('<SkyScrollArea')
|
||||
const requestScrollEnd =
|
||||
requestBranchSource.indexOf('</SkyScrollArea>', requestScrollStart) +
|
||||
'</SkyScrollArea>'.length
|
||||
const requestScrollSource = requestBranchSource.slice(
|
||||
requestScrollStart,
|
||||
requestScrollEnd,
|
||||
)
|
||||
const requestDockStart = requestBranchSource.indexOf(
|
||||
'<footer',
|
||||
requestScrollEnd,
|
||||
)
|
||||
const requestDockEnd =
|
||||
requestBranchSource.indexOf('</footer>', requestDockStart) +
|
||||
'</footer>'.length
|
||||
const requestDockSource = requestBranchSource.slice(
|
||||
requestDockStart,
|
||||
requestDockEnd,
|
||||
)
|
||||
const styleSource = source.slice(source.indexOf('<style scoped>'))
|
||||
|
||||
describe('CompaniesApp request conversation contract', () => {
|
||||
it('keeps one registered scroll owner and a separate fixed conversation dock', () => {
|
||||
expect(requestBranchStart).toBeGreaterThan(-1)
|
||||
expect(requestBranchEnd).toBeGreaterThan(requestBranchStart)
|
||||
expect(requestBranchSource.match(/<SkyScrollArea\b/g)).toHaveLength(1)
|
||||
expect(requestScrollSource).toContain(
|
||||
'class="companies-content request-thread"',
|
||||
)
|
||||
|
||||
expect(requestDockStart).toBeGreaterThan(requestScrollEnd)
|
||||
expect(requestScrollSource).not.toContain('request-thread-actions')
|
||||
expect(requestScrollSource).not.toContain('<SkyMessagebar')
|
||||
expect(requestDockSource).toContain('class="request-thread-dock"')
|
||||
expect(requestDockSource).toContain('class="request-thread-actions"')
|
||||
expect(requestDockSource).toContain('<SkyMessagebar')
|
||||
expect(requestDockSource.indexOf('request-thread-actions')).toBeLessThan(
|
||||
requestDockSource.indexOf('<SkyMessagebar'),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not introduce a nested vertical request scroller', () => {
|
||||
expect(requestBranchSource).not.toContain('request-thread-scroll')
|
||||
expect(styleSource).not.toMatch(
|
||||
/\.request-thread(?:-content|-scroll)?\s*\{[^}]*\boverflow(?:-y)?\s*:\s*(?:auto|scroll)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('scrolls the sentinel after initial and newly received messages while respecting reduced motion', () => {
|
||||
expect(scriptSource).toContain(
|
||||
'const requestThreadBottom = ref<HTMLElement | null>(null)',
|
||||
)
|
||||
expect(requestScrollSource).toContain('ref="requestThreadBottom"')
|
||||
expect(requestScrollSource).toContain('class="request-thread-bottom"')
|
||||
expect(scriptSource).toContain(
|
||||
'requestThreadBottom.value?.scrollIntoView({',
|
||||
)
|
||||
expect(scriptSource).toContain(
|
||||
"window.matchMedia(\n '(prefers-reduced-motion: reduce)',",
|
||||
)
|
||||
expect(scriptSource).toContain(
|
||||
"behavior: animate && !reduceMotion ? 'smooth' : 'auto'",
|
||||
)
|
||||
|
||||
expect(scriptSource).toMatch(
|
||||
/watch\(\s*\[\s*\(\) => companies\.request\?\.id \?\? ''[\s\S]*?companies\.request\?\.messages\.length \?\? 0[\s\S]*?messages\[messages\.length - 1\]!\.id[\s\S]*?scrollRequestThreadToBottom\(requestId === previousRequestId\)[\s\S]*?\{ flush: 'post' \},\s*\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,10 @@ const requestFilterSource = source.slice(
|
||||
source.indexOf('<div class="companies-segment-wrap">'),
|
||||
) + '</div>'.length,
|
||||
)
|
||||
const availabilitySources =
|
||||
source.match(
|
||||
/<SkySegmented\s+class=\x22availability-segmented\x22[\s\S]*?<\/SkySegmented>/g,
|
||||
) ?? []
|
||||
|
||||
describe('CompaniesApp Sky pill navigation contract', () => {
|
||||
it('uses the full-width sliding glass navigation on the root screen', () => {
|
||||
@@ -53,6 +57,7 @@ describe('CompaniesApp Sky pill navigation contract', () => {
|
||||
expect(requestFilterSource).toContain('<SkySegmented')
|
||||
expect(requestFilterSource).toContain('compact')
|
||||
expect(requestFilterSource).toContain('navigation')
|
||||
expect(requestFilterSource).toContain('strong')
|
||||
expect(requestFilterSource).toContain(':item-count="2"')
|
||||
expect(requestFilterSource).toContain(
|
||||
':active-index="requestList === \'open\' ? 0 : 1"',
|
||||
@@ -64,6 +69,12 @@ describe('CompaniesApp Sky pill navigation contract', () => {
|
||||
})
|
||||
|
||||
it('uses compact sliding Glass for both availability controls', () => {
|
||||
expect(availabilitySources).toHaveLength(2)
|
||||
for (const availabilitySource of availabilitySources) {
|
||||
expect(availabilitySource).toContain('compact')
|
||||
expect(availabilitySource).toContain('navigation')
|
||||
expect(availabilitySource).toContain('strong')
|
||||
}
|
||||
expect(source.match(/class="availability-segmented"/g)).toHaveLength(2)
|
||||
expect(
|
||||
source.match(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./CompaniesApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const testserverSource = readFileSync(
|
||||
new URL('../../../testserver/index.cjs', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const requestListClassIndex = source.indexOf('company-request-list')
|
||||
const requestListStart = source.lastIndexOf(
|
||||
'<SkyListCard',
|
||||
requestListClassIndex,
|
||||
)
|
||||
const requestListSource = source.slice(
|
||||
requestListStart,
|
||||
source.indexOf('</SkyListCard>', requestListStart) + '</SkyListCard>'.length,
|
||||
)
|
||||
|
||||
describe('CompaniesApp request card contract', () => {
|
||||
it('keeps the screenshot hierarchy and request state visible', () => {
|
||||
expect(requestListStart).toBeGreaterThan(-1)
|
||||
expect(requestListSource).toMatch(/:header=\x22item\.companyName\x22/)
|
||||
expect(requestListSource).toMatch(/:title=\x22item\.subject\x22/)
|
||||
expect(requestListSource).toMatch(
|
||||
/:subtitle=\x22requestSubtitle\(item\)\x22/,
|
||||
)
|
||||
expect(requestListSource).toMatch(/<ClipboardList :size=\x2219\x22/)
|
||||
expect(requestListSource).toMatch(/v-if=\x22item\.unreadCount\x22/)
|
||||
expect(requestListSource).toContain('item.unreadCount')
|
||||
expect(requestListSource).toMatch(
|
||||
/:class=\x22statusClass\(item\.status\)\x22/,
|
||||
)
|
||||
expect(requestListSource).toContain(
|
||||
'Apps.companies.requestStatuses.${item.status}',
|
||||
)
|
||||
expect(requestListSource).toMatch(/openRequest\(item\.id, 'customer'\)/)
|
||||
})
|
||||
|
||||
it('uses separate token-based request surfaces instead of hardcoded colors', () => {
|
||||
expect(source).toMatch(
|
||||
/\.company-request-list \{[\s\S]*?display: grid;[\s\S]*?gap: var\(--sky-space-2\);[\s\S]*?background: transparent;[\s\S]*?\}/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.company-request-list :deep\(\.sky-list-item\) \{[\s\S]*?border: 1px solid var\(--company-border\);[\s\S]*?border-radius: var\(--sky-radius-card\);[\s\S]*?background: var\(--company-surface\);[\s\S]*?\}/,
|
||||
)
|
||||
})
|
||||
|
||||
it('retains the in-progress reference request in the browser mock', () => {
|
||||
expect(testserverSource).toMatch(/name: [\x22']Benny's Motor Works[\x22']/)
|
||||
expect(testserverSource).toMatch(/subject: 'Vehicle will not start'/)
|
||||
expect(testserverSource).toMatch(/serviceName: 'Roadside Assistance'/)
|
||||
expect(testserverSource).toMatch(/status: 'in_progress'/)
|
||||
expect(testserverSource).toContain('unreadCount: 1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./CompaniesApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const apostrophe = String.fromCharCode(39)
|
||||
const quote = String.fromCharCode(34)
|
||||
|
||||
function sourceBlock(startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker)
|
||||
const end = source.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('CompaniesApp outbound service-line dialer contract', () => {
|
||||
it('gates the work action and its opener by service-line permission', () => {
|
||||
const openDialer = sourceBlock(
|
||||
'function openServiceLineDialer()',
|
||||
'\n\nfunction closeServiceLineDialer()',
|
||||
)
|
||||
const workAction = sourceBlock(
|
||||
`phone.t(${apostrophe}Apps.companies.work.takeCalls${apostrophe})`,
|
||||
`phone.t(${apostrophe}Apps.companies.manager.title${apostrophe})`,
|
||||
)
|
||||
|
||||
expect(openDialer).toContain(
|
||||
'if (!companies.workContext?.permissions.canTakeCalls) return',
|
||||
)
|
||||
expect(workAction).toContain(
|
||||
`v-if=${quote}companies.workContext.permissions.canTakeCalls${quote}`,
|
||||
)
|
||||
expect(workAction).toContain(`@click=${quote}openServiceLineDialer${quote}`)
|
||||
expect(workAction).toContain('Apps.companies.work.dialServiceLine')
|
||||
})
|
||||
|
||||
it('uses a Sky sheet and telephone field for the target number', () => {
|
||||
const sheet = sourceBlock(
|
||||
`<div class=${quote}companies-sheet companies-service-line-sheet${quote}>`,
|
||||
`<div class=${quote}companies-sheet companies-assignment-sheet${quote}>`,
|
||||
)
|
||||
|
||||
expect(sheet).toContain('<SkySheet')
|
||||
expect(sheet).toContain(`:opened=${quote}serviceLineSheetOpened${quote}`)
|
||||
expect(sheet).toContain(
|
||||
`<SkyList inset strong class=${quote}service-line-sheet__form${quote}>`,
|
||||
)
|
||||
expect(sheet).toContain('<SkyField')
|
||||
expect(sheet).not.toMatch(/<SkyField\s+outline/)
|
||||
expect(sheet).toContain(`type=${quote}tel${quote}`)
|
||||
expect(sheet).toContain(`:value=${quote}serviceLineTarget${quote}`)
|
||||
expect(sheet).toContain(`:disabled=${quote}!canDialServiceLine${quote}`)
|
||||
expect(sheet).toContain(`@click=${quote}dialServiceLine${quote}`)
|
||||
})
|
||||
|
||||
it('applies the authoritative call state before opening the Phone app', () => {
|
||||
const dial = sourceBlock(
|
||||
'async function dialServiceLine()',
|
||||
'\n\nfunction syncManagerDraft(',
|
||||
)
|
||||
const applyIndex = dial.indexOf('calls.applyCallState(response.data)')
|
||||
const routeIndex = dial.indexOf(
|
||||
`await router.push(${apostrophe}/apps/phone${apostrophe})`,
|
||||
)
|
||||
|
||||
expect(dial).toContain('companies.dialServiceLine(')
|
||||
expect(dial).toContain('serviceLineTarget.value.trim()')
|
||||
expect(applyIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(routeIndex).toBeGreaterThan(applyIndex)
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,8 @@ const threadDraft = ref('')
|
||||
const workActionsOpened = ref(false)
|
||||
const assignmentSheetOpened = ref(false)
|
||||
const selectedMemberId = ref('')
|
||||
const serviceLineSheetOpened = ref(false)
|
||||
const serviceLineTarget = ref('')
|
||||
const cancelDialogOpened = ref(false)
|
||||
const conflictDialogOpened = ref(false)
|
||||
const toastOpened = ref(false)
|
||||
@@ -180,6 +182,7 @@ const announcementDraft = reactive({ body: '', expiresAt: '' })
|
||||
const profileCoords = ref<CompanyCoordinates | null>(null)
|
||||
const selectedLogoMedia = ref<PhoneMedia | null>(null)
|
||||
const selectedCoverMedia = ref<PhoneMedia | null>(null)
|
||||
const requestThreadBottom = ref<HTMLElement | null>(null)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -224,6 +227,15 @@ const canSendThreadMessage = computed(
|
||||
Boolean(threadDraft.value.trim()) &&
|
||||
!companies.mutating,
|
||||
)
|
||||
const canDialServiceLine = computed(
|
||||
() => Boolean(serviceLineTarget.value.trim()) && !companies.mutating,
|
||||
)
|
||||
const requestDockVisible = computed(() => {
|
||||
const actions = companies.request?.actions
|
||||
return Boolean(
|
||||
actions && (actions.canCall || actions.canCancel || actions.canReply),
|
||||
)
|
||||
})
|
||||
const navbarTitle = computed(() => {
|
||||
if (screen.value === 'company') return activeCompany.value?.name ?? ''
|
||||
if (screen.value === 'request') {
|
||||
@@ -731,6 +743,30 @@ async function toggleCallAvailability(): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
function openServiceLineDialer(): void {
|
||||
if (!companies.workContext?.permissions.canTakeCalls) return
|
||||
serviceLineTarget.value = ''
|
||||
serviceLineSheetOpened.value = true
|
||||
}
|
||||
|
||||
function closeServiceLineDialer(): void {
|
||||
serviceLineSheetOpened.value = false
|
||||
}
|
||||
|
||||
async function dialServiceLine(): Promise<void> {
|
||||
if (!canDialServiceLine.value) return
|
||||
const response = await companies.dialServiceLine(
|
||||
serviceLineTarget.value.trim(),
|
||||
)
|
||||
if (!response.success || !response.data) {
|
||||
showToast(errorText(response.error))
|
||||
return
|
||||
}
|
||||
calls.applyCallState(response.data)
|
||||
closeServiceLineDialer()
|
||||
await router.push('/apps/phone')
|
||||
}
|
||||
|
||||
function syncManagerDraft(company: Company): void {
|
||||
profileDraft.acceptsRequests = company.acceptsRequests
|
||||
profileDraft.address = company.location?.address ?? ''
|
||||
@@ -929,6 +965,21 @@ async function reloadConflict(): Promise<void> {
|
||||
if (companies.request) await companies.loadRequest(companies.request.id)
|
||||
}
|
||||
|
||||
async function scrollRequestThreadToBottom(animate: boolean): Promise<void> {
|
||||
await nextTick()
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
if (screen.value !== 'request') return
|
||||
const reduceMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches
|
||||
requestThreadBottom.value?.scrollIntoView({
|
||||
behavior: animate && !reduceMotion ? 'smooth' : 'auto',
|
||||
block: 'end',
|
||||
})
|
||||
}
|
||||
|
||||
watch([search, selectedCategory], ([, category], [, previousCategory]) => {
|
||||
if (category === previousCategory) {
|
||||
queueDirectoryLoad()
|
||||
@@ -949,6 +1000,21 @@ watch(requestSheetOpened, async (opened) => {
|
||||
await nextTick()
|
||||
requestSheetContent.value?.scrollTo({ top: 0 })
|
||||
})
|
||||
watch(
|
||||
[
|
||||
() => companies.request?.id ?? '',
|
||||
() => companies.request?.messages.length ?? 0,
|
||||
() => {
|
||||
const messages = companies.request?.messages ?? []
|
||||
return messages.length ? messages[messages.length - 1]!.id : ''
|
||||
},
|
||||
],
|
||||
([requestId], [previousRequestId]) => {
|
||||
if (!requestId || screen.value !== 'request') return
|
||||
void scrollRequestThreadToBottom(requestId === previousRequestId)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.requestId,
|
||||
@@ -1201,6 +1267,7 @@ onBeforeUnmount(() => {
|
||||
compact
|
||||
navigation
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
<SkySegmentedButton
|
||||
:active="requestList === 'open'"
|
||||
@@ -1366,6 +1433,7 @@ onBeforeUnmount(() => {
|
||||
compact
|
||||
navigation
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
<SkySegmentedButton
|
||||
v-for="availability in availabilityValues"
|
||||
@@ -1397,6 +1465,23 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</template>
|
||||
</SkyListItem>
|
||||
<SkyListItem
|
||||
v-if="companies.workContext.permissions.canTakeCalls"
|
||||
link
|
||||
link-component="button"
|
||||
:link-props="{ type: 'button' }"
|
||||
:title="phone.t('Apps.companies.work.dialServiceLine')"
|
||||
:subtitle="
|
||||
phone.t('Apps.companies.work.dialServiceLineBody', {
|
||||
number:
|
||||
workCompany.phoneNumber ??
|
||||
phone.t('Apps.companies.manager.noPhoneNumber'),
|
||||
})
|
||||
"
|
||||
@click="openServiceLineDialer"
|
||||
>
|
||||
<template #media><Phone :size="20" /></template>
|
||||
</SkyListItem>
|
||||
<SkyListItem
|
||||
v-if="companies.workContext.role === 'manager'"
|
||||
link
|
||||
@@ -1623,119 +1708,136 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyScrollArea>
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'request'"
|
||||
padded
|
||||
class="companies-content request-thread"
|
||||
>
|
||||
<SkyEmptyState
|
||||
v-if="companies.requestLoading"
|
||||
:title="phone.t('Apps.companies.loading.request')"
|
||||
>
|
||||
<template #icon><SkySpinner /></template>
|
||||
</SkyEmptyState>
|
||||
<SkyEmptyState
|
||||
v-else-if="companies.requestError || !companies.request"
|
||||
tone="danger"
|
||||
:title="phone.t('Apps.companies.states.requestError')"
|
||||
:body="errorText(companies.requestError)"
|
||||
>
|
||||
<template #icon><CircleAlert :size="34" /></template>
|
||||
<template #actions>
|
||||
<SkyButton rounded @click="goBack">
|
||||
{{ phone.t('Apps.companies.back') }}
|
||||
</SkyButton>
|
||||
</template>
|
||||
</SkyEmptyState>
|
||||
<template v-else>
|
||||
<div class="request-thread-scroll">
|
||||
<SkyCard class="request-summary-card">
|
||||
<span>
|
||||
<small>{{ companies.request.companyName }}</small>
|
||||
<strong>{{ companies.request.subject }}</strong>
|
||||
</span>
|
||||
<SkyBadge :class="statusClass(companies.request.status)">
|
||||
{{
|
||||
phone.t(
|
||||
`Apps.companies.requestStatuses.${companies.request.status}`,
|
||||
)
|
||||
}}
|
||||
</SkyBadge>
|
||||
<p>{{ companies.request.description }}</p>
|
||||
<span class="request-summary-card__meta">
|
||||
{{
|
||||
companies.request.serviceName ??
|
||||
phone.t('Apps.companies.requests.generalService')
|
||||
}}
|
||||
· {{ formatDate(companies.request.createdAt) }}
|
||||
</span>
|
||||
</SkyCard>
|
||||
<template v-else-if="screen === 'request'">
|
||||
<SkyScrollArea padded class="companies-content request-thread">
|
||||
<SkyEmptyState
|
||||
v-if="companies.requestLoading"
|
||||
:title="phone.t('Apps.companies.loading.request')"
|
||||
>
|
||||
<template #icon><SkySpinner /></template>
|
||||
</SkyEmptyState>
|
||||
<SkyEmptyState
|
||||
v-else-if="companies.requestError || !companies.request"
|
||||
tone="danger"
|
||||
:title="phone.t('Apps.companies.states.requestError')"
|
||||
:body="errorText(companies.requestError)"
|
||||
>
|
||||
<template #icon><CircleAlert :size="34" /></template>
|
||||
<template #actions>
|
||||
<SkyButton rounded @click="goBack">
|
||||
{{ phone.t('Apps.companies.back') }}
|
||||
</SkyButton>
|
||||
</template>
|
||||
</SkyEmptyState>
|
||||
<template v-else>
|
||||
<div class="request-thread-content">
|
||||
<SkyCard class="request-summary-card">
|
||||
<span>
|
||||
<small>{{ companies.request.companyName }}</small>
|
||||
<strong>{{ companies.request.subject }}</strong>
|
||||
</span>
|
||||
<SkyBadge :class="statusClass(companies.request.status)">
|
||||
{{
|
||||
phone.t(
|
||||
`Apps.companies.requestStatuses.${companies.request.status}`,
|
||||
)
|
||||
}}
|
||||
</SkyBadge>
|
||||
<p>{{ companies.request.description }}</p>
|
||||
<span class="request-summary-card__meta">
|
||||
{{
|
||||
companies.request.serviceName ??
|
||||
phone.t('Apps.companies.requests.generalService')
|
||||
}}
|
||||
· {{ formatDate(companies.request.createdAt) }}
|
||||
</span>
|
||||
</SkyCard>
|
||||
|
||||
<div
|
||||
v-if="companies.request.media.length"
|
||||
class="request-media-strip"
|
||||
:aria-label="phone.t('Apps.companies.requests.attachments')"
|
||||
>
|
||||
<img
|
||||
v-for="media in companies.request.media"
|
||||
:key="media.id"
|
||||
:src="media.url"
|
||||
:alt="phone.t('Apps.companies.requests.attachedPhoto')"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SkyBlockTitle>{{
|
||||
phone.t('Apps.companies.requests.timeline')
|
||||
}}</SkyBlockTitle>
|
||||
<div class="request-timeline">
|
||||
<div v-for="event in companies.request.events" :key="event.id">
|
||||
<span><Check :size="12" /></span>
|
||||
<p>
|
||||
<strong>{{ eventLabel(event) }}</strong>
|
||||
<small>{{ formatDate(event.createdAt) }}</small>
|
||||
</p>
|
||||
<div
|
||||
v-if="companies.request.media.length"
|
||||
class="request-media-strip"
|
||||
:aria-label="phone.t('Apps.companies.requests.attachments')"
|
||||
>
|
||||
<img
|
||||
v-for="media in companies.request.media"
|
||||
:key="media.id"
|
||||
:src="media.url"
|
||||
:alt="phone.t('Apps.companies.requests.attachedPhoto')"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkyBlockTitle>{{
|
||||
phone.t('Apps.companies.requests.conversation')
|
||||
}}</SkyBlockTitle>
|
||||
<SkyMessages class="company-messages">
|
||||
<SkyMessagesTitle v-if="!companies.request.messages.length">
|
||||
{{ phone.t('Apps.companies.requests.noMessages') }}
|
||||
</SkyMessagesTitle>
|
||||
<SkyMessage
|
||||
v-for="message in companies.request.messages"
|
||||
:key="message.id"
|
||||
:type="message.isMine ? 'sent' : 'received'"
|
||||
:name="messageAuthorLabel(message.authorLabel)"
|
||||
:text="message.body"
|
||||
:text-footer="formatDate(message.createdAt)"
|
||||
<SkyBlockTitle>{{
|
||||
phone.t('Apps.companies.requests.timeline')
|
||||
}}</SkyBlockTitle>
|
||||
<div class="request-timeline">
|
||||
<div v-for="event in companies.request.events" :key="event.id">
|
||||
<span><Check :size="12" /></span>
|
||||
<p>
|
||||
<strong>{{ eventLabel(event) }}</strong>
|
||||
<small>{{ formatDate(event.createdAt) }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkyBlockTitle>{{
|
||||
phone.t('Apps.companies.requests.conversation')
|
||||
}}</SkyBlockTitle>
|
||||
<SkyMessages class="company-messages">
|
||||
<SkyMessagesTitle v-if="!companies.request.messages.length">
|
||||
{{ phone.t('Apps.companies.requests.noMessages') }}
|
||||
</SkyMessagesTitle>
|
||||
<SkyMessage
|
||||
v-for="message in companies.request.messages"
|
||||
:key="message.id"
|
||||
:type="message.isMine ? 'sent' : 'received'"
|
||||
:name="messageAuthorLabel(message.authorLabel)"
|
||||
:text="message.body"
|
||||
:text-footer="formatDate(message.createdAt)"
|
||||
/>
|
||||
</SkyMessages>
|
||||
<span
|
||||
ref="requestThreadBottom"
|
||||
class="request-thread-bottom"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SkyMessages>
|
||||
|
||||
<div class="request-thread-actions">
|
||||
<SkyButton
|
||||
v-if="companies.request.actions.canCall"
|
||||
outline
|
||||
rounded
|
||||
@click="callRequestParty"
|
||||
>
|
||||
<Phone :size="16" />{{ phone.t('Apps.companies.requests.call') }}
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
v-if="companies.request.actions.canCancel"
|
||||
outline
|
||||
rounded
|
||||
class="is-danger"
|
||||
@click="cancelDialogOpened = true"
|
||||
>
|
||||
<X :size="16" />{{ phone.t('Apps.companies.requests.cancel') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</template>
|
||||
</SkyScrollArea>
|
||||
<footer
|
||||
v-if="requestDockVisible && companies.request"
|
||||
class="request-thread-dock"
|
||||
:class="{
|
||||
'request-thread-dock--actions-only':
|
||||
!companies.request.actions.canReply,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
companies.request.actions.canCall ||
|
||||
companies.request.actions.canCancel
|
||||
"
|
||||
class="request-thread-actions"
|
||||
>
|
||||
<SkyButton
|
||||
v-if="companies.request.actions.canCall"
|
||||
outline
|
||||
rounded
|
||||
@click="callRequestParty"
|
||||
>
|
||||
<Phone :size="16" />{{ phone.t('Apps.companies.requests.call') }}
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
v-if="companies.request.actions.canCancel"
|
||||
outline
|
||||
rounded
|
||||
class="is-danger"
|
||||
@click="cancelDialogOpened = true"
|
||||
>
|
||||
<X :size="16" />{{ phone.t('Apps.companies.requests.cancel') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
<SkyMessagebar
|
||||
v-if="companies.request.actions.canReply"
|
||||
@@ -1761,8 +1863,8 @@ onBeforeUnmount(() => {
|
||||
</SkyToolbarPane>
|
||||
</template>
|
||||
</SkyMessagebar>
|
||||
</template>
|
||||
</SkyScrollArea>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<SkyScrollArea
|
||||
v-else-if="screen === 'manager' && workCompany"
|
||||
@@ -1792,6 +1894,7 @@ onBeforeUnmount(() => {
|
||||
compact
|
||||
navigation
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
<SkySegmentedButton
|
||||
v-for="availability in availabilityValues"
|
||||
@@ -2335,6 +2438,64 @@ onBeforeUnmount(() => {
|
||||
</SkyActionGroup>
|
||||
</SkyActionSheet>
|
||||
|
||||
<div class="companies-sheet companies-service-line-sheet">
|
||||
<SkySheet
|
||||
:opened="serviceLineSheetOpened"
|
||||
:aria-label="phone.t('Apps.companies.work.dialServiceLine')"
|
||||
@backdropclick="closeServiceLineDialer"
|
||||
@escape="closeServiceLineDialer"
|
||||
>
|
||||
<section
|
||||
v-if="serviceLineSheetOpened && workCompany"
|
||||
class="companies-sheet__content service-line-sheet__content"
|
||||
>
|
||||
<header>
|
||||
<span><PhoneCall :size="23" /></span>
|
||||
<div>
|
||||
<small>{{ workCompany.name }}</small>
|
||||
<h2>{{ phone.t('Apps.companies.work.dialServiceLine') }}</h2>
|
||||
</div>
|
||||
<SkyLink
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.companies.close')"
|
||||
type="button"
|
||||
@click="closeServiceLineDialer"
|
||||
>
|
||||
<X :size="19" />
|
||||
</SkyLink>
|
||||
</header>
|
||||
<p class="service-line-sheet__body">
|
||||
{{
|
||||
phone.t('Apps.companies.work.dialServiceLineHint', {
|
||||
number:
|
||||
workCompany.phoneNumber ??
|
||||
phone.t('Apps.companies.manager.noPhoneNumber'),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<SkyList inset strong class="service-line-sheet__form">
|
||||
<SkyField
|
||||
type="tel"
|
||||
:label="phone.t('Apps.companies.work.targetNumber')"
|
||||
:placeholder="phone.t('Apps.companies.work.targetNumberHint')"
|
||||
:value="serviceLineTarget"
|
||||
@input="serviceLineTarget = eventValue($event)"
|
||||
@keydown.enter.exact="handleEnterAction($event, dialServiceLine)"
|
||||
/>
|
||||
</SkyList>
|
||||
<SkyButton
|
||||
large
|
||||
rounded
|
||||
:disabled="!canDialServiceLine"
|
||||
@click="dialServiceLine"
|
||||
>
|
||||
<Phone :size="18" />{{ phone.t('Apps.companies.work.callNow') }}
|
||||
</SkyButton>
|
||||
</section>
|
||||
</SkySheet>
|
||||
</div>
|
||||
|
||||
<div class="companies-sheet companies-assignment-sheet">
|
||||
<SkySheet
|
||||
:opened="assignmentSheetOpened"
|
||||
@@ -2494,9 +2655,24 @@ onBeforeUnmount(() => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.company-list,
|
||||
.company-list {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.company-request-list {
|
||||
margin: 0 !important;
|
||||
display: grid;
|
||||
gap: var(--sky-space-2);
|
||||
overflow: visible;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.company-request-list :deep(.sky-list-item) {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--company-border);
|
||||
border-radius: var(--sky-radius-card);
|
||||
background: var(--company-surface);
|
||||
}
|
||||
|
||||
.company-list :deep(.sky-list-item__row),
|
||||
@@ -2831,16 +3007,8 @@ onBeforeUnmount(() => {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.request-thread {
|
||||
padding: 8px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-thread-scroll {
|
||||
height: 100%;
|
||||
padding: 4px 10px 88px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
.request-thread-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.request-summary-card :deep(> *) {
|
||||
@@ -2947,11 +3115,30 @@ onBeforeUnmount(() => {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.request-thread-bottom {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.request-thread-dock {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
flex: 0 0 auto;
|
||||
background: var(--sky-bg);
|
||||
}
|
||||
|
||||
.request-thread-actions {
|
||||
margin: 14px 5px;
|
||||
margin: var(--sky-space-2)
|
||||
calc(var(--sky-page-gutter) + var(--sky-safe-area-right)) var(--sky-space-1)
|
||||
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sky-space-2);
|
||||
}
|
||||
|
||||
.request-thread-actions > :only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.request-thread-actions .is-danger,
|
||||
@@ -2959,17 +3146,16 @@ onBeforeUnmount(() => {
|
||||
color: var(--company-red);
|
||||
}
|
||||
|
||||
.company-messagebar {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
.request-thread-dock--actions-only {
|
||||
padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
|
||||
}
|
||||
|
||||
.companies-app.sky-app-page--dark .company-messagebar {
|
||||
background: transparent;
|
||||
.company-messagebar {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.manager-screen {
|
||||
@@ -3127,6 +3313,21 @@ onBeforeUnmount(() => {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.service-line-sheet__body {
|
||||
margin: 0 0 var(--sky-space-4);
|
||||
color: var(--company-muted);
|
||||
font-size: var(--sky-font-body);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.service-line-sheet__form {
|
||||
margin: 0 0 var(--sky-space-4) !important;
|
||||
}
|
||||
|
||||
.service-line-sheet__content > :deep(.sky-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.composer-service-title {
|
||||
margin-top: 18px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -166,6 +166,7 @@ const sheet = ref<CrewLinkSheet>(null)
|
||||
const username = ref('')
|
||||
const authMode = ref<'login' | 'register'>('login')
|
||||
const authUsername = ref('')
|
||||
const authPassword = ref('')
|
||||
const authProfilePhoto = ref<PhoneMedia | null>(null)
|
||||
const authPending = ref(false)
|
||||
const authError = ref('')
|
||||
@@ -408,6 +409,7 @@ function showToast(message: string): void {
|
||||
function switchAuthMode(mode: 'login' | 'register'): void {
|
||||
authMode.value = mode
|
||||
authProfilePhoto.value = null
|
||||
authPassword.value = ''
|
||||
authUsername.value =
|
||||
mode === 'register'
|
||||
? (account.email.split('@')[0] ?? '')
|
||||
@@ -419,6 +421,8 @@ function switchAuthMode(mode: 'login' | 'register'): void {
|
||||
|
||||
function authErrorText(code?: string): string {
|
||||
const known = [
|
||||
'invalid_credentials',
|
||||
'invalid_password',
|
||||
'invalid_profile_image',
|
||||
'invalid_username',
|
||||
'no_ifruit_account',
|
||||
@@ -438,56 +442,45 @@ async function submitAuthentication(): Promise<void> {
|
||||
authError.value = authErrorText('no_ifruit_account')
|
||||
return
|
||||
}
|
||||
if (!authUsernameValid.value) {
|
||||
if (authPassword.value.length < 8 || authPassword.value.length > 72) {
|
||||
authError.value = authErrorText('invalid_password')
|
||||
return
|
||||
}
|
||||
if (authMode.value === 'register' && !authUsernameValid.value) {
|
||||
authError.value = authErrorText('invalid_username')
|
||||
return
|
||||
}
|
||||
|
||||
const submittedUsername = authUsername.value.trim()
|
||||
authPending.value = true
|
||||
const loaded = await crew.bootstrap()
|
||||
if (!loaded) {
|
||||
authPending.value = false
|
||||
authError.value = authErrorText(crew.error)
|
||||
const response =
|
||||
authMode.value === 'login'
|
||||
? await crew.login(authPassword.value)
|
||||
: await crew.register(
|
||||
submittedUsername,
|
||||
authPassword.value,
|
||||
authProfilePhoto.value?.id ?? 0,
|
||||
)
|
||||
authPending.value = false
|
||||
if (!response.success || !crew.profile) {
|
||||
authError.value = authErrorText(response.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (authMode.value === 'login') {
|
||||
authPending.value = false
|
||||
if (!crew.profile) {
|
||||
authError.value = authErrorText('profile_not_found')
|
||||
return
|
||||
}
|
||||
if (
|
||||
crew.profile.username.toLowerCase() !== submittedUsername.toLowerCase()
|
||||
) {
|
||||
authError.value = authErrorText('invalid_username')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (crew.profile) {
|
||||
authPending.value = false
|
||||
authError.value = authErrorText('profile_exists')
|
||||
return
|
||||
}
|
||||
const response = await crew.createProfile(
|
||||
submittedUsername,
|
||||
authProfilePhoto.value?.id ?? 0,
|
||||
)
|
||||
authPending.value = false
|
||||
if (!response.success) {
|
||||
authError.value = authErrorText(response.error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
appAuth.signIn('crewlink', account.email)
|
||||
username.value = crew.profile?.username ?? submittedUsername
|
||||
authUsername.value = ''
|
||||
authPassword.value = ''
|
||||
authProfilePhoto.value = null
|
||||
openSharedInvite()
|
||||
}
|
||||
|
||||
async function handleLoggedOut(): Promise<void> {
|
||||
await crew.logout()
|
||||
activeTab.value = 'map'
|
||||
authPassword.value = ''
|
||||
}
|
||||
|
||||
function openAuthMedia(app: 'camera' | 'photos'): void {
|
||||
messageMedia.begin(
|
||||
'crewlink:auth-avatar',
|
||||
@@ -657,16 +650,6 @@ function removeProfilePhoto(): void {
|
||||
profileAvatarRemoved.value = true
|
||||
}
|
||||
|
||||
async function createProfile(): Promise<void> {
|
||||
const response = await crew.createProfile(username.value.trim())
|
||||
if (!response.success) {
|
||||
formError.value = errorText(response.error)
|
||||
return
|
||||
}
|
||||
showToast(t('profileCreated'))
|
||||
openSharedInvite()
|
||||
}
|
||||
|
||||
async function createGroup(): Promise<void> {
|
||||
const response = await crew.createGroup(
|
||||
groupName.value.trim(),
|
||||
@@ -1136,7 +1119,10 @@ onMounted(async () => {
|
||||
authProfilePhoto.value =
|
||||
authSelection.media[0] ?? authSelection.context?.selectedPhoto ?? null
|
||||
}
|
||||
if (appAuth.isSignedIn('crewlink')) await crew.bootstrap()
|
||||
if (appAuth.isSignedIn('crewlink')) {
|
||||
await crew.bootstrap()
|
||||
if (!crew.authenticated) appAuth.signOut('crewlink')
|
||||
}
|
||||
username.value = crew.profile?.username ?? ''
|
||||
if (profileSelection && crew.profile) {
|
||||
username.value = profileSelection.context?.username ?? crew.profile.username
|
||||
@@ -1173,6 +1159,7 @@ onBeforeUnmount(() => {
|
||||
<div class="crewlink-onboarding crewlink-auth">
|
||||
<AppProfileAuth
|
||||
:mode="authMode"
|
||||
v-model:password="authPassword"
|
||||
v-model:username="authUsername"
|
||||
:avatar-url="authProfilePhoto?.url ?? null"
|
||||
:body="t('authBody')"
|
||||
@@ -1186,10 +1173,14 @@ onBeforeUnmount(() => {
|
||||
:max-username-length="20"
|
||||
:min-username-length="3"
|
||||
:pending="authPending"
|
||||
:password-label="t('password')"
|
||||
:password-placeholder="t('passwordPlaceholder')"
|
||||
:register-label="t('register')"
|
||||
require-password
|
||||
:title="t('authTitle')"
|
||||
:username-label="t('username')"
|
||||
:username-placeholder="t('usernamePlaceholder')"
|
||||
variant="centered"
|
||||
@camera="openAuthMedia('camera')"
|
||||
@gallery="openAuthMedia('photos')"
|
||||
@submit="submitAuthentication"
|
||||
@@ -1218,57 +1209,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!crew.profile">
|
||||
<div class="crewlink-onboarding crewlink-onboarding--profile">
|
||||
<div class="crewlink-orbits" aria-hidden="true">
|
||||
<i></i><i></i><i></i><span><Radio /></span>
|
||||
</div>
|
||||
<small>{{ t('welcomeEyebrow') }}</small>
|
||||
<h1>{{ t('welcomeTitle') }}</h1>
|
||||
<p>{{ t('welcomeBody') }}</p>
|
||||
<sky-list inset strong class="crewlink-form-list">
|
||||
<sky-field
|
||||
input-id="crewlink-username"
|
||||
:label="t('username')"
|
||||
:placeholder="t('usernamePlaceholder')"
|
||||
:value="username"
|
||||
maxlength="20"
|
||||
outline
|
||||
@input="updateValue('username', $event)"
|
||||
@keydown.enter="handleEnterAction($event, createProfile)"
|
||||
/>
|
||||
</sky-list>
|
||||
<p v-if="formError" class="crewlink-error" role="alert">
|
||||
{{ formError }}
|
||||
</p>
|
||||
<sky-button
|
||||
large
|
||||
rounded
|
||||
:disabled="crew.isLoading"
|
||||
@click="createProfile"
|
||||
>
|
||||
<sky-spinner v-if="crew.isLoading" />
|
||||
<template v-else>{{ t('createProfile') }}</template>
|
||||
</sky-button>
|
||||
<sky-button
|
||||
large
|
||||
rounded
|
||||
outline
|
||||
class="crewlink-logout-button"
|
||||
@click="logoutDialogOpen = true"
|
||||
>
|
||||
<LogOut />{{ phone.t('Common.signOut') }}
|
||||
</sky-button>
|
||||
<small class="crewlink-privacy"
|
||||
><ShieldCheck />{{ t('privacyNote') }}</small
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-else-if="crew.profile">
|
||||
<sky-navbar
|
||||
v-if="activeGroup"
|
||||
class="crewlink-navbar"
|
||||
:class="{ 'crewlink-navbar--map': activeTab === 'map' }"
|
||||
:subtitle="headerSubtitle"
|
||||
:title="headerTitle"
|
||||
/>
|
||||
@@ -1481,7 +1426,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'group'" class="crewlink-scroll-tab">
|
||||
<section
|
||||
v-show="activeTab === 'group'"
|
||||
class="crewlink-scroll-tab crewlink-group-tab"
|
||||
>
|
||||
<div class="crewlink-group-hero" :style="activeCrewStyle">
|
||||
<div class="crewlink-group-hero__signal"><Radio /></div>
|
||||
<small>{{ t('activeCrew') }}</small>
|
||||
@@ -1674,7 +1622,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'profile'" class="crewlink-scroll-tab">
|
||||
<section
|
||||
v-show="activeTab === 'profile'"
|
||||
class="crewlink-scroll-tab crewlink-profile-tab"
|
||||
>
|
||||
<div
|
||||
class="crewlink-profile-card"
|
||||
:style="{ '--crew': activeColour }"
|
||||
@@ -1891,6 +1842,10 @@ onBeforeUnmount(() => {
|
||||
<section
|
||||
v-if="sheet"
|
||||
class="crewlink-sheet__panel__content"
|
||||
:class="{
|
||||
'crewlink-sheet__panel__content--manage': sheet === 'edit-group',
|
||||
'crewlink-sheet__panel__content--ping': sheet === 'ping',
|
||||
}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
@@ -2372,7 +2327,7 @@ onBeforeUnmount(() => {
|
||||
v-model:opened="logoutDialogOpen"
|
||||
app-id="crewlink"
|
||||
:app-name="t('name')"
|
||||
@logged-out="activeTab = 'map'"
|
||||
@logged-out="handleLoggedOut"
|
||||
/>
|
||||
|
||||
<sky-dialog
|
||||
@@ -2409,6 +2364,7 @@ onBeforeUnmount(() => {
|
||||
--cl-surface: rgba(255, 255, 255, 0.84);
|
||||
--cl-text: #102034;
|
||||
--cl-muted: #6e7c8d;
|
||||
--cl-map-header-background: linear-gradient(90deg, #061823, #0f2837);
|
||||
--sky-safe-area-top: 46px;
|
||||
--sky-safe-area-bottom: 25px;
|
||||
position: relative;
|
||||
@@ -2428,22 +2384,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.crewlink-navbar {
|
||||
--sky-safe-area-top: 46px;
|
||||
--sky-navbar-glass: var(--cl-bg);
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
.crewlink-navbar::after {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 22px;
|
||||
background: linear-gradient(var(--cl-bg), transparent);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.crewlink-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -2476,8 +2423,11 @@ onBeforeUnmount(() => {
|
||||
.crewlink-auth {
|
||||
--auth-accent: #20bde0;
|
||||
--panel: rgba(18, 39, 53, 0.92);
|
||||
padding: 60px 18px 30px;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
padding: calc(var(--sky-safe-area-top) + 20px) 18px
|
||||
calc(var(--sky-safe-area-bottom) + 20px);
|
||||
justify-content: center;
|
||||
color: #f3f8fb;
|
||||
background:
|
||||
radial-gradient(circle at 82% 8%, rgba(39, 217, 237, 0.2), transparent 34%),
|
||||
@@ -2485,7 +2435,9 @@ onBeforeUnmount(() => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.crewlink-auth :deep(.app-profile-auth) {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 340px);
|
||||
flex: none;
|
||||
margin: auto;
|
||||
}
|
||||
.crewlink-logo {
|
||||
width: 78px;
|
||||
@@ -2522,6 +2474,15 @@ onBeforeUnmount(() => {
|
||||
.crewlink-onboarding :deep(.button) {
|
||||
width: 100%;
|
||||
}
|
||||
.crewlink-navbar--map {
|
||||
--sky-text: #f3f8fb;
|
||||
--sky-muted: #aab7c1;
|
||||
background: var(--cl-map-header-background);
|
||||
}
|
||||
.crewlink-navbar--map :deep(.sky-navbar__blur),
|
||||
.crewlink-navbar--map :deep(.sky-navbar__background) {
|
||||
display: none;
|
||||
}
|
||||
.crewlink-orbits {
|
||||
width: 170px;
|
||||
height: 170px;
|
||||
@@ -2703,17 +2664,27 @@ onBeforeUnmount(() => {
|
||||
padding-bottom: var(--sky-safe-area-bottom);
|
||||
}
|
||||
.crewlink-map-summary {
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
height: 64px;
|
||||
padding: 8px 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: white;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(6, 24, 35, 0.96),
|
||||
rgba(15, 41, 56, 0.95)
|
||||
);
|
||||
background: var(--cl-map-header-background);
|
||||
}
|
||||
.crewlink-map-summary::after {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 16px;
|
||||
background: var(--cl-map-header-background);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000, transparent);
|
||||
mask-image: linear-gradient(to bottom, #000, transparent);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.crewlink-map-summary > div:first-child {
|
||||
display: grid;
|
||||
@@ -3020,6 +2991,14 @@ onBeforeUnmount(() => {
|
||||
linear-gradient(145deg, #0b2433, #0b1825);
|
||||
box-shadow: 0 15px 35px rgba(6, 20, 31, 0.2);
|
||||
}
|
||||
.crewlink-group-tab {
|
||||
--sky-card-outer-left: 0px;
|
||||
--sky-card-outer-right: 0px;
|
||||
--sky-list-outer-left: 0px;
|
||||
--sky-list-outer-right: 0px;
|
||||
--sky-title-gutter-left: 0px;
|
||||
--sky-title-gutter-right: 0px;
|
||||
}
|
||||
.crewlink-group-hero__signal {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -3311,6 +3290,12 @@ onBeforeUnmount(() => {
|
||||
color: white;
|
||||
background: linear-gradient(145deg, var(--crew), #183a5a);
|
||||
}
|
||||
.crewlink-profile-tab {
|
||||
--sky-list-outer-left: 0px;
|
||||
--sky-list-outer-right: 0px;
|
||||
--sky-title-gutter-left: 0px;
|
||||
--sky-title-gutter-right: 0px;
|
||||
}
|
||||
.crewlink-profile-card > span {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
@@ -3413,6 +3398,16 @@ onBeforeUnmount(() => {
|
||||
background: var(--cl-bg);
|
||||
border-radius: 24px 24px 0 0;
|
||||
}
|
||||
.crewlink-sheet__panel__content--manage {
|
||||
--sky-card-outer-left: 0px;
|
||||
--sky-card-outer-right: 0px;
|
||||
--sky-list-outer-left: 0px;
|
||||
--sky-list-outer-right: 0px;
|
||||
}
|
||||
.crewlink-sheet__panel__content--ping {
|
||||
--sky-list-outer-left: 0px;
|
||||
--sky-list-outer-right: 0px;
|
||||
}
|
||||
.crewlink-sheet__panel__close {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./FeatherApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const postCard = readFileSync(
|
||||
new URL('../../components/feather/FeatherPostCard.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FeatherApp Sky UI contract', () => {
|
||||
it('uses explicit Sky UI components without Konsta-style aliases', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(postCard).not.toContain("from 'konsta/vue'")
|
||||
expect(postCard).toContain('import { SkyButton, SkyGlass, SkyIcon }')
|
||||
expect(postCard).not.toMatch(/Sky[A-Za-z]+ as k[A-Za-z]+/)
|
||||
expect(postCard).not.toMatch(/<\/?k[A-Z]/)
|
||||
expect(source).not.toContain('SkyTabBar')
|
||||
expect(source).not.toContain('SkyTabButton')
|
||||
expect(source).not.toContain('SkyToolbarPane')
|
||||
expect(source).toContain('<SkyPillNavigation')
|
||||
expect(source).toContain('<SkyScrollArea')
|
||||
expect(source).toContain('with-tabbar')
|
||||
})
|
||||
|
||||
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
|
||||
expect(postCard).toContain(
|
||||
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
|
||||
)
|
||||
expect(postCard).toContain('@click.stop="react(\'like\')"')
|
||||
expect(postCard).toContain('@click.stop="react(\'bookmark\')"')
|
||||
expect(postCard).toContain("'is-pulsing': reactionPulse === 'like'")
|
||||
expect(postCard).toContain("'is-pulsing': reactionPulse === 'bookmark'")
|
||||
expect(postCard).toContain('@keyframes feather-reaction-pop')
|
||||
expect(postCard).toContain('@keyframes feather-reaction-ring')
|
||||
expect(postCard).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('keeps the post header actions visible and compact', () => {
|
||||
expect(postCard).toContain(
|
||||
':aria-label="phone.t(\'Apps.feather.moreActions\')"',
|
||||
)
|
||||
expect(postCard).toMatch(
|
||||
/<SkyButton\s+icon-only\s+rounded\s+small\s+tonal\s+class="feather-more"/s,
|
||||
)
|
||||
expect(postCard).toMatch(
|
||||
/\.feather-follow\s*\{[^}]*--sky-app-accent:\s*var\(--feather-blue,[^}]*color:\s*var\(--feather-blue,/s,
|
||||
)
|
||||
expect(postCard).not.toMatch(
|
||||
/\.feather-follow\s*\{[^}]*--sky-app-accent:\s*transparent/s,
|
||||
)
|
||||
expect(postCard).toMatch(
|
||||
/\.feather-more\s*\{[^}]*width:\s*27px[^}]*height:\s*27px[^}]*border:\s*1px/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the composer below the iPhone header with compact actions', () => {
|
||||
expect(source).toContain("'feather-app--composer':")
|
||||
expect(source).toMatch(
|
||||
/\.feather-app--active\.feather-app--composer \.feather-navbar\s*\{[^}]*--sky-safe-area-top:\s*54px/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.feather-app--active\.feather-app--composer > \.feather-composer\s*\{[^}]*top:\s*112px[^}]*padding-top:\s*18px/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.feather-composer-close\s*\{[^}]*width:\s*32px[^}]*height:\s*32px/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.feather-composer-publish\s*\{[^}]*min-width:\s*52px[^}]*min-height:\s*28px/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/v-if="screen === 'composer'"[\s\S]*?variant="secondary"[\s\S]*?class="feather-composer-publish"/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/v-else-if="screen === 'edit'"[\s\S]*?variant="secondary"[\s\S]*?class="feather-edit__navbar-save"/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the shared phone action sheet for every post more menu', () => {
|
||||
expect(source).toContain('<SkyActionSheet')
|
||||
expect(source).toContain('<SkyActionGroup')
|
||||
expect(source).toContain('<SkyActionButton')
|
||||
expect(source).toContain('<SkyActionsLabel')
|
||||
expect(source).not.toContain('<SkySheet :opened="menuPost !== null"')
|
||||
})
|
||||
|
||||
it('renders compact Instagram-style comments with a bottom message bar', () => {
|
||||
expect(source).toContain('class="feather-comments"')
|
||||
expect(source).toContain('class="feather-comment"')
|
||||
expect(source).toContain('class="feather-comment__like"')
|
||||
expect(source).toContain('class="feather-comment-composer"')
|
||||
expect(source).toContain('<SkyMessagebar')
|
||||
expect(source).toContain('@click="reactComment(post)"')
|
||||
expect(source).toContain('@click="focusThreadReply(post)"')
|
||||
expect(source).not.toMatch(
|
||||
/<FeatherPostCard\s+v-for="post in feather\.thread\.replies"/,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns to the profile tab navigation after closing its connection list', () => {
|
||||
expect(source).toContain(
|
||||
"const connectionReturnScreen = ref<'main' | 'profile'>('profile')",
|
||||
)
|
||||
expect(source).toContain(
|
||||
"connectionReturnScreen.value = screen.value === 'main' ? 'main' : 'profile'",
|
||||
)
|
||||
expect(source).toContain('screen.value = connectionReturnScreen.value')
|
||||
})
|
||||
|
||||
it('uses a strong Explore filter and a dedicated Network follow control', () => {
|
||||
expect(source).toMatch(
|
||||
/<SkySegmented[\s\S]*?class="feather-explore-tabs"[\s\S]*?rounded[\s\S]*?strong/,
|
||||
)
|
||||
expect(source).toContain(
|
||||
'class="feather-follow-button feather-network-person__follow"',
|
||||
)
|
||||
expect(source).toContain(':tonal="person.is_following"')
|
||||
expect(source).toMatch(
|
||||
/\.feather-network-person__follow\s*\{[^}]*min-width:\s*84px[^}]*min-height:\s*32px/s,
|
||||
)
|
||||
expect(source).not.toContain('feather-feed-add')
|
||||
})
|
||||
|
||||
it('keeps owner actions in the navbar and removes the profile media tab', () => {
|
||||
const profileStart = source.indexOf('class="feather-profile"')
|
||||
const tabsStart = source.indexOf(
|
||||
'class="feather-profile-tabs"',
|
||||
profileStart,
|
||||
)
|
||||
const profile = source.slice(profileStart, tabsStart)
|
||||
|
||||
expect(profile).toContain('class="feather-profile__cover-title"')
|
||||
expect(profile).not.toContain('class="feather-profile__logout"')
|
||||
expect(source).toContain('class="feather-profile__logout"')
|
||||
expect(source).toContain(':aria-label="phone.t(\'Common.signOut\')"')
|
||||
expect(source).toContain('@click="logoutDialogOpen = true"')
|
||||
expect(profile).not.toContain('feather-profile-action--logout')
|
||||
expect(source).toContain('<Check :size="14" :stroke-width="2.8" />')
|
||||
expect(source).not.toContain("profileView === 'media'")
|
||||
expect(source).not.toContain("selectProfileView('media')")
|
||||
expect(source).toMatch(
|
||||
/\.feather-profile__cover-title\s*\{[^}]*color:\s*#fff/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.feather-app--active \.feather-profile__logout\s*\{[^}]*--sky-app-accent:\s*#fff/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ const localeSource = readFileSync(
|
||||
)
|
||||
|
||||
describe('FlareApp profile editing contract', () => {
|
||||
it('opens the central photo source picker while creating an account', () => {
|
||||
it('shows direct Gallery and Camera actions while creating an account', () => {
|
||||
const onboardingStart = source.indexOf(
|
||||
'<template v-else-if="!flare.profile">',
|
||||
)
|
||||
@@ -29,27 +29,68 @@ describe('FlareApp profile editing contract', () => {
|
||||
|
||||
expect(onboardingStart).toBeGreaterThan(-1)
|
||||
expect(onboardingEnd).toBeGreaterThan(onboardingStart)
|
||||
expect(onboarding).toContain('aria-controls="flare-photo-source-sheet"')
|
||||
expect(onboarding).toContain('aria-haspopup="dialog"')
|
||||
expect(onboarding).toContain('@click="openPhotoSourcePicker"')
|
||||
expect(onboarding).not.toContain("openProfileMediaApp('photos')")
|
||||
expect(onboarding).not.toContain("openProfileMediaApp('camera')")
|
||||
expect(onboarding).toContain("openProfileMediaApp('photos')")
|
||||
expect(onboarding).toContain("openProfileMediaApp('camera')")
|
||||
expect(onboarding).toContain("phone.t('Apps.flare.choosePhotos')")
|
||||
expect(onboarding).toContain("phone.t('Apps.flare.takePhoto')")
|
||||
expect(onboarding).not.toContain('@click="openPhotoSourcePicker"')
|
||||
})
|
||||
|
||||
it('uses the same Gallery and Camera picker for onboarding and editing', () => {
|
||||
it('uses the central anchored SkyDropdown for photo sources while editing', () => {
|
||||
const triggerClass = source.indexOf('class="flare-photo-add"')
|
||||
const triggerStart = source.lastIndexOf('<sky-button', triggerClass)
|
||||
const triggerEnd = source.indexOf('</sky-button>', triggerClass)
|
||||
const trigger = source.slice(triggerStart, triggerEnd)
|
||||
const dropdownStart = source.search(/<SkyDropdown\b/)
|
||||
const dropdownEnd = source.indexOf('/>', dropdownStart)
|
||||
const dropdown = source.slice(dropdownStart, dropdownEnd)
|
||||
const pickerStart = source.indexOf('function openPhotoSourcePicker')
|
||||
const pickerEnd = source.indexOf(
|
||||
'function openProfileMediaApp',
|
||||
pickerStart,
|
||||
)
|
||||
const picker = source.slice(pickerStart, pickerEnd)
|
||||
const mediaAppStart = source.indexOf('function openProfileMediaApp')
|
||||
const mediaAppEnd = source.indexOf('function removeDraftPhoto')
|
||||
const mediaApp = source.slice(mediaAppStart, mediaAppEnd)
|
||||
|
||||
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(2)
|
||||
expect(source).toMatch(
|
||||
/<sky-action-sheet\s+id=["']flare-photo-source-sheet["']/,
|
||||
expect(triggerClass).toBeGreaterThan(-1)
|
||||
expect(triggerStart).toBeGreaterThan(-1)
|
||||
expect(triggerEnd).toBeGreaterThan(triggerStart)
|
||||
expect(source.match(/@click="openPhotoSourcePicker"/g)).toHaveLength(1)
|
||||
expect(trigger).toContain('aria-controls="flare-photo-source-menu"')
|
||||
expect(trigger).toContain('aria-haspopup="menu"')
|
||||
expect(trigger).toContain(':aria-expanded="photoSourceOpened"')
|
||||
expect(trigger).not.toContain('aria-haspopup="dialog"')
|
||||
|
||||
expect(dropdownStart).toBeGreaterThan(-1)
|
||||
expect(dropdownEnd).toBeGreaterThan(dropdownStart)
|
||||
expect(dropdown).toContain('id="flare-photo-source-menu"')
|
||||
expect(dropdown).toContain(':items="photoSourceItems"')
|
||||
expect(dropdown).toMatch(/:label="phone\.t\('Apps\.flare\.addPhotos'\)"/)
|
||||
expect(dropdown).toContain(':opened="photoSourceOpened"')
|
||||
expect(dropdown).toContain(':target="photoSourceTarget"')
|
||||
expect(dropdown).toContain('@backdropclick="closePhotoSourcePicker"')
|
||||
expect(dropdown).toContain('@escape="closePhotoSourcePicker"')
|
||||
expect(dropdown).toContain('@positionerror="closePhotoSourcePicker"')
|
||||
expect(dropdown).toContain('@select="selectPhotoSource"')
|
||||
|
||||
expect(source).toContain(
|
||||
"{ id: 'photos', label: phone.t('Apps.flare.choosePhotos') }",
|
||||
)
|
||||
expect(source).toContain(
|
||||
'<sky-action-button bold @click="openProfileMediaApp(\'photos\')">',
|
||||
"{ id: 'camera', label: phone.t('Apps.flare.takePhoto') }",
|
||||
)
|
||||
expect(source).toContain(
|
||||
'<sky-action-button @click="openProfileMediaApp(\'camera\')">',
|
||||
expect(pickerStart).toBeGreaterThan(-1)
|
||||
expect(pickerEnd).toBeGreaterThan(pickerStart)
|
||||
expect(picker).toContain('event.currentTarget instanceof HTMLElement')
|
||||
expect(picker).toContain('photoSourceTarget.value = event.currentTarget')
|
||||
expect(picker).toContain("if (id !== 'photos' && id !== 'camera') return")
|
||||
expect(picker).toContain('openProfileMediaApp(id)')
|
||||
|
||||
expect(source).not.toContain('flare-photo-source-sheet')
|
||||
expect(source).not.toMatch(
|
||||
/<sky-action-sheet\b[\s\S]*?openProfileMediaApp\(['"](?:photos|camera)['"]\)[\s\S]*?<\/sky-action-sheet>/i,
|
||||
)
|
||||
expect(mediaAppStart).toBeGreaterThan(-1)
|
||||
expect(mediaAppEnd).toBeGreaterThan(mediaAppStart)
|
||||
@@ -60,6 +101,51 @@ describe('FlareApp profile editing contract', () => {
|
||||
expect(mediaApp).toContain("query: { mediaAttachment: 'photo' }")
|
||||
})
|
||||
|
||||
it('requires one profile photo before creating or saving a profile', () => {
|
||||
const saveStart = source.indexOf('async function saveProfile()')
|
||||
const saveEnd = source.indexOf('\n\nfunction selectTab', saveStart)
|
||||
const saveProfile = source.slice(saveStart, saveEnd)
|
||||
|
||||
expect(source).toContain(
|
||||
'const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)',
|
||||
)
|
||||
expect(saveProfile).toContain('!hasRequiredProfilePhoto.value')
|
||||
expect(
|
||||
source.match(/:disabled="profileSaving \|\| !hasRequiredProfilePhoto"/g),
|
||||
).toHaveLength(2)
|
||||
expect(source).toContain('if (draftPhotos.value.length <= 1) return')
|
||||
expect(source).toContain(
|
||||
'if (flare.profile && draftPhotos.value.length === 0)',
|
||||
)
|
||||
expect(
|
||||
source.match(
|
||||
/<sky-link\s+v-if="draftPhotos\.length > 1"[\s\S]*?class="flare-photo-remove"/g,
|
||||
),
|
||||
).toHaveLength(2)
|
||||
expect(source.match(/Apps\.flare\.profilePhotoRequired/g)).toHaveLength(2)
|
||||
expect(serverSource).toMatch(
|
||||
/#data\.photoMediaIds\s*<\s*1[\s\S]*?#data\.photoMediaIds\s*>\s*6/,
|
||||
)
|
||||
expect(localeSource).toContain(
|
||||
'Add one to six photos from Photos or Camera.',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses eligible in-game profile photos for Explorer covers', () => {
|
||||
const explorerStart = source.indexOf("activeTab === 'explore'")
|
||||
const explorerEnd = source.indexOf("activeTab === 'likes'", explorerStart)
|
||||
const explorer = source.slice(explorerStart, explorerEnd)
|
||||
|
||||
expect(source).not.toContain('profiles-source.png')
|
||||
expect(source).toContain('const exploreTiles = computed(')
|
||||
expect(source).toContain('profile.photoUrls.length > 0')
|
||||
expect(source).toContain("coverUrl: profile?.photoUrls[0] ?? ''")
|
||||
expect(explorer).toContain(
|
||||
':style="tile.coverUrl ? photoStyle(tile.coverUrl) : undefined"',
|
||||
)
|
||||
expect(explorer).not.toContain('avatarStyle(tile.avatar)')
|
||||
})
|
||||
|
||||
it('opens the relationship goal editor from the profile summary card', () => {
|
||||
const cardClass = source.indexOf('class="flare-profile-card"')
|
||||
const cardStart = source.lastIndexOf('<sky-card', cardClass)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
SkyActionButton,
|
||||
SkyActionGroup,
|
||||
SkyActionSheet,
|
||||
SkyActionsLabel,
|
||||
SkyBadge,
|
||||
SkyBlock,
|
||||
SkyBlockTitle,
|
||||
@@ -11,6 +7,7 @@ import {
|
||||
SkyCard,
|
||||
SkyDialog,
|
||||
SkyDialogButton,
|
||||
SkyDropdown,
|
||||
SkyIcon,
|
||||
SkyLink,
|
||||
SkyList,
|
||||
@@ -72,7 +69,6 @@ import {
|
||||
} from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import profilesSprite from '@/assets/img/flare/profiles-source.png'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||
import SharedContentCard from '@/components/SharedContentCard.vue'
|
||||
@@ -137,6 +133,7 @@ const profileEditing = ref(false)
|
||||
const profileSettings = ref(false)
|
||||
const profileSaving = ref(false)
|
||||
const photoSourceOpened = ref(false)
|
||||
const photoSourceTarget = ref<HTMLElement | null>(null)
|
||||
const discoverySaving = ref(false)
|
||||
const signOutDialogOpened = ref(false)
|
||||
const deleteAccountDialogOpened = ref(false)
|
||||
@@ -152,6 +149,10 @@ const gifResults = ref<GifSearchResult[]>([])
|
||||
const gifLoading = ref(false)
|
||||
const gifError = ref<string | null>(null)
|
||||
const gifHasMore = ref(true)
|
||||
const photoSourceItems = computed(() => [
|
||||
{ id: 'photos', label: phone.t('Apps.flare.choosePhotos') },
|
||||
{ id: 'camera', label: phone.t('Apps.flare.takePhoto') },
|
||||
])
|
||||
|
||||
function shareProfile(): void {
|
||||
const profile = flare.profile
|
||||
@@ -207,6 +208,7 @@ const activeChoiceTitle = computed(() =>
|
||||
choiceFieldLabel(activeChoiceField.value),
|
||||
)
|
||||
const activeChoiceValue = computed(() => profileDraft[activeChoiceField.value])
|
||||
const hasRequiredProfilePhoto = computed(() => draftPhotos.value.length >= 1)
|
||||
|
||||
const filteredSuggestions = computed(() =>
|
||||
activeExploreMode.value === 'all'
|
||||
@@ -250,21 +252,35 @@ const cardStyle = computed(() => ({
|
||||
transform: `translateX(${cardOffset.value}px) rotate(${cardOffset.value / 22}deg)`,
|
||||
transition: dragging.value ? 'none' : undefined,
|
||||
}))
|
||||
const exploreTiles = [
|
||||
{ avatar: 0, key: 'forYou', mode: 'all', tone: 'coral' },
|
||||
{ avatar: 1, key: 'longTerm', mode: 'longTerm', tone: 'violet' },
|
||||
{ avatar: 4, key: 'newFriends', mode: 'friends', tone: 'blue' },
|
||||
{ avatar: 3, key: 'dateNight', mode: 'dates', tone: 'amber' },
|
||||
const exploreTileDefinitions = [
|
||||
{ key: 'forYou', mode: 'all', tone: 'coral' },
|
||||
{ key: 'longTerm', mode: 'longTerm', tone: 'violet' },
|
||||
{ key: 'newFriends', mode: 'friends', tone: 'blue' },
|
||||
{ key: 'dateNight', mode: 'dates', tone: 'amber' },
|
||||
] as const
|
||||
const exploreTiles = computed(() => {
|
||||
const profiles = flare.suggestions.filter(
|
||||
(profile) => profile.photoUrls.length > 0,
|
||||
)
|
||||
return exploreTileDefinitions.map((tile) => {
|
||||
const profile =
|
||||
tile.mode === 'all'
|
||||
? profiles[0]
|
||||
: (profiles.find((candidate) => candidate.lookingFor === tile.mode) ??
|
||||
profiles[0])
|
||||
return {
|
||||
...tile,
|
||||
coverUrl: profile?.photoUrls[0] ?? '',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function avatarStyle(avatar: number): Record<string, string> {
|
||||
const safeAvatar = Math.max(0, Math.min(5, Math.floor(avatar)))
|
||||
const column = safeAvatar % 3
|
||||
const row = Math.floor(safeAvatar / 3)
|
||||
return {
|
||||
backgroundImage: `url(${profilesSprite})`,
|
||||
backgroundPosition: `${column * 50}% ${row * 100}%`,
|
||||
backgroundSize: '300% 200%',
|
||||
backgroundImage: `linear-gradient(${135 + safeAvatar * 12}deg, var(--flare), var(--flare-warm) 52%, var(--flare-ink))`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +363,10 @@ function resetProfileDraft(): void {
|
||||
ownPhotoIndex.value = 0
|
||||
}
|
||||
|
||||
function openPhotoSourcePicker(): void {
|
||||
function openPhotoSourcePicker(event: MouseEvent): void {
|
||||
if (draftPhotos.value.length >= 6) return
|
||||
if (!(event.currentTarget instanceof HTMLElement)) return
|
||||
photoSourceTarget.value = event.currentTarget
|
||||
photoSourceOpened.value = true
|
||||
}
|
||||
|
||||
@@ -356,6 +374,11 @@ function closePhotoSourcePicker(): void {
|
||||
photoSourceOpened.value = false
|
||||
}
|
||||
|
||||
function selectPhotoSource(id: string): void {
|
||||
if (id !== 'photos' && id !== 'camera') return
|
||||
openProfileMediaApp(id)
|
||||
}
|
||||
|
||||
function openProfileMediaApp(app: 'camera' | 'photos'): void {
|
||||
const remaining = 6 - draftPhotos.value.length
|
||||
if (remaining < 1) return
|
||||
@@ -378,6 +401,7 @@ function openProfileMediaApp(app: 'camera' | 'photos'): void {
|
||||
}
|
||||
|
||||
function removeDraftPhoto(id: number): void {
|
||||
if (draftPhotos.value.length <= 1) return
|
||||
draftPhotos.value = draftPhotos.value.filter((photo) => photo.id !== id)
|
||||
profileDraft.photoMediaIds = draftPhotos.value.map((photo) => photo.id)
|
||||
}
|
||||
@@ -585,7 +609,7 @@ async function refreshFlareState(): Promise<void> {
|
||||
}
|
||||
|
||||
async function saveProfile(): Promise<void> {
|
||||
if (profileSaving.value) return
|
||||
if (profileSaving.value || !hasRequiredProfilePhoto.value) return
|
||||
profileSaving.value = true
|
||||
const creatingProfile = !flare.profile
|
||||
try {
|
||||
@@ -913,6 +937,10 @@ onMounted(async () => {
|
||||
profileEditing.value = selection.context.editing
|
||||
} else {
|
||||
syncDraft()
|
||||
if (flare.profile && draftPhotos.value.length === 0) {
|
||||
activeTab.value = 'profile'
|
||||
profileEditing.value = true
|
||||
}
|
||||
if (route.query.profileEdit === '1' && flare.profile) {
|
||||
activeTab.value = 'profile'
|
||||
profileEditing.value = true
|
||||
@@ -1012,6 +1040,7 @@ onBeforeUnmount(() => {
|
||||
{{ phone.t('Apps.flare.primaryPhoto') }}
|
||||
</span>
|
||||
<sky-link
|
||||
v-if="draftPhotos.length > 1"
|
||||
component="button"
|
||||
icon-only
|
||||
class="flare-photo-remove"
|
||||
@@ -1025,19 +1054,27 @@ onBeforeUnmount(() => {
|
||||
<X />
|
||||
</sky-link>
|
||||
</div>
|
||||
<sky-button
|
||||
v-if="draftPhotos.length < 6"
|
||||
clear
|
||||
class="flare-photo-add"
|
||||
:class="{ 'is-empty': !draftPhotos.length }"
|
||||
aria-controls="flare-photo-source-sheet"
|
||||
aria-haspopup="dialog"
|
||||
@click="openPhotoSourcePicker"
|
||||
>
|
||||
<Plus />
|
||||
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="draftPhotos.length < 6"
|
||||
class="flare-onboarding-photo-actions"
|
||||
>
|
||||
<sky-button outline rounded @click="openProfileMediaApp('photos')">
|
||||
<Images :size="19" />
|
||||
<span>{{ phone.t('Apps.flare.choosePhotos') }}</span>
|
||||
</sky-button>
|
||||
<sky-button outline rounded @click="openProfileMediaApp('camera')">
|
||||
<Camera :size="19" />
|
||||
<span>{{ phone.t('Apps.flare.takePhoto') }}</span>
|
||||
</sky-button>
|
||||
</div>
|
||||
<p
|
||||
v-if="!hasRequiredProfilePhoto"
|
||||
class="flare-photo-required"
|
||||
role="status"
|
||||
>
|
||||
{{ phone.t('Apps.flare.profilePhotoRequired') }}
|
||||
</p>
|
||||
</sky-card>
|
||||
<sky-list inset strong>
|
||||
<sky-field
|
||||
@@ -1104,7 +1141,7 @@ onBeforeUnmount(() => {
|
||||
<sky-button
|
||||
large
|
||||
rounded
|
||||
:disabled="profileSaving"
|
||||
:disabled="profileSaving || !hasRequiredProfilePhoto"
|
||||
:aria-busy="profileSaving"
|
||||
@click="saveProfile"
|
||||
>
|
||||
@@ -1332,7 +1369,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
component="button"
|
||||
class="flare-navbar-done"
|
||||
:disabled="profileSaving"
|
||||
:disabled="profileSaving || !hasRequiredProfilePhoto"
|
||||
:aria-busy="profileSaving"
|
||||
@click="saveProfile"
|
||||
>
|
||||
@@ -1512,6 +1549,8 @@ onBeforeUnmount(() => {
|
||||
<sky-card
|
||||
v-for="tile in exploreTiles"
|
||||
:key="tile.key"
|
||||
component="button"
|
||||
type="button"
|
||||
:content-wrap="false"
|
||||
class="flare-explore-card"
|
||||
:class="`flare-explore-card--${tile.tone}`"
|
||||
@@ -1519,7 +1558,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div
|
||||
class="flare-explore-card__photo"
|
||||
:style="avatarStyle(tile.avatar)"
|
||||
:class="{ 'is-fallback': !tile.coverUrl }"
|
||||
:style="tile.coverUrl ? photoStyle(tile.coverUrl) : undefined"
|
||||
/>
|
||||
<div class="flare-explore-card__shade" />
|
||||
<strong>{{
|
||||
@@ -1819,6 +1859,7 @@ onBeforeUnmount(() => {
|
||||
{{ phone.t('Apps.flare.primaryPhoto') }}
|
||||
</span>
|
||||
<sky-link
|
||||
v-if="draftPhotos.length > 1"
|
||||
component="button"
|
||||
icon-only
|
||||
class="flare-photo-remove"
|
||||
@@ -1837,14 +1878,22 @@ onBeforeUnmount(() => {
|
||||
clear
|
||||
class="flare-photo-add"
|
||||
:class="{ 'is-empty': !draftPhotos.length }"
|
||||
aria-controls="flare-photo-source-sheet"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="flare-photo-source-menu"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="photoSourceOpened"
|
||||
@click="openPhotoSourcePicker"
|
||||
>
|
||||
<Plus />
|
||||
<span>{{ phone.t('Apps.flare.addPhotos') }}</span>
|
||||
</sky-button>
|
||||
</div>
|
||||
<p
|
||||
v-if="!hasRequiredProfilePhoto"
|
||||
class="flare-photo-required"
|
||||
role="status"
|
||||
>
|
||||
{{ phone.t('Apps.flare.profilePhotoRequired') }}
|
||||
</p>
|
||||
</sky-card>
|
||||
<sky-list inset strong>
|
||||
<sky-field
|
||||
@@ -2028,36 +2077,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sky-action-sheet
|
||||
id="flare-photo-source-sheet"
|
||||
:aria-label="phone.t('Apps.flare.addPhotos')"
|
||||
<SkyDropdown
|
||||
id="flare-photo-source-menu"
|
||||
:items="photoSourceItems"
|
||||
:label="phone.t('Apps.flare.addPhotos')"
|
||||
:opened="photoSourceOpened"
|
||||
:target="photoSourceTarget"
|
||||
@backdropclick="closePhotoSourcePicker"
|
||||
@escape="closePhotoSourcePicker"
|
||||
>
|
||||
<sky-action-group>
|
||||
<sky-actions-label>
|
||||
{{ phone.t('Apps.flare.addPhotos') }}
|
||||
</sky-actions-label>
|
||||
<sky-action-button bold @click="openProfileMediaApp('photos')">
|
||||
<span class="flare-photo-source-action">
|
||||
<Images :size="20" />
|
||||
{{ phone.t('Apps.flare.choosePhotos') }}
|
||||
</span>
|
||||
</sky-action-button>
|
||||
<sky-action-button @click="openProfileMediaApp('camera')">
|
||||
<span class="flare-photo-source-action">
|
||||
<Camera :size="20" />
|
||||
{{ phone.t('Apps.flare.takePhoto') }}
|
||||
</span>
|
||||
</sky-action-button>
|
||||
</sky-action-group>
|
||||
<sky-action-group>
|
||||
<sky-action-button @click="closePhotoSourcePicker">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</sky-action-button>
|
||||
</sky-action-group>
|
||||
</sky-action-sheet>
|
||||
@positionerror="closePhotoSourcePicker"
|
||||
@select="selectPhotoSource"
|
||||
/>
|
||||
|
||||
<div class="flare-choice-sheet">
|
||||
<sky-sheet
|
||||
@@ -2596,6 +2626,9 @@ onBeforeUnmount(() => {
|
||||
background-repeat: no-repeat;
|
||||
filter: saturate(0.82);
|
||||
}
|
||||
.flare-explore-card__photo.is-fallback {
|
||||
background: linear-gradient(145deg, var(--flare), var(--flare-warm));
|
||||
}
|
||||
.flare-explore-card__shade {
|
||||
background: linear-gradient(15deg, rgb(0 0 0 / 72%), transparent 70%);
|
||||
mix-blend-mode: multiply;
|
||||
@@ -3158,11 +3191,27 @@ onBeforeUnmount(() => {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
.flare-photo-source-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
.flare-onboarding-photo-actions {
|
||||
margin-top: var(--sky-space-3);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sky-space-2);
|
||||
}
|
||||
.flare-onboarding-photo-actions :deep(.sky-button) {
|
||||
min-width: 0;
|
||||
padding-inline: var(--sky-space-2);
|
||||
}
|
||||
.flare-onboarding-photo-actions :deep(.sky-button span) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.flare-photo-required {
|
||||
margin: var(--sky-space-3) 0 0;
|
||||
color: var(--flare);
|
||||
font-size: var(--sky-font-caption);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.flare-error {
|
||||
margin: 8px 22px 0;
|
||||
color: #ff3b30;
|
||||
|
||||
@@ -12,6 +12,14 @@ const headerActions = source.slice(
|
||||
)
|
||||
|
||||
describe('GalleryApp import action', () => {
|
||||
it('opens a configured wallpaper upload and returns the imported photo directly', () => {
|
||||
expect(source).toContain("route.query.wallpaperUpload === '1'")
|
||||
expect(source).toContain("requestedMessageMedia.value === 'photo'")
|
||||
expect(source).toContain('openImport()')
|
||||
expect(source).toContain('messageMedia.complete(response.data)')
|
||||
expect(source).toContain('await router.push(returnPath)')
|
||||
})
|
||||
|
||||
it('keeps import available as a header button', () => {
|
||||
expect(headerActions).toContain('<SkyToolbarPane')
|
||||
expect(headerActions).toContain('gallery-header-tool--icon')
|
||||
@@ -61,11 +69,17 @@ describe('GalleryApp import action', () => {
|
||||
expect(source).toContain("id: 'sort-oldest'")
|
||||
expect(source).toContain("id: 'show-all'")
|
||||
expect(source).toContain("id: 'show-favorites'")
|
||||
expect(source).toContain("group: 'sort'")
|
||||
expect(source).toContain("group: 'show'")
|
||||
expect(source).toContain("phone.t('Apps.photos.sorting.title')")
|
||||
expect(source).toContain("phone.t('Apps.photos.sorting.show')")
|
||||
expect(source).toContain('orderMedia(media.value, sortOrder.value)')
|
||||
})
|
||||
|
||||
it('starts with the newest media at the bottom-right and loads older media above', () => {
|
||||
expect(source).toContain("const sortOrder = ref<GallerySortOrder>('newest')")
|
||||
expect(source).toContain(
|
||||
"const sortOrder = ref<GallerySortOrder>('newest')",
|
||||
)
|
||||
expect(source).toContain(
|
||||
'galleryContent.value.scrollTop = galleryContent.value.scrollHeight',
|
||||
)
|
||||
@@ -81,9 +95,9 @@ describe('GalleryApp import action', () => {
|
||||
expect(source).toContain('v-if="selectionMode"')
|
||||
expect(source).toContain('selectedCountText')
|
||||
expect(source).toContain('shareSelection')
|
||||
expect(source.match(/<SkyToolbarPane class="gallery-selection-action">/g)).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(
|
||||
source.match(/<SkyToolbarPane class="gallery-selection-action">/g),
|
||||
).toHaveLength(2)
|
||||
expect(source).toContain('<div class="gallery-selection-actions">')
|
||||
expect(source).toMatch(
|
||||
/\.gallery-selection-actions\s*\{[^}]*display:\s*flex;[^}]*gap:\s*var\(--sky-space-2\);/s,
|
||||
@@ -133,7 +147,9 @@ describe('GalleryApp import action', () => {
|
||||
expect(source).toContain(
|
||||
'galleryContent.value.scrollTop = galleryReturnScrollTop',
|
||||
)
|
||||
expect(source).toMatch(/async function closeMedia[\s\S]*?await nextTick\(\)/)
|
||||
expect(source).toMatch(
|
||||
/async function closeMedia[\s\S]*?await nextTick\(\)/,
|
||||
)
|
||||
expect(source).toContain('@wheel.prevent="zoomImageWithWheel"')
|
||||
expect(source).toContain('media.clientWidth / bounds.width')
|
||||
expect(source).toContain('media.clientHeight / bounds.height')
|
||||
@@ -162,4 +178,28 @@ describe('GalleryApp import action', () => {
|
||||
expect(source).toContain('id: 13 + index')
|
||||
expect(source).not.toContain('picsum.photos')
|
||||
})
|
||||
|
||||
it('routes single-image deletion through the browser API when apiPort is set', () => {
|
||||
const deleteSelectedStart = source.indexOf(
|
||||
'async function deleteSelected()',
|
||||
)
|
||||
const deleteSelected = source.slice(
|
||||
deleteSelectedStart,
|
||||
source.indexOf('function onMessage', deleteSelectedStart),
|
||||
)
|
||||
|
||||
expect(deleteSelected).toContain(
|
||||
'if (isDevelopment && !developmentApiEnabled)',
|
||||
)
|
||||
expect(deleteSelected).toContain(
|
||||
"const response = await nuiCall('gallery:delete'",
|
||||
)
|
||||
expect(deleteSelected).toContain(
|
||||
'if (isDevelopment && developmentApiEnabled)',
|
||||
)
|
||||
expect(deleteSelected).toContain("type: 'media:deleteResult'")
|
||||
expect(deleteSelected).toContain('error: response.error')
|
||||
expect(deleteSelected).toContain('success: response.success')
|
||||
expect(deleteSelected).not.toMatch(/if \(isDevelopment\)\s*\{/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
GallerySortOrder,
|
||||
MediaImportSource,
|
||||
MediaImportSources,
|
||||
MediaType,
|
||||
PhoneMedia,
|
||||
} from '@/types/media'
|
||||
import {
|
||||
@@ -77,7 +78,7 @@ const easyShare = useEasyShareStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
|
||||
const requestedMessageMedia = computed<MediaType | null>(() => {
|
||||
const value = route.query.mediaAttachment ?? route.query.messageAttachment
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
@@ -144,22 +145,30 @@ const orderedMedia = computed(() => orderMedia(media.value, sortOrder.value))
|
||||
const sortMenuItems = computed(() => [
|
||||
{
|
||||
checked: sortOrder.value === 'newest',
|
||||
group: 'sort',
|
||||
groupLabel: phone.t('Apps.photos.sorting.title'),
|
||||
id: 'sort-newest',
|
||||
label: phone.t('Apps.photos.sorting.newestFirst'),
|
||||
},
|
||||
{
|
||||
checked: sortOrder.value === 'oldest',
|
||||
group: 'sort',
|
||||
groupLabel: phone.t('Apps.photos.sorting.title'),
|
||||
id: 'sort-oldest',
|
||||
label: phone.t('Apps.photos.sorting.oldestFirst'),
|
||||
},
|
||||
{
|
||||
checked: !favoritesOnly.value,
|
||||
group: 'show',
|
||||
groupLabel: phone.t('Apps.photos.sorting.show'),
|
||||
id: 'show-all',
|
||||
label: phone.t('Apps.photos.sorting.allItems'),
|
||||
separatorBefore: true,
|
||||
},
|
||||
{
|
||||
checked: favoritesOnly.value,
|
||||
group: 'show',
|
||||
groupLabel: phone.t('Apps.photos.sorting.show'),
|
||||
id: 'show-favorites',
|
||||
label: phone.t('Apps.photos.sorting.favorites'),
|
||||
},
|
||||
@@ -406,7 +415,19 @@ async function loadImportSources(): Promise<void> {
|
||||
if (isDevelopment && !developmentApiEnabled) return
|
||||
const response = await nuiCall<MediaImportSources>('media:import:sources')
|
||||
if (!response.success || !response.data) return
|
||||
importSources.value = response.data.sources
|
||||
const requestedType = requestedMessageMedia.value
|
||||
importSources.value = requestedType
|
||||
? response.data.sources.filter((source) =>
|
||||
source.mediaTypes.includes(requestedType),
|
||||
)
|
||||
: response.data.sources
|
||||
if (
|
||||
route.query.wallpaperUpload === '1' &&
|
||||
requestedType === 'photo' &&
|
||||
importSources.value.length > 0
|
||||
) {
|
||||
openImport()
|
||||
}
|
||||
}
|
||||
|
||||
function selectImportSource(source: MediaImportSource): void {
|
||||
@@ -465,6 +486,16 @@ async function commitUrlImport(): Promise<void> {
|
||||
}
|
||||
media.value = mergeMedia(media.value, [response.data])
|
||||
await fetchCounts()
|
||||
if (
|
||||
route.query.wallpaperUpload === '1' &&
|
||||
requestedMessageMedia.value === 'photo'
|
||||
) {
|
||||
const returnPath = messageMedia.complete(response.data)
|
||||
if (returnPath) {
|
||||
await router.push(returnPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
closeImport()
|
||||
showToast(phone.t('Apps.photos.import.linkCompleted'))
|
||||
}
|
||||
@@ -959,12 +990,13 @@ async function initializeVideo(event: Event): Promise<void> {
|
||||
|
||||
async function deleteSelected(): Promise<void> {
|
||||
if (!selected.value || deleting.value) return
|
||||
const selectedId = selected.value.id
|
||||
deleting.value = true
|
||||
deleteDialogOpened.value = false
|
||||
pendingDeleteCorrelation = `${Date.now()}-${crypto.randomUUID()}`
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && !developmentApiEnabled) {
|
||||
const developmentIndex = developmentMedia.findIndex(
|
||||
(entry) => entry.id === selected.value?.id,
|
||||
(entry) => entry.id === selectedId,
|
||||
)
|
||||
if (developmentIndex >= 0) developmentMedia.splice(developmentIndex, 1)
|
||||
window.setTimeout(() => {
|
||||
@@ -973,7 +1005,7 @@ async function deleteSelected(): Promise<void> {
|
||||
data: {
|
||||
data: {
|
||||
correlationId: pendingDeleteCorrelation,
|
||||
id: selected.value?.id,
|
||||
id: selectedId,
|
||||
success: true,
|
||||
},
|
||||
type: 'media:deleteResult',
|
||||
@@ -983,10 +1015,25 @@ async function deleteSelected(): Promise<void> {
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
await nuiCall('gallery:delete', {
|
||||
const response = await nuiCall('gallery:delete', {
|
||||
correlationId: pendingDeleteCorrelation,
|
||||
id: selected.value.id,
|
||||
id: selectedId,
|
||||
})
|
||||
if (isDevelopment && developmentApiEnabled) {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId: pendingDeleteCorrelation,
|
||||
error: response.error,
|
||||
id: selectedId,
|
||||
success: response.success,
|
||||
},
|
||||
type: 'media:deleteResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
@@ -1846,13 +1893,11 @@ onBeforeUnmount(() => {
|
||||
transition: transform var(--sky-transition-fast, 100ms) ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.gallery-detail-navbar
|
||||
:deep(.gallery-detail-back:hover:not(:disabled)) {
|
||||
.gallery-detail-navbar :deep(.gallery-detail-back:hover:not(:disabled)) {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gallery-detail-navbar
|
||||
:deep(.gallery-detail-back:hover:not(:disabled) svg) {
|
||||
.gallery-detail-navbar :deep(.gallery-detail-back:hover:not(:disabled) svg) {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
SkyBlockTitle,
|
||||
SkyButton,
|
||||
SkyCard,
|
||||
SkyDialog,
|
||||
SkyDialogButton,
|
||||
SkyList,
|
||||
SkyListItem,
|
||||
SkyNavbar,
|
||||
SkyAppPage,
|
||||
SkySpinner,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
import {
|
||||
Camera,
|
||||
CarFront,
|
||||
@@ -30,15 +17,29 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useHousingStore } from '@/stores/housing'
|
||||
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 { SkyToast } from '@/ui'
|
||||
import {
|
||||
SkyAppPage,
|
||||
SkyBlockTitle,
|
||||
SkyButton,
|
||||
SkyCard,
|
||||
SkyDialog,
|
||||
SkyDialogButton,
|
||||
SkyList,
|
||||
SkyListItem,
|
||||
SkyNavbar,
|
||||
SkySheet,
|
||||
SkySpinner,
|
||||
SkyToast,
|
||||
} from '@/ui'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const housing = useHousingStore()
|
||||
@@ -49,15 +50,26 @@ const revokeCandidate = ref<HousingKey | null>(null)
|
||||
const toastOpened = ref(false)
|
||||
const toastText = ref('')
|
||||
const houseScroll = ref<HTMLElement | null>(null)
|
||||
const isRefreshing = ref(false)
|
||||
const pullDistance = ref(0)
|
||||
|
||||
const pullThreshold = 56
|
||||
let pullStartY = 0
|
||||
let isPulling = false
|
||||
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
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(
|
||||
() =>
|
||||
@@ -93,57 +105,9 @@ function isPending(action: string, property: HousingProperty): boolean {
|
||||
return housing.pendingAction === `${action}:${property.id}`
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (isRefreshing.value) return
|
||||
isRefreshing.value = true
|
||||
pullDistance.value = pullThreshold
|
||||
const loaded = await housing.load(true)
|
||||
isRefreshing.value = false
|
||||
pullDistance.value = 0
|
||||
if (!loaded && housing.error === 'reload_cooldown') {
|
||||
showToast(translatedError(housing.error))
|
||||
}
|
||||
}
|
||||
|
||||
function atTop(): boolean {
|
||||
return (houseScroll.value?.scrollTop ?? 0) <= 0
|
||||
}
|
||||
|
||||
function startPull(event: TouchEvent): void {
|
||||
if (!atTop() || isRefreshing.value) return
|
||||
pullStartY = event.touches[0]?.clientY ?? 0
|
||||
isPulling = true
|
||||
}
|
||||
|
||||
function movePull(event: TouchEvent): void {
|
||||
if (!isPulling || isRefreshing.value) return
|
||||
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
|
||||
if (distance <= 0) {
|
||||
pullDistance.value = 0
|
||||
return
|
||||
}
|
||||
pullDistance.value = Math.min(pullThreshold + 20, distance * 0.45)
|
||||
}
|
||||
|
||||
function finishPull(): void {
|
||||
if (!isPulling && pullDistance.value === 0) return
|
||||
isPulling = false
|
||||
if (pullDistance.value >= pullThreshold) {
|
||||
void refresh()
|
||||
return
|
||||
}
|
||||
pullDistance.value = 0
|
||||
}
|
||||
|
||||
function pullWithWheel(event: WheelEvent): void {
|
||||
if (!atTop() || isRefreshing.value || event.deltaY >= 0) return
|
||||
pullDistance.value = Math.min(
|
||||
pullThreshold + 20,
|
||||
pullDistance.value + Math.abs(event.deltaY) * 0.18,
|
||||
)
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
if (!updatePullWithWheel(event)) return
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
wheelRefreshTimeout = setTimeout(finishPull, 130)
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
@@ -233,7 +197,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./LocalPagesApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('LocalPagesApp Sky UI contract', () => {
|
||||
it('uses the current Sky UI navigation and scroll primitives', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(source).not.toContain('SkyTabBar')
|
||||
expect(source).not.toContain('SkyTabButton')
|
||||
expect(source).not.toContain('SkyToolbarPane')
|
||||
expect(source).toContain('<SkyPillNavigation')
|
||||
expect(source).toContain('<SkySegmented')
|
||||
expect(source).toContain('<SkyScrollArea')
|
||||
expect(source).toContain('with-tabbar')
|
||||
})
|
||||
|
||||
it('keeps the city banner copy white on its colored surface', () => {
|
||||
expect(source).toMatch(
|
||||
/\.pages-hero-glass\s*\{[^}]*background:\s*linear-gradient[^}]*color:\s*#fff/s,
|
||||
)
|
||||
expect(source).toMatch(/\.pages__hero\s*\{[^}]*color:\s*#fff/s)
|
||||
})
|
||||
|
||||
it('animates successful likes and saves with reduced motion support', () => {
|
||||
expect(source).toContain('function triggerReactionPulse(')
|
||||
expect(source).toContain('triggerReactionPulse(post.id, kind)')
|
||||
expect(source).toContain('triggerReactionPulse(selected.value.id, kind)')
|
||||
expect(source).toContain('pages-reaction-pop')
|
||||
expect(source).toContain('pages-reaction-glow')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('gives the composer more top clearance and compact navbar actions', () => {
|
||||
expect(source).toMatch(
|
||||
/\.pages-create-navbar\s*\{[^}]*--sky-safe-area-top:\s*112px/s,
|
||||
)
|
||||
expect(source).toMatch(/\.pages__compose-scroll\s*\{[^}]*top:\s*170px/s)
|
||||
expect(source).toMatch(
|
||||
/\.pages-create-navbar :deep\(\.sky-navbar__left\),[\s\S]*?background:\s*transparent;[\s\S]*?backdrop-filter:\s*none;/,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.pages-create-close,[\s\S]*?min-width:\s*44px[\s\S]*?height:\s*30px[\s\S]*?background:\s*transparent/,
|
||||
)
|
||||
expect(source).toMatch(/class="pages-create-close"[\s\S]*?<X :size="17"/)
|
||||
expect(source).toMatch(/clear[\s\S]*?class="pages-create-publish"/)
|
||||
})
|
||||
|
||||
it('places sign out as an icon in the profile navbar', () => {
|
||||
expect(source).toContain('class="pages__navbar-logout"')
|
||||
expect(source).toMatch(/clear[\s\S]*?class="pages__navbar-logout"/)
|
||||
expect(source).toContain(':aria-label="phone.t(\'Common.signOut\')"')
|
||||
expect(source).not.toContain('class="pages__logout-glass"')
|
||||
expect(source).not.toContain('class="pages__logout"')
|
||||
})
|
||||
})
|
||||
@@ -21,16 +21,17 @@ import {
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyGlass,
|
||||
SkyIcon,
|
||||
SkyNavbar,
|
||||
SkyAppPage,
|
||||
SkyPillNavigation,
|
||||
SkySearchbar,
|
||||
SkyTabBar,
|
||||
SkyTabButton,
|
||||
SkyToolbarPane,
|
||||
SkyScrollArea,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
} from '@/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
|
||||
@@ -85,6 +86,7 @@ const search = ref('')
|
||||
const category = ref<string>('all')
|
||||
const feedback = ref('')
|
||||
const reactionPending = ref(false)
|
||||
const reactionPulse = ref('')
|
||||
const onboardingReady = ref(false)
|
||||
const authMode = ref<'login' | 'register'>('login')
|
||||
const authUsername = ref('')
|
||||
@@ -180,6 +182,25 @@ const canSaveProfile = computed(() => {
|
||||
profileDraft.value.bio.trim().length <= 160
|
||||
)
|
||||
})
|
||||
const tabIndex = computed(() => (tab.value === 'feed' ? 0 : 2))
|
||||
|
||||
let reactionPulseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function triggerReactionPulse(id: string, kind: 'like' | 'save'): void {
|
||||
reactionPulse.value = ''
|
||||
void nextTick(() => {
|
||||
reactionPulse.value = `${id}:${kind}`
|
||||
if (reactionPulseTimer) clearTimeout(reactionPulseTimer)
|
||||
reactionPulseTimer = setTimeout(() => {
|
||||
reactionPulse.value = ''
|
||||
reactionPulseTimer = undefined
|
||||
}, 520)
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reactionPulseTimer) clearTimeout(reactionPulseTimer)
|
||||
})
|
||||
const profileAvatarUrl = computed(
|
||||
() =>
|
||||
selectedProfilePhoto.value?.url ??
|
||||
@@ -565,6 +586,7 @@ async function react(kind: 'like' | 'save'): Promise<void> {
|
||||
)
|
||||
selected.value.is_liked = active
|
||||
} else selected.value.is_saved = active
|
||||
if (active) triggerReactionPulse(selected.value.id, kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +603,8 @@ async function reactToPost(
|
||||
kind === 'like' ? !Boolean(post.is_liked) : !Boolean(post.is_saved)
|
||||
reactionPending.value = true
|
||||
try {
|
||||
await pages.react(post.id, kind, active)
|
||||
const success = await pages.react(post.id, kind, active)
|
||||
if (success && active) triggerReactionPulse(post.id, kind)
|
||||
} finally {
|
||||
reactionPending.value = false
|
||||
}
|
||||
@@ -753,11 +776,26 @@ onMounted(async () => {
|
||||
tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile',
|
||||
)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template v-if="tab === 'profile' && !profileEditing" #right>
|
||||
<SkyButton
|
||||
clear
|
||||
icon-only
|
||||
rounded
|
||||
small
|
||||
class="pages__navbar-logout"
|
||||
:aria-label="phone.t('Common.signOut')"
|
||||
@click="logoutDialogOpen = true"
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
</SkyButton>
|
||||
</template>
|
||||
</sky-navbar>
|
||||
|
||||
<section
|
||||
<SkyScrollArea
|
||||
v-if="onboardingReady"
|
||||
class="pages__content"
|
||||
:with-tabbar="isAuthenticated && Boolean(pages.profile?.exists)"
|
||||
:class="{
|
||||
'pages__content--gate': !isAuthenticated || !pages.profile?.exists,
|
||||
}"
|
||||
@@ -985,35 +1023,28 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
</sky-glass>
|
||||
<sky-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>
|
||||
</sky-glass>
|
||||
</template>
|
||||
<sky-glass
|
||||
v-if="!profileEditing && pages.profile?.exists"
|
||||
class="pages__logout-glass"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pages__logout"
|
||||
@click="logoutDialogOpen = true"
|
||||
<SkySegmented
|
||||
class="pages__segmented"
|
||||
:active-index="profileMode === 'own' ? 0 : 1"
|
||||
:aria-label="phone.t('Apps.localPages.profile')"
|
||||
:item-count="2"
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
{{ phone.t('Common.signOut') }}
|
||||
</button>
|
||||
</sky-glass>
|
||||
<SkySegmentedButton
|
||||
:active="profileMode === 'own'"
|
||||
@click="profileMode = 'own'"
|
||||
>
|
||||
{{ phone.t('Apps.localPages.myPosts') }}
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
:active="profileMode === 'saved'"
|
||||
@click="profileMode = 'saved'"
|
||||
>
|
||||
{{ phone.t('Apps.localPages.saved') }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1077,7 +1108,11 @@ onMounted(async () => {
|
||||
<div class="pages__post-foot">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: post.is_liked }"
|
||||
class="pages__reaction"
|
||||
:class="{
|
||||
active: post.is_liked,
|
||||
'is-pulsing': reactionPulse === `${post.id}:like`,
|
||||
}"
|
||||
:disabled="reactionPending"
|
||||
:aria-label="phone.t('Apps.localPages.likes')"
|
||||
@click="reactToPost(post, 'like')"
|
||||
@@ -1100,7 +1135,11 @@ onMounted(async () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: post.is_saved }"
|
||||
class="pages__reaction"
|
||||
:class="{
|
||||
active: post.is_saved,
|
||||
'is-pulsing': reactionPulse === `${post.id}:save`,
|
||||
}"
|
||||
:disabled="reactionPending"
|
||||
:aria-label="phone.t('Apps.localPages.save')"
|
||||
@click="reactToPost(post, 'save')"
|
||||
@@ -1121,65 +1160,47 @@ onMounted(async () => {
|
||||
><span>{{ phone.t('Apps.localPages.noPostsBody') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SkyScrollArea>
|
||||
|
||||
<sky-tab-bar
|
||||
<SkyPillNavigation
|
||||
v-if="isAuthenticated && pages.profile?.exists"
|
||||
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')"
|
||||
class="pages-navigation"
|
||||
:label="phone.t('Apps.localPages.name')"
|
||||
layout="full"
|
||||
>
|
||||
<sky-toolbar-pane class="pages__tab-pane">
|
||||
<sky-tab-button
|
||||
component="button"
|
||||
<SkySegmented
|
||||
class="pages-navigation__segments"
|
||||
:active-index="tabIndex"
|
||||
:aria-label="phone.t('Apps.localPages.name')"
|
||||
:item-count="3"
|
||||
navigation
|
||||
strong
|
||||
>
|
||||
<SkySegmentedButton
|
||||
:active="tab === 'feed'"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
class="pages-navigation__button"
|
||||
@click="selectTab('feed')"
|
||||
>
|
||||
<template #label
|
||||
><span class="pages__tab-label">{{
|
||||
phone.t('Apps.localPages.discover')
|
||||
}}</span></template
|
||||
>
|
||||
<template #icon
|
||||
><sky-icon><Compass :size="20" /></sky-icon
|
||||
></template>
|
||||
</sky-tab-button>
|
||||
<sky-tab-button
|
||||
component="button"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
<Compass :size="19" />
|
||||
<small>{{ phone.t('Apps.localPages.discover') }}</small>
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
class="pages-navigation__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"
|
||||
><sky-icon><Plus :size="21" /></sky-icon></span
|
||||
></template>
|
||||
</sky-tab-button>
|
||||
<sky-tab-button
|
||||
component="button"
|
||||
<span class="pages-navigation__create"><Plus :size="19" /></span>
|
||||
<small>{{ phone.t('Apps.localPages.create') }}</small>
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
:active="tab === 'profile'"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
class="pages-navigation__button"
|
||||
@click="selectTab('profile')"
|
||||
>
|
||||
<template #label
|
||||
><span class="pages__tab-label">{{
|
||||
phone.t('Apps.localPages.profile')
|
||||
}}</span></template
|
||||
>
|
||||
<template #icon
|
||||
><sky-icon><UserRound :size="20" /></sky-icon
|
||||
></template>
|
||||
</sky-tab-button>
|
||||
</sky-toolbar-pane>
|
||||
</sky-tab-bar>
|
||||
<UserRound :size="19" />
|
||||
<small>{{ phone.t('Apps.localPages.profile') }}</small>
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</SkyPillNavigation>
|
||||
</template>
|
||||
|
||||
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
|
||||
@@ -1206,7 +1227,11 @@ onMounted(async () => {
|
||||
v-else
|
||||
component="button"
|
||||
type="button"
|
||||
class="pages__detail-control"
|
||||
class="pages__detail-control pages__reaction"
|
||||
:class="{
|
||||
active: selected.is_saved,
|
||||
'is-pulsing': reactionPulse === `${selected.id}:save`,
|
||||
}"
|
||||
@click="react('save')"
|
||||
>
|
||||
<Bookmark
|
||||
@@ -1215,7 +1240,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</sky-glass>
|
||||
</header>
|
||||
<div class="pages__detail-scroll">
|
||||
<SkyScrollArea as="div" class="pages__detail-scroll">
|
||||
<div
|
||||
v-if="selected.images.length"
|
||||
class="pages__gallery"
|
||||
@@ -1272,11 +1297,15 @@ onMounted(async () => {
|
||||
>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</SkyScrollArea>
|
||||
<sky-glass class="pages__detail-actions">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: selected.is_liked }"
|
||||
class="pages__reaction"
|
||||
:class="{
|
||||
active: selected.is_liked,
|
||||
'is-pulsing': reactionPulse === `${selected.id}:like`,
|
||||
}"
|
||||
@click="react('like')"
|
||||
>
|
||||
<Heart
|
||||
@@ -1287,7 +1316,15 @@ onMounted(async () => {
|
||||
<button type="button" @click="sharePost(selected)">
|
||||
<Share2 :size="19" />{{ phone.t('Apps.easyShare.share') }}
|
||||
</button>
|
||||
<button type="button" @click="react('save')">
|
||||
<button
|
||||
type="button"
|
||||
class="pages__reaction"
|
||||
:class="{
|
||||
active: selected.is_saved,
|
||||
'is-pulsing': reactionPulse === `${selected.id}:save`,
|
||||
}"
|
||||
@click="react('save')"
|
||||
>
|
||||
<Bookmark
|
||||
:size="19"
|
||||
:fill="selected.is_saved ? 'currentColor' : 'none'"
|
||||
@@ -1304,8 +1341,6 @@ onMounted(async () => {
|
||||
<sky-navbar
|
||||
class="pages-create-navbar"
|
||||
center-title
|
||||
left-class="pages-create-action pages-create-action--close !min-w-[58px] !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(
|
||||
cityMarktListing
|
||||
@@ -1322,27 +1357,32 @@ onMounted(async () => {
|
||||
"
|
||||
>
|
||||
<template #left>
|
||||
<button
|
||||
<SkyButton
|
||||
clear
|
||||
icon-only
|
||||
rounded
|
||||
small
|
||||
class="pages-create-close"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click="closeCompose"
|
||||
>
|
||||
{{ phone.t('Common.close') }}
|
||||
</button>
|
||||
<X :size="17" />
|
||||
</SkyButton>
|
||||
</template>
|
||||
<template #right>
|
||||
<button
|
||||
<SkyButton
|
||||
clear
|
||||
rounded
|
||||
small
|
||||
class="pages-create-publish"
|
||||
type="button"
|
||||
:disabled="!canPublish"
|
||||
@click="publish"
|
||||
>
|
||||
{{ phone.t('Apps.localPages.publish') }}
|
||||
</button>
|
||||
</SkyButton>
|
||||
</template>
|
||||
</sky-navbar>
|
||||
<div class="pages__compose-scroll">
|
||||
<SkyScrollArea as="div" class="pages__compose-scroll">
|
||||
<sky-glass v-if="cityMarktListing" class="pages__citymarkt-source">
|
||||
<Store :size="19" />
|
||||
<span
|
||||
@@ -1459,7 +1499,7 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SkyScrollArea>
|
||||
</section>
|
||||
<AccountLogoutDialog
|
||||
v-model:opened="logoutDialogOpen"
|
||||
@@ -1481,6 +1521,8 @@ onMounted(async () => {
|
||||
--ink: #15191d;
|
||||
--panel: #20262c;
|
||||
--muted: #9ba4aa;
|
||||
--sky-app-accent: var(--yellow);
|
||||
--sky-app-accent-soft: rgb(255 214 62 / 16%);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 47px 0 24px;
|
||||
@@ -1553,7 +1595,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(125deg, #514005, #d99a00);
|
||||
color: #fff7d2;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 22px #0003;
|
||||
}
|
||||
.pages__hero div {
|
||||
@@ -1579,7 +1621,7 @@ onMounted(async () => {
|
||||
line-height: 1.35;
|
||||
}
|
||||
.pages__hero > svg {
|
||||
color: var(--yellow);
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 4px 6px #0005);
|
||||
}
|
||||
.pages__search {
|
||||
@@ -1783,22 +1825,18 @@ onMounted(async () => {
|
||||
font-size: 9px;
|
||||
}
|
||||
.pages__segmented {
|
||||
--sky-segmented-strong-highlight: #fff;
|
||||
width: 100%;
|
||||
margin: 3px 0 10px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
|
||||
padding: 4px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
background: var(--panel);
|
||||
border-radius: var(--sky-radius-pill);
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
}
|
||||
.pages__segmented button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
.pages__segmented button.active {
|
||||
background: var(--yellow);
|
||||
color: #17191a;
|
||||
font-weight: 800;
|
||||
min-height: 34px;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.pages__tabbar {
|
||||
position: absolute;
|
||||
@@ -2175,29 +2213,6 @@ onMounted(async () => {
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
.pages__tabbar {
|
||||
height: 58px;
|
||||
padding: 7px 7px 0;
|
||||
justify-content: space-around;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.pages--light .pages__tabbar {
|
||||
border-color: #00000012;
|
||||
}
|
||||
.pages__tabbar button {
|
||||
width: 54px;
|
||||
gap: 2px;
|
||||
}
|
||||
.pages__tabbar button > span {
|
||||
position: relative;
|
||||
}
|
||||
.pages__tabbar .create span {
|
||||
width: 37px;
|
||||
height: 30px;
|
||||
margin-top: -4px;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pages__header {
|
||||
height: 64px;
|
||||
padding: 6px 16px 8px;
|
||||
@@ -2267,6 +2282,41 @@ onMounted(async () => {
|
||||
.pages__post-foot button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pages__reaction svg {
|
||||
transform-origin: center;
|
||||
}
|
||||
.pages__reaction.is-pulsing svg {
|
||||
animation: pages-reaction-pop 480ms cubic-bezier(0.2, 0.9, 0.25, 1.35);
|
||||
}
|
||||
.pages__reaction.is-pulsing {
|
||||
animation: pages-reaction-glow 480ms ease-out;
|
||||
}
|
||||
@keyframes pages-reaction-pop {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
38% {
|
||||
transform: scale(1.48) rotate(-8deg);
|
||||
}
|
||||
68% {
|
||||
transform: scale(0.9) rotate(3deg);
|
||||
}
|
||||
}
|
||||
@keyframes pages-reaction-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 34%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 12px transparent;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pages__reaction.is-pulsing,
|
||||
.pages__reaction.is-pulsing svg {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.pages__post-foot button svg:last-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -2736,6 +2786,10 @@ onMounted(async () => {
|
||||
}
|
||||
.pages-hero-glass {
|
||||
margin-bottom: 10px;
|
||||
border-color: rgb(255 255 255 / 16%);
|
||||
background: linear-gradient(125deg, #514005, #d99a00);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 22px rgb(0 0 0 / 22%);
|
||||
}
|
||||
.pages__hero {
|
||||
height: 105px;
|
||||
@@ -2749,33 +2803,12 @@ onMounted(async () => {
|
||||
.pages-profile-glass {
|
||||
margin: 3px 0 10px;
|
||||
}
|
||||
.pages__logout-glass {
|
||||
margin: 10px 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pages__logout {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
background: transparent;
|
||||
color: #ff6b70;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pages__profile {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.pages-segmented-glass {
|
||||
padding: 4px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.pages__segmented {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.pages-post-glass {
|
||||
overflow: hidden;
|
||||
@@ -2784,82 +2817,88 @@ onMounted(async () => {
|
||||
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-navigation {
|
||||
--sky-app-accent: var(--yellow);
|
||||
}
|
||||
.pages__tab-label {
|
||||
.pages-navigation__segments {
|
||||
width: 100%;
|
||||
}
|
||||
.pages-navigation__button {
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
.pages-navigation__button small {
|
||||
display: block;
|
||||
max-width: 52px;
|
||||
max-width: 76px;
|
||||
overflow: hidden;
|
||||
font-size: 9.5px;
|
||||
line-height: 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
line-height: 10px;
|
||||
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;
|
||||
.pages-navigation__create {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
: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__navbar-logout {
|
||||
--sky-app-accent: #ff6b70;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pages-create-navbar {
|
||||
--sky-safe-area-top: 46px;
|
||||
--sky-safe-area-top: 112px;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.pages-create-action {
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.pages-create-action--close,
|
||||
.pages-create-action--publish {
|
||||
min-width: 58px;
|
||||
}
|
||||
.pages-create-close,
|
||||
.pages-create-publish {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
.pages-create-navbar :deep(.sky-navbar__left),
|
||||
.pages-create-navbar :deep(.sky-navbar__right) {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.pages-create-close,
|
||||
.pages-create-publish {
|
||||
min-width: 58px;
|
||||
padding: 0 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
width: auto;
|
||||
min-width: 44px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
border: 0;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pages-create-close {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.pages-create-publish {
|
||||
--sky-app-accent: var(--yellow);
|
||||
color: var(--yellow);
|
||||
}
|
||||
.pages-create-publish:disabled {
|
||||
opacity: 0.38;
|
||||
}
|
||||
.pages__compose-scroll {
|
||||
position: absolute;
|
||||
top: 104px;
|
||||
top: 170px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -12,7 +12,10 @@ const mailServerSource = readFileSync(
|
||||
'utf8',
|
||||
)
|
||||
const migrationSource = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/db_migrate.lua', import.meta.url),
|
||||
new URL(
|
||||
'../../../../sky_phone/source/server/db_migrate.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const clientSource = readFileSync(
|
||||
@@ -52,9 +55,7 @@ describe('MailApp Sky UI contract', () => {
|
||||
})
|
||||
|
||||
it('provides a Sky UI modal to create and open custom mailboxes', () => {
|
||||
const mailboxStart = source.indexOf(
|
||||
'class="mail-modal mail-mailbox-modal"',
|
||||
)
|
||||
const mailboxStart = source.indexOf('class="mail-modal mail-mailbox-modal"')
|
||||
const mailboxEnd = source.indexOf('</sky-sheet>', mailboxStart)
|
||||
const mailboxModal = source.slice(mailboxStart, mailboxEnd)
|
||||
|
||||
@@ -67,19 +68,19 @@ describe('MailApp Sky UI contract', () => {
|
||||
expect(source).toContain('v-model="mailboxName"')
|
||||
expect(source).toContain("phone.t('Apps.mail.mailboxLocation')")
|
||||
expect(source).toContain('@click="createMailbox"')
|
||||
expect(source).toContain('@click="openFolder(mailboxFolderKey(mailbox.id))"')
|
||||
expect(source).toContain(
|
||||
'@click="openFolder(mailboxFolderKey(mailbox.id))"',
|
||||
)
|
||||
expect(source).toContain('@click="moveMessageToMailbox(mailbox)"')
|
||||
expect(source).toContain('@click="moveMessageToDefaultMailbox"')
|
||||
expect(mailboxModal).toContain('class="mail-modal__nav-button"')
|
||||
expect(mailboxModal).toContain(":aria-label=\"phone.t('Common.cancel')\"")
|
||||
expect(mailboxModal).toContain(":aria-label=\"phone.t('Common.save')\"")
|
||||
expect(mailboxModal).toContain(':aria-label="phone.t(\'Common.cancel\')"')
|
||||
expect(mailboxModal).toContain(':aria-label="phone.t(\'Common.save\')"')
|
||||
expect(mailboxModal).toContain('<X :size="20" />')
|
||||
expect(mailboxModal).toContain('<Check :size="20" />')
|
||||
expect(mailboxModal).not.toContain('<sky-spinner')
|
||||
expect(mailboxModal).toContain('class="mail-mailbox-create__name"')
|
||||
expect(mailboxModal).toContain(
|
||||
'class="mail-mailbox-create__location-row"',
|
||||
)
|
||||
expect(mailboxModal).toContain('class="mail-mailbox-create__location-row"')
|
||||
expect(mailboxModal).not.toContain(' outline')
|
||||
expect(source).toMatch(
|
||||
/\.mail-mailbox-create__form\s*\{[^}]*overflow-y:\s*auto/s,
|
||||
@@ -121,9 +122,7 @@ describe('MailApp Sky UI contract', () => {
|
||||
})
|
||||
|
||||
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
|
||||
const filterStart = source.indexOf(
|
||||
'class="mail-modal mail-filter-modal"',
|
||||
)
|
||||
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
|
||||
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
|
||||
const filterScreen = source.slice(filterStart, filterEnd)
|
||||
|
||||
@@ -132,7 +131,9 @@ describe('MailApp Sky UI contract', () => {
|
||||
expect(filterScreen).toContain('@backdropclick="closeMailFilters"')
|
||||
expect(filterScreen).toContain('@escape="closeMailFilters"')
|
||||
expect(filterScreen).toContain('@swipeclose="closeMailFilters"')
|
||||
expect(filterScreen).toContain('class="mail-modal__navbar mail-filters__navbar"')
|
||||
expect(filterScreen).toContain(
|
||||
'class="mail-modal__navbar mail-filters__navbar"',
|
||||
)
|
||||
expect(filterScreen).toContain('@click="applyMailFilters"')
|
||||
expect(filterScreen).toContain("toggleDraftReadFilter('unread')")
|
||||
expect(filterScreen).toContain("toggleDraftReadFilter('read')")
|
||||
@@ -157,9 +158,11 @@ describe('MailApp Sky UI contract', () => {
|
||||
it('sends active filters through the paginated mail list contract', () => {
|
||||
expect(source).toContain('mail.setListFilters(mailFilters.value)')
|
||||
expect(mailServerSource).toContain('local function normalize_list_filters')
|
||||
expect(mailServerSource).toContain('local filters = normalize_list_filters(data.filters)')
|
||||
expect(mailServerSource).toContain("filters.read == \"unread\"")
|
||||
expect(mailServerSource).toContain("filters.address == \"to-me\"")
|
||||
expect(mailServerSource).toContain(
|
||||
'local filters = normalize_list_filters(data.filters)',
|
||||
)
|
||||
expect(mailServerSource).toContain('filters.read == "unread"')
|
||||
expect(mailServerSource).toContain('filters.address == "to-me"')
|
||||
expect(mailServerSource).toContain('m.`created_at` >= CURRENT_DATE()')
|
||||
})
|
||||
|
||||
@@ -241,9 +244,7 @@ describe('MailMarkdownEditor Sky toolbar contract', () => {
|
||||
expect(editorSource).toMatch(
|
||||
/\.mail-editor__tools\s*\{[^}]*padding-right:\s*var\(--sky-space-3\)[^}]*padding-left:\s*var\(--sky-space-3\)/s,
|
||||
)
|
||||
expect(editorSource).toMatch(
|
||||
/:deep\(\.tiptap p\)\s*\{[^}]*margin:\s*0;/s,
|
||||
)
|
||||
expect(editorSource).toMatch(/:deep\(\.tiptap p\)\s*\{[^}]*margin:\s*0;/s)
|
||||
expect(editorSource).not.toContain(':deep(.tiptap p:last-child)')
|
||||
})
|
||||
})
|
||||
@@ -262,16 +263,16 @@ describe('Mail list toolbar styling contract', () => {
|
||||
expect(source).toMatch(
|
||||
/\.mail-folders-toolbar\s+:deep\(\.sky-fab\)\s*\{[^}]*border:\s*1px solid var\(--sky-hairline\)/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.mail-folder-row\s*\{[^}]*min-height:\s*52px/s,
|
||||
)
|
||||
expect(source).toMatch(/\.mail-folder-row\s*\{[^}]*min-height:\s*52px/s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mail custom mailbox server contract', () => {
|
||||
it('persists account-owned mailboxes and custom entry placement', () => {
|
||||
expect(migrationSource).toContain('name = "sky_phone_mailboxes"')
|
||||
expect(migrationSource).toContain('{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" }')
|
||||
expect(migrationSource).toContain(
|
||||
'{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" }',
|
||||
)
|
||||
expect(mailServerSource).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:mail:create-mailbox"',
|
||||
)
|
||||
@@ -281,9 +282,7 @@ describe('Mail custom mailbox server contract', () => {
|
||||
expect(mailServerSource).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:mail:move"',
|
||||
)
|
||||
expect(mailServerSource).toContain(
|
||||
'WHERE `id` = ? AND `account_id` = ?',
|
||||
)
|
||||
expect(mailServerSource).toContain('WHERE `id` = ? AND `account_id` = ?')
|
||||
expect(mailServerSource).toContain(
|
||||
'WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL',
|
||||
)
|
||||
@@ -300,3 +299,58 @@ describe('Mail custom mailbox server contract', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function functionSource(name: string, nextName: string): string {
|
||||
const start = source.indexOf(`async function ${name}`)
|
||||
const end = source.indexOf(`async function ${nextName}`, start + 1)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('MailApp contact compose deep-link contract', () => {
|
||||
it('normalizes a compose=1 recipient only after mail authentication', () => {
|
||||
const consumeRequest = functionSource(
|
||||
'consumeContactComposeRequest',
|
||||
'closeCompose',
|
||||
)
|
||||
|
||||
expect(source).toContain('const route = useRoute()')
|
||||
expect(source).toContain('const router = useRouter()')
|
||||
expect(consumeRequest).toContain(
|
||||
"if (!authenticated.value || route.query.compose !== '1') return",
|
||||
)
|
||||
expect(consumeRequest).toContain("typeof route.query.to === 'string'")
|
||||
expect(consumeRequest).toContain('normalizeMailAddress(route.query.to)')
|
||||
expect(consumeRequest).toContain(
|
||||
"beginCompose({ body: '', recipients: [requestedRecipient], subject: '' })",
|
||||
)
|
||||
})
|
||||
|
||||
it('consumes the route after handling the request', () => {
|
||||
const consumeRequest = functionSource(
|
||||
'consumeContactComposeRequest',
|
||||
'closeCompose',
|
||||
)
|
||||
const composeIndex = consumeRequest.indexOf('beginCompose(')
|
||||
const replaceIndex = consumeRequest.indexOf(
|
||||
"await router.replace('/apps/mail')",
|
||||
)
|
||||
|
||||
expect(replaceIndex).toBeGreaterThan(composeIndex)
|
||||
})
|
||||
|
||||
it('retries the pending compose request after auth and on authenticated mount', () => {
|
||||
const submitAuth = functionSource('submitAuth', 'signOut')
|
||||
const mounted = source.slice(
|
||||
source.indexOf('onMounted(() => {'),
|
||||
source.indexOf('onBeforeUnmount(() => {'),
|
||||
)
|
||||
|
||||
expect(submitAuth).toMatch(
|
||||
/if \(!response\.success\)[\s\S]*await consumeContactComposeRequest\(\)/,
|
||||
)
|
||||
expect(mounted).toContain('void consumeContactComposeRequest()')
|
||||
expect(source).toMatch(
|
||||
/watch\(authenticated,[\s\S]*isAuthenticated && !wasAuthenticated && !submitting\.value[\s\S]*consumeContactComposeRequest\(\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import mailIcon from '@/assets/img/app-icons/mail.webp'
|
||||
import MailMarkdownEditor, {
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
MAIL_ADDRESS_INPUT_MAX_LENGTH,
|
||||
MAIL_RECIPIENT_INPUT_MAX_LENGTH,
|
||||
mailPlainText,
|
||||
normalizeMailAddress,
|
||||
parseMailRecipients,
|
||||
} from '@/utils/mail'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
@@ -106,6 +108,8 @@ const MAIL_DELETE_BATCH_SIZE = 50
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authMode = ref<AuthMode>('login')
|
||||
const authEmail = ref('')
|
||||
const authPassword = ref('')
|
||||
@@ -613,6 +617,7 @@ async function submitAuth(): Promise<void> {
|
||||
authPassword.value = ''
|
||||
authConfirm.value = ''
|
||||
screen.value = 'folders'
|
||||
await consumeContactComposeRequest()
|
||||
}
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
@@ -795,6 +800,18 @@ function beginCompose(draft?: MailComposeDraft): void {
|
||||
screen.value = 'compose'
|
||||
}
|
||||
|
||||
async function consumeContactComposeRequest(): Promise<void> {
|
||||
if (!authenticated.value || route.query.compose !== '1') return
|
||||
const requestedRecipient =
|
||||
typeof route.query.to === 'string'
|
||||
? normalizeMailAddress(route.query.to)
|
||||
: null
|
||||
if (requestedRecipient) {
|
||||
beginCompose({ body: '', recipients: [requestedRecipient], subject: '' })
|
||||
}
|
||||
await router.replace('/apps/mail')
|
||||
}
|
||||
|
||||
async function closeCompose(): Promise<void> {
|
||||
await saveDraftNow()
|
||||
screen.value = composeReturn.value
|
||||
@@ -964,7 +981,16 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('message', onMailEvent))
|
||||
watch(authenticated, (isAuthenticated, wasAuthenticated) => {
|
||||
if (isAuthenticated && !wasAuthenticated && !submitting.value) {
|
||||
void consumeContactComposeRequest()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onMailEvent)
|
||||
void consumeContactComposeRequest()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMailEvent)
|
||||
|
||||
@@ -6,6 +6,10 @@ const source = readFileSync(
|
||||
new URL('./MessagesApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const styles = readFileSync(
|
||||
new URL('../../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('MessagesApp Sky UI contract', () => {
|
||||
it('uses first-party Sky UI without direct Konsta markup', () => {
|
||||
@@ -15,21 +19,109 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
expect(source).toContain('<SkyNavbar')
|
||||
expect(source).toContain('<SkyScrollArea')
|
||||
expect(source).toContain('<SkySearchbar')
|
||||
expect(source).toContain('<SkySegmented')
|
||||
expect(source).toContain('<SkyDropdown')
|
||||
expect(source).toContain('<SkyFab')
|
||||
expect(source).toContain('<SkyMessages')
|
||||
expect(source).toContain('<SkyMessagebar')
|
||||
expect(source).toContain('<SkyPillNavigation')
|
||||
expect(source).toContain('<SkySettingsGroup')
|
||||
expect(source).toContain('<SkySettingsRow')
|
||||
expect(source).toContain('<SkySheet')
|
||||
expect(source).toContain('<SkyToolbar')
|
||||
expect(source).not.toContain('<SkySegmented')
|
||||
})
|
||||
|
||||
it('keeps inbox search focused on text and exposes clear filters', () => {
|
||||
const inboxStart = source.indexOf('messages-sky-inbox')
|
||||
it('uses the anchored central dropdown for sort, filter, and edit actions', () => {
|
||||
const inboxStart = source.indexOf(
|
||||
'class="messages-sky-page messages-sky-inbox"',
|
||||
)
|
||||
const composeStart = source.indexOf('v-else-if="composing"')
|
||||
const inbox = source.slice(inboxStart, composeStart)
|
||||
|
||||
expect(inbox).toContain('<SkySearchbar')
|
||||
expect(inbox).not.toContain('messages-inbox-search__voice')
|
||||
expect(inbox).toContain('Apps.messages.allMessages')
|
||||
expect(inbox).toContain('Apps.messages.unreadMessages')
|
||||
expect(source).toContain("id: 'sort-newest'")
|
||||
expect(source).toContain("id: 'sort-oldest'")
|
||||
expect(source).toContain("id: 'filter-all'")
|
||||
expect(source).toContain("id: 'filter-unread'")
|
||||
expect(source).toContain("id: 'edit'")
|
||||
expect(source).toContain("phone.t('Apps.messages.sortNewest')")
|
||||
expect(source).toContain("phone.t('Apps.messages.sortOldest')")
|
||||
expect(source).toContain("phone.t('Apps.messages.allMessages')")
|
||||
expect(source).toContain("phone.t('Apps.messages.unreadMessages')")
|
||||
expect(source).toContain("phone.t('Common.edit')")
|
||||
expect(source).toContain("group: 'sort'")
|
||||
expect(source).toContain("group: 'filter'")
|
||||
expect(source).toContain("phone.t('Apps.messages.sortLabel')")
|
||||
expect(source).toContain("phone.t('Apps.messages.filterLabel')")
|
||||
expect(source).toContain(
|
||||
'inboxMenuTarget.value = event.currentTarget as HTMLElement',
|
||||
)
|
||||
expect(source).toContain("if (id === 'edit') toggleListEditing()")
|
||||
|
||||
expect(inbox).toContain('class="messages-sky-inbox-menu-trigger"')
|
||||
expect(inbox).toContain('aria-haspopup="menu"')
|
||||
expect(inbox).toContain(':aria-expanded="inboxMenuOpened"')
|
||||
expect(inbox).toContain('@click="openInboxMenu"')
|
||||
expect(inbox).toContain('id="messages-inbox-menu"')
|
||||
expect(inbox).toContain(':items="inboxMenuItems"')
|
||||
expect(inbox).toContain(':opened="inboxMenuOpened"')
|
||||
expect(inbox).toContain(':target="inboxMenuTarget"')
|
||||
expect(inbox).toContain('@backdropclick="dismissInboxMenu"')
|
||||
expect(inbox).toContain('@escape="dismissInboxMenu"')
|
||||
expect(inbox).toContain('@positionerror="dismissInboxMenu"')
|
||||
expect(inbox).toContain('@select="selectInboxMenuItem"')
|
||||
})
|
||||
|
||||
it('places search and the compose action together in the bottom toolbar', () => {
|
||||
const inboxStart = source.indexOf(
|
||||
'class="messages-sky-page messages-sky-inbox"',
|
||||
)
|
||||
const composeStart = source.indexOf('v-else-if="composing"')
|
||||
const inbox = source.slice(inboxStart, composeStart)
|
||||
const scrollEnd = inbox.indexOf('</SkyScrollArea>')
|
||||
const toolbarStart = inbox.indexOf('<SkyToolbar')
|
||||
const toolbarEnd = inbox.indexOf('</SkyToolbar>', toolbarStart)
|
||||
const toolbar = inbox.slice(toolbarStart, toolbarEnd)
|
||||
const searchbarStart = toolbar.indexOf('<SkySearchbar')
|
||||
const fabStart = toolbar.indexOf('<SkyFab')
|
||||
|
||||
expect(toolbarStart).toBeGreaterThan(scrollEnd)
|
||||
expect(toolbar).toContain('class="messages-sky-inbox-toolbar"')
|
||||
expect(toolbar).toContain('component="footer"')
|
||||
expect(searchbarStart).toBeGreaterThan(-1)
|
||||
expect(fabStart).toBeGreaterThan(searchbarStart)
|
||||
expect(toolbar).toContain('v-model="search"')
|
||||
expect(toolbar).toContain('variant="neutral"')
|
||||
expect(toolbar).toContain('@click="beginCompose"')
|
||||
expect(toolbar).toContain('<SquarePen :size="21" />')
|
||||
expect(inbox).not.toContain('messages-sky-compose-navigation')
|
||||
})
|
||||
|
||||
it('uses the results-empty state for search and unread filters', () => {
|
||||
expect(source).toContain('search || showUnreadOnly')
|
||||
expect(source).toContain('v-if="!search && !showUnreadOnly" #actions')
|
||||
})
|
||||
|
||||
it('keeps the unread marker in the media slot left of the avatar', () => {
|
||||
const inboxStart = source.indexOf(
|
||||
'class="messages-sky-page messages-sky-inbox"',
|
||||
)
|
||||
const composeStart = source.indexOf('v-else-if="composing"')
|
||||
const inbox = source.slice(inboxStart, composeStart)
|
||||
const mediaSlotStart = inbox.indexOf('<template #media>')
|
||||
const unreadSlot = inbox.indexOf(
|
||||
'class="messages-sky-unread-slot"',
|
||||
mediaSlotStart,
|
||||
)
|
||||
const avatar = inbox.indexOf('class="messages-avatar"', mediaSlotStart)
|
||||
|
||||
expect(mediaSlotStart).toBeGreaterThan(-1)
|
||||
expect(unreadSlot).toBeGreaterThan(mediaSlotStart)
|
||||
expect(avatar).toBeGreaterThan(unreadSlot)
|
||||
expect(inbox).toContain(
|
||||
"'is-visible': !editingList && conversation.unread > 0",
|
||||
)
|
||||
expect(source).toContain('count: String(conversation.unread)')
|
||||
expect(inbox).toContain("'aria-pressed': selectedNumbers.includes(")
|
||||
})
|
||||
|
||||
it('uses one compact scroll region and an in-flow composer in threads', () => {
|
||||
@@ -48,8 +140,92 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('provides a full-size back target and a compact recipient row', () => {
|
||||
expect(source).toContain('back-appearance="plain"')
|
||||
it('keeps add and text entry in separate glass surfaces', () => {
|
||||
expect(source).toContain('class="messages-sky-composer-row"')
|
||||
expect(source).toMatch(
|
||||
/<SkyGlass[\s\S]*?class="messages-sky-messagebar__action messages-sky-messagebar__plus"[\s\S]*?<\/SkyGlass>[\s\S]*?<SkyGlass[\s\S]*?class="messages-sky-composer-pill"/,
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips up to six media items into a removable composer preview', () => {
|
||||
expect(source).toContain('const MAX_PENDING_ATTACHMENTS = 6')
|
||||
expect(source).toContain(
|
||||
'pendingAttachments: [...pendingAttachments.value]',
|
||||
)
|
||||
expect(source).toContain('messageMedia.consumeMany<MessagesMediaContext>')
|
||||
expect(source).toContain('class="messages-pending-media"')
|
||||
expect(source).toContain('@click="removePendingAttachment(media.id)"')
|
||||
expect(source).toContain('Apps.photos.videoAlt')
|
||||
expect(source).not.toMatch(/await sendAttachment\([\s\S]*?media\.mediaType/)
|
||||
})
|
||||
|
||||
it('sends selected media in order and captions the last item', () => {
|
||||
expect(source).toContain(
|
||||
'for (const [index, media] of queuedAttachments.entries())',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'index === queuedAttachments.length - 1 ? body : undefined',
|
||||
)
|
||||
expect(source).toContain("media.mediaType === 'photo' ? 'image' : 'video'")
|
||||
})
|
||||
|
||||
it('normalizes voice waveforms and uses the regular unfilled send icon', () => {
|
||||
expect(source).toContain(
|
||||
'compressWaveformSamples(recordingSamples, WAVEFORM_SAMPLES)',
|
||||
)
|
||||
expect(source).toContain('class="messages-recorder__send"')
|
||||
expect(source).toContain('<ArrowUpCircle :size="27" :stroke-width="2.4" />')
|
||||
expect(source).not.toContain('fill="currentColor" />\n </SkyButton>')
|
||||
expect(source).toContain('const recordingStarting = ref(false)')
|
||||
expect(source).toContain('if (requestId !== recordingRequestId)')
|
||||
expect(source).toContain('requestedStream.getTracks().forEach')
|
||||
expect(source).toContain(':disabled="sending || recordingStarting"')
|
||||
})
|
||||
|
||||
it('uses provider dimensions for proportional GIF picker results', () => {
|
||||
expect(source).toContain(
|
||||
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
|
||||
const sheetStart = source.indexOf('class="messages-media-picker-sheet"')
|
||||
const sheetEnd = source.indexOf('</SkySheet>', sheetStart)
|
||||
const sheet = source.slice(sheetStart, sheetEnd)
|
||||
|
||||
expect(sheetStart).toBeGreaterThan(-1)
|
||||
expect(sheetEnd).toBeGreaterThan(sheetStart)
|
||||
expect(sheet).toContain(
|
||||
':opened="activeCanMessage && attachmentPicker !== null"',
|
||||
)
|
||||
expect(sheet).toContain('swipe-to-close')
|
||||
expect(sheet).toContain('grabber-clickable')
|
||||
expect(sheet).toContain('@backdropclick="closeAttachmentPicker"')
|
||||
expect(sheet).toContain('@escape="closeAttachmentPicker"')
|
||||
expect(sheet).toContain('@grabberclick="closeAttachmentPicker"')
|
||||
expect(sheet).toContain('@swipeclose="closeAttachmentPicker"')
|
||||
expect(sheet).toContain('v-if="attachmentPicker === \'contacts\'"')
|
||||
expect(sheet).toContain('@click="sendContact(contact)"')
|
||||
expect(source).toContain('function closeAttachmentPicker(): void')
|
||||
expect(styles).toMatch(
|
||||
/\.messages-media-picker-sheet \.sky-sheet__panel\s*\{[^}]*border-radius:\s*30px 30px 0 0/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a surface back target in the thread and a compact recipient row', () => {
|
||||
const threadStart = source.indexOf(
|
||||
'class="messages-sky-page messages-sky-thread"',
|
||||
)
|
||||
const contactProfileStart = source.indexOf(
|
||||
'v-if="contactDetailsOpen"',
|
||||
threadStart,
|
||||
)
|
||||
const threadHeader = source.slice(threadStart, contactProfileStart)
|
||||
|
||||
expect(threadHeader).toContain('class="messages-sky-thread-navbar"')
|
||||
expect(threadHeader).toContain('show-back')
|
||||
expect(threadHeader).toContain('back-appearance="surface"')
|
||||
expect(threadHeader).toContain('@back="goBack"')
|
||||
expect(source).toContain('class="messages-recipient-field"')
|
||||
expect(source).toContain('layout="inline"')
|
||||
expect(source).toMatch(
|
||||
@@ -57,17 +233,103 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the SMS compose action in the inbox navbar', () => {
|
||||
const inboxStart = source.indexOf('messages-sky-inbox')
|
||||
const composeStart = source.indexOf('v-else-if="composing"')
|
||||
const inbox = source.slice(inboxStart, composeStart)
|
||||
it('renders contact details read-only with icon actions and contact routing', () => {
|
||||
const contactOverlayClass = source.indexOf(
|
||||
'class="messages-contact-overlay"',
|
||||
)
|
||||
const contactProfileStart = source.lastIndexOf(
|
||||
'<SkyAppPage',
|
||||
contactOverlayClass,
|
||||
)
|
||||
const threadScrollStart = source.indexOf(
|
||||
'class="messages-sky-thread-scroll"',
|
||||
contactProfileStart,
|
||||
)
|
||||
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
|
||||
|
||||
expect(inbox).toContain('@click="beginCompose"')
|
||||
expect(inbox).toContain('<SquarePen')
|
||||
expect(inbox).not.toContain('messages-sky-compose-navigation')
|
||||
expect(contactProfileStart).toBeGreaterThan(-1)
|
||||
expect(threadScrollStart).toBeGreaterThan(contactProfileStart)
|
||||
expect(contactProfile).toContain('component="section"')
|
||||
expect(contactProfile).toContain('back-appearance="surface"')
|
||||
expect(contactProfile).toContain('activeContact?.avatar_url')
|
||||
expect(contactProfile).toContain('<SkySettingsGroup')
|
||||
expect(contactProfile).toContain('<SkySettingsRow')
|
||||
expect(contactProfile).toContain('activeContact?.organization')
|
||||
expect(contactProfile).toContain(':value="activeContact.organization"')
|
||||
expect(contactProfile).toContain('activeContact?.notes')
|
||||
expect(contactProfile).not.toContain('<SkyField')
|
||||
|
||||
expect(contactProfile).toMatch(
|
||||
/<SkyButton\s+v-if="activeContact\?\.canCall !== false"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="callActiveContact"/,
|
||||
)
|
||||
expect(contactProfile).toMatch(
|
||||
/<SkyButton\s+v-if="activeCanMessage"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="contactDetailsOpen = false"/,
|
||||
)
|
||||
expect(contactProfile).toMatch(
|
||||
/<SkyButton\s+v-if="activeContactEmail"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="mailActiveContact"/,
|
||||
)
|
||||
expect(contactProfile).toContain('<PhoneIcon :size="22" />')
|
||||
expect(contactProfile).toContain('<MessageCircle :size="22" />')
|
||||
expect(contactProfile).toContain('<Mail :size="22" />')
|
||||
|
||||
expect(contactProfile).toContain('Apps.messages.showInContacts')
|
||||
expect(contactProfile).toContain('Apps.messages.addContact')
|
||||
expect(contactProfile).toContain('@activate="openActiveContactInPhone"')
|
||||
expect(source).toContain("path: '/apps/phone'")
|
||||
expect(source).toContain('{ contactId: activeContact.value.id }')
|
||||
expect(source).toContain('{ newContactNumber: messages.activeNumber }')
|
||||
expect(source).toContain("path: '/apps/mail'")
|
||||
expect(source).toContain(
|
||||
"query: { compose: '1', to: activeContactEmail.value }",
|
||||
)
|
||||
|
||||
expect(contactProfile).not.toContain('Common.edit')
|
||||
expect(source).not.toContain('contactEditing')
|
||||
expect(source).not.toContain('saveContactDetails')
|
||||
expect(source).not.toContain('deleteActiveContact')
|
||||
expect(source).not.toContain('Apps.messages.deleteContact')
|
||||
expect(contactProfile).not.toContain('<Pencil')
|
||||
expect(source).toContain(':inert="contactDetailsOpen || undefined"')
|
||||
expect(source).toContain(':aria-hidden="contactDetailsOpen"')
|
||||
})
|
||||
|
||||
it('confirms contact blocking in a dismissible dialog', () => {
|
||||
const contactOverlayClass = source.indexOf(
|
||||
'class="messages-contact-overlay"',
|
||||
)
|
||||
const contactProfileStart = source.lastIndexOf(
|
||||
'<SkyAppPage',
|
||||
contactOverlayClass,
|
||||
)
|
||||
const threadScrollStart = source.indexOf(
|
||||
'class="messages-sky-thread-scroll"',
|
||||
contactProfileStart,
|
||||
)
|
||||
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
|
||||
const blockDialogOpened = source.indexOf(':opened="blockDialogOpened"')
|
||||
const blockDialogStart = source.lastIndexOf('<SkyDialog', blockDialogOpened)
|
||||
const toastStart = source.indexOf('<SkyToast', blockDialogStart)
|
||||
const blockDialog = source.slice(blockDialogStart, toastStart)
|
||||
|
||||
expect(contactProfile).toContain('kind="action"')
|
||||
expect(contactProfile).toContain('tone="danger"')
|
||||
expect(contactProfile).toContain('Apps.messages.blockContact')
|
||||
expect(contactProfile).toContain('@activate="confirmBlockActiveContact"')
|
||||
expect(blockDialogStart).toBeGreaterThan(-1)
|
||||
expect(blockDialog).toContain('@backdropclick="blockDialogOpened = false"')
|
||||
expect(blockDialog).toContain('@escape="blockDialogOpened = false"')
|
||||
expect(blockDialog).toContain('Apps.messages.blockContactTitle')
|
||||
expect(blockDialog).toContain('Apps.messages.blockContactBody')
|
||||
expect(blockDialog).toContain('@click="blockActiveContact"')
|
||||
expect(source).toContain(
|
||||
'const response = await calls.blockNumber(messages.activeNumber)',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps SMS conversations and recipients in flat iMessage-style lists', () => {
|
||||
expect(source).not.toMatch(
|
||||
/\.messages-sky-page\s*\{[^}]*--sky-page-gutter/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/v-if="filteredConversations.length"\s+flush\s+class="messages-sky-conversation-list"/,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./NotesApp.vue', import.meta.url), 'utf8')
|
||||
const source = readFileSync(
|
||||
new URL('./NotesApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const menuSource = source.slice(
|
||||
source.indexOf('<SkyActionSheet'),
|
||||
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
|
||||
|
||||
@@ -18,4 +18,46 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
expect(source).not.toContain('<sky-segmented')
|
||||
})
|
||||
|
||||
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
|
||||
const mounted = source.slice(
|
||||
source.indexOf('onMounted(async () => {'),
|
||||
source.indexOf('onBeforeUnmount(() => {'),
|
||||
)
|
||||
const bootstrapIndex = mounted.indexOf('await calls.bootstrap()')
|
||||
const contactRequestIndex = mounted.indexOf(
|
||||
"typeof route.query.contactId === 'string'",
|
||||
)
|
||||
|
||||
expect(bootstrapIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(contactRequestIndex).toBeGreaterThan(bootstrapIndex)
|
||||
expect(mounted).toContain(
|
||||
'(contact) => contact.id === route.query.contactId',
|
||||
)
|
||||
expect(mounted).toContain("tab.value = 'contacts'")
|
||||
expect(mounted).toContain('openRecentDetail(requestedContact.phone_number)')
|
||||
expect(mounted).toMatch(
|
||||
/route\.query\.contactId[\s\S]*await router\.replace\('\/apps\/phone'\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens new-contact deep links in the contact editor and consumes the query', () => {
|
||||
const mounted = source.slice(
|
||||
source.indexOf('onMounted(async () => {'),
|
||||
source.indexOf('onBeforeUnmount(() => {'),
|
||||
)
|
||||
const bootstrapIndex = mounted.indexOf('await calls.bootstrap()')
|
||||
const newContactRequestIndex = mounted.indexOf(
|
||||
"typeof route.query.newContactNumber === 'string'",
|
||||
)
|
||||
|
||||
expect(bootstrapIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(newContactRequestIndex).toBeGreaterThan(bootstrapIndex)
|
||||
expect(mounted).toContain(
|
||||
'openContact(undefined, route.query.newContactNumber)',
|
||||
)
|
||||
expect(mounted).toMatch(
|
||||
/route\.query\.newContactNumber[\s\S]*await router\.replace\('\/apps\/phone'\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
@@ -60,6 +60,7 @@ type ContactPhotoContext = {
|
||||
avatarMediaId: number | null
|
||||
avatarUrl: string
|
||||
contactId?: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
notes: string
|
||||
@@ -72,6 +73,7 @@ const calls = useCallsStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const mediaPicker = useMessageMediaStore()
|
||||
const messages = useMessagesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tab = ref<PhoneTab>('recents')
|
||||
const query = ref('')
|
||||
@@ -86,6 +88,7 @@ const editingContact = ref<PhoneContact | null>(null)
|
||||
const contactFirstName = ref('')
|
||||
const contactLastName = ref('')
|
||||
const contactOrganization = ref('')
|
||||
const contactEmail = ref('')
|
||||
const contactNotes = ref('')
|
||||
const contactNumber = ref('')
|
||||
const contactAvatarMediaId = ref<number | null>(null)
|
||||
@@ -258,6 +261,7 @@ function openContact(contact?: PhoneContact, number = ''): void {
|
||||
contactFirstName.value = nameParts.shift() ?? ''
|
||||
contactLastName.value = nameParts.join(' ')
|
||||
contactOrganization.value = contact?.organization ?? ''
|
||||
contactEmail.value = contact?.email ?? ''
|
||||
contactNotes.value = contact?.notes ?? ''
|
||||
contactNumber.value = contact?.phone_number ?? number
|
||||
contactAvatarMediaId.value = contact?.avatar_media_id ?? null
|
||||
@@ -271,6 +275,7 @@ function openContactPhotoPicker(source: 'camera' | 'photos'): void {
|
||||
avatarMediaId: contactAvatarMediaId.value,
|
||||
avatarUrl: contactAvatarUrl.value,
|
||||
contactId: editingContact.value?.id,
|
||||
email: contactEmail.value,
|
||||
firstName: contactFirstName.value,
|
||||
lastName: contactLastName.value,
|
||||
notes: contactNotes.value,
|
||||
@@ -386,6 +391,7 @@ async function saveContact(): Promise<void> {
|
||||
}
|
||||
const response = await calls.saveContact({
|
||||
avatarMediaId: contactAvatarMediaId.value,
|
||||
email: contactEmail.value.trim(),
|
||||
id: editingContact.value?.id,
|
||||
name,
|
||||
notes: contactNotes.value.trim(),
|
||||
@@ -672,6 +678,7 @@ onMounted(async () => {
|
||||
: null
|
||||
contactFirstName.value = context?.firstName ?? ''
|
||||
contactLastName.value = context?.lastName ?? ''
|
||||
contactEmail.value = context?.email ?? ''
|
||||
contactNotes.value = context?.notes ?? ''
|
||||
contactOrganization.value = context?.organization ?? ''
|
||||
contactNumber.value = context?.phoneNumber ?? ''
|
||||
@@ -683,6 +690,19 @@ onMounted(async () => {
|
||||
}
|
||||
tab.value = 'contacts'
|
||||
editorOpened.value = true
|
||||
} else if (typeof route.query.contactId === 'string') {
|
||||
const requestedContact = calls.contacts.find(
|
||||
(contact) => contact.id === route.query.contactId,
|
||||
)
|
||||
if (requestedContact) {
|
||||
tab.value = 'contacts'
|
||||
openRecentDetail(requestedContact.phone_number)
|
||||
}
|
||||
await router.replace('/apps/phone')
|
||||
} else if (typeof route.query.newContactNumber === 'string') {
|
||||
tab.value = 'contacts'
|
||||
openContact(undefined, route.query.newContactNumber)
|
||||
await router.replace('/apps/phone')
|
||||
}
|
||||
callClock = window.setInterval(updateCallElapsed, 500)
|
||||
})
|
||||
@@ -1572,6 +1592,14 @@ onBeforeUnmount(() => {
|
||||
:readonly="editingContact?.readonly"
|
||||
@input="contactOrganization = eventValue($event)"
|
||||
/>
|
||||
<sky-field
|
||||
:value="contactEmail"
|
||||
type="email"
|
||||
:placeholder="phone.t('Apps.phone.mail')"
|
||||
autocomplete="email"
|
||||
:readonly="editingContact?.readonly"
|
||||
@input="contactEmail = eventValue($event)"
|
||||
/>
|
||||
</sky-list>
|
||||
|
||||
<sky-list strong inset class="phone-contact-editor__number-list">
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('SettingsApp Sky UI contract', () => {
|
||||
expect(source).toContain('<SkyScrollArea')
|
||||
expect(source).toContain('<SkySettingsGroup')
|
||||
expect(source).toContain('<SkySettingsRow')
|
||||
expect(source).toContain('<SkySettingsRangeRow')
|
||||
expect(source).toContain('<template #leading><Volume1 /></template>')
|
||||
expect(source).toContain('<template #trailing><Volume2 /></template>')
|
||||
expect(source).toContain(
|
||||
`<template v-if="activeView === 'account' && !account.email" #right>`,
|
||||
)
|
||||
@@ -36,9 +39,14 @@ describe('SettingsApp Sky UI contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('offers photo, camera, recent, and two-column built-in wallpaper choices', () => {
|
||||
it('offers photo, camera, configured custom upload, recent, and built-in wallpaper choices', () => {
|
||||
expect(source).toContain("openWallpaperMedia('photos')")
|
||||
expect(source).toContain("openWallpaperMedia('camera')")
|
||||
expect(source).toContain('customWallpaperUploadAvailable')
|
||||
expect(source).toContain('openWallpaperCustomUpload')
|
||||
expect(source).toContain("nuiCall<MediaConfig>('media:config')")
|
||||
expect(source).toContain('nuiCall<MediaImportSources>')
|
||||
expect(source).toContain("'media:import:sources'")
|
||||
expect(source).toContain('`settings:wallpaper:${wallpaperTarget.value}`')
|
||||
expect(source).toContain("wallpaperTarget === 'home'")
|
||||
expect(source).toContain("wallpaperTarget === 'lock'")
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
Signal,
|
||||
Smartphone,
|
||||
Sun,
|
||||
Upload,
|
||||
UserRound,
|
||||
Volume1,
|
||||
Volume2,
|
||||
Wifi,
|
||||
} from 'lucide-vue-next'
|
||||
@@ -47,6 +49,7 @@ import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
} from '@/types/apps'
|
||||
import type { MediaConfig, MediaImportSources } from '@/types/media'
|
||||
import {
|
||||
filterMailAddressInput,
|
||||
MAIL_ADDRESS_INPUT_MAX_LENGTH,
|
||||
@@ -159,6 +162,8 @@ const simEjectOpened = ref(false)
|
||||
const factoryResetting = ref(false)
|
||||
const factoryResetProgress = ref(0)
|
||||
const wallpaperTarget = ref<WallpaperTarget>('home')
|
||||
const customWallpaperUploadAvailable = ref(false)
|
||||
let wallpaperMediaConfigLoaded = false
|
||||
const selectedFrameColor = computed(
|
||||
() => PHONE_FRAME_COLORS[phone.preferences.settings.frame],
|
||||
)
|
||||
@@ -299,6 +304,44 @@ function openWallpaperMedia(app: 'photos' | 'camera'): void {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadWallpaperMediaConfig(): Promise<void> {
|
||||
if (wallpaperMediaConfigLoaded) return
|
||||
wallpaperMediaConfigLoaded = true
|
||||
|
||||
const configResponse = await nuiCall<MediaConfig>('media:config')
|
||||
if (
|
||||
!configResponse.success ||
|
||||
configResponse.data?.customWallpaperUploadEnabled !== true
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourcesResponse = await nuiCall<MediaImportSources>(
|
||||
'media:import:sources',
|
||||
)
|
||||
customWallpaperUploadAvailable.value =
|
||||
sourcesResponse.success &&
|
||||
Boolean(
|
||||
sourcesResponse.data?.sources.some((source) =>
|
||||
source.mediaTypes.includes('photo'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function openWallpaperCustomUpload(): void {
|
||||
const target = wallpaperTarget.value
|
||||
mediaPicker.begin(
|
||||
`settings:wallpaper:${target}`,
|
||||
'photo',
|
||||
`/apps/settings?wallpaper=1&wallpaperTarget=${target}`,
|
||||
1,
|
||||
)
|
||||
void router.push({
|
||||
path: '/apps/photos',
|
||||
query: { mediaAttachment: 'photo', wallpaperUpload: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
function wallpaperPreviewStyle(
|
||||
entry: WallpaperHistoryEntry,
|
||||
): Record<string, string> | undefined {
|
||||
@@ -404,6 +447,7 @@ function openView(view: SubmenuView): void {
|
||||
if (view === 'security') {
|
||||
passcodeLength.value = phone.security.length ?? 6
|
||||
}
|
||||
if (view === 'wallpaper') void loadWallpaperMediaConfig()
|
||||
activeView.value = view
|
||||
scrollPageToTop()
|
||||
}
|
||||
@@ -700,6 +744,7 @@ onMounted(() => {
|
||||
activeView.value = 'wallpaper'
|
||||
wallpaperTarget.value =
|
||||
route.query.wallpaperTarget === 'lock' ? 'lock' : 'home'
|
||||
void loadWallpaperMediaConfig()
|
||||
}
|
||||
|
||||
const selectedPhoto = mediaPicker.consume(
|
||||
@@ -1198,7 +1243,10 @@ onBeforeUnmount(() => {
|
||||
@update:model-value="
|
||||
updateNumberPreference('ringtoneVolume', $event)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template #leading><Volume1 /></template>
|
||||
<template #trailing><Volume2 /></template>
|
||||
</SkySettingsRangeRow>
|
||||
<SkySettingsRangeRow
|
||||
:model-value="phone.preferences.settings.notificationVolume"
|
||||
:title="phone.t('Apps.settings.notificationVolume')"
|
||||
@@ -1211,7 +1259,10 @@ onBeforeUnmount(() => {
|
||||
@update:model-value="
|
||||
updateNumberPreference('notificationVolume', $event)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template #leading><Volume1 /></template>
|
||||
<template #trailing><Volume2 /></template>
|
||||
</SkySettingsRangeRow>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
|
||||
@@ -1458,7 +1509,10 @@ onBeforeUnmount(() => {
|
||||
@update:model-value="
|
||||
updateNumberPreference('screenBrightness', $event)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template #leading><Sun /></template>
|
||||
<template #trailing><Sun /></template>
|
||||
</SkySettingsRangeRow>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup :aria-label="phone.t('Apps.settings.phoneScale')">
|
||||
@@ -1471,7 +1525,10 @@ onBeforeUnmount(() => {
|
||||
:max="PHONE_SCALE_MAX"
|
||||
:step="PHONE_SCALE_STEP"
|
||||
@update:model-value="updateNumberPreference('phoneScale', $event)"
|
||||
/>
|
||||
>
|
||||
<template #leading><Smartphone /></template>
|
||||
<template #trailing><Smartphone /></template>
|
||||
</SkySettingsRangeRow>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.phoneFrame')">
|
||||
@@ -1541,6 +1598,24 @@ onBeforeUnmount(() => {
|
||||
}}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="customWallpaperUploadAvailable"
|
||||
type="button"
|
||||
class="settings-wallpaper-actions__custom"
|
||||
@click="openWallpaperCustomUpload"
|
||||
>
|
||||
<span class="settings-wallpaper-actions__icon" aria-hidden="true">
|
||||
<Upload />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{{
|
||||
phone.t('Apps.settings.wallpaperCustomUpload')
|
||||
}}</strong>
|
||||
<small>{{
|
||||
phone.t('Apps.settings.wallpaperCustomUploadDescription')
|
||||
}}</small>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -1938,6 +2013,11 @@ onBeforeUnmount(() => {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.settings-wallpaper-actions > .settings-wallpaper-actions__custom {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.settings-wallpaper-actions > button:focus-visible,
|
||||
.settings-wallpaper-history > button:focus-visible,
|
||||
.settings-wallpaper-choice:focus-visible {
|
||||
@@ -1951,8 +2031,8 @@ onBeforeUnmount(() => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: var(--sky-app-accent-soft);
|
||||
color: var(--sky-app-accent);
|
||||
background: var(--sky-surface-muted);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-wallpaper-actions__icon > svg {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import WeatherConditionIcon from '@/components/WeatherConditionIcon.vue'
|
||||
import { usePullToRefresh } from '@/composables/usePullToRefresh'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
@@ -27,14 +28,23 @@ import {
|
||||
const phone = usePhoneStore()
|
||||
const weather = useWeatherStore()
|
||||
const forecast = computed(() => weather.forecast)
|
||||
const pullDistance = ref(0)
|
||||
const cooldownToastOpened = ref(false)
|
||||
const pullThreshold = 56
|
||||
let pullStartY = 0
|
||||
let isPulling = false
|
||||
let wheelRefreshTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let cooldownToastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const {
|
||||
finishPull,
|
||||
movePull,
|
||||
pullDistance,
|
||||
pullThreshold,
|
||||
pullWithWheel,
|
||||
startPull,
|
||||
} = usePullToRefresh({
|
||||
isAtTop: (event) =>
|
||||
(event.currentTarget as HTMLElement | null)?.scrollTop === 0,
|
||||
isBusy: () => weather.isLoading,
|
||||
refresh: () => weather.refresh(true, true),
|
||||
})
|
||||
|
||||
function closeCooldownToast(): void {
|
||||
cooldownToastOpened.value = false
|
||||
if (weather.error === 'reload_cooldown') weather.error = null
|
||||
@@ -114,52 +124,7 @@ function formatHour(timestamp: number, index: number): string {
|
||||
}).format(timestamp)
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (weather.isLoading) return
|
||||
pullDistance.value = pullThreshold
|
||||
await weather.refresh(true, true)
|
||||
pullDistance.value = 0
|
||||
}
|
||||
|
||||
function atTop(event: Event): boolean {
|
||||
return (event.currentTarget as HTMLElement | null)?.scrollTop === 0
|
||||
}
|
||||
|
||||
function startPull(event: TouchEvent): void {
|
||||
if (!atTop(event) || weather.isLoading) return
|
||||
pullStartY = event.touches[0]?.clientY ?? 0
|
||||
isPulling = true
|
||||
}
|
||||
|
||||
function movePull(event: TouchEvent): void {
|
||||
if (!isPulling || weather.isLoading) return
|
||||
const distance = (event.touches[0]?.clientY ?? pullStartY) - pullStartY
|
||||
pullDistance.value =
|
||||
distance > 0 ? Math.min(pullThreshold + 20, distance * 0.45) : 0
|
||||
}
|
||||
|
||||
function finishPull(): void {
|
||||
if (!isPulling && pullDistance.value === 0) return
|
||||
isPulling = false
|
||||
if (pullDistance.value >= pullThreshold) {
|
||||
void refresh()
|
||||
return
|
||||
}
|
||||
pullDistance.value = 0
|
||||
}
|
||||
|
||||
function pullWithWheel(event: WheelEvent): void {
|
||||
if (!atTop(event) || weather.isLoading || event.deltaY >= 0) return
|
||||
pullDistance.value = Math.min(
|
||||
pullThreshold + 20,
|
||||
pullDistance.value + Math.abs(event.deltaY) * 0.18,
|
||||
)
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
wheelRefreshTimeout = setTimeout(finishPull, 130)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
if (cooldownToastTimer) clearTimeout(cooldownToastTimer)
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./weazel-news-app.vue', import.meta.url),
|
||||
new URL('./WeazelNewsApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user