mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 02:01:40 +00:00
d99be70e77
Integrates the current dev branch while preserving the weather-app Dynamic Island, Notes deletion flow, and simplified Phone contact header. Resolves the overlapping Sky UI migration conflicts in Notes and Phone.
438 lines
11 KiB
Vue
438 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Ellipsis,
|
|
Pin,
|
|
PinOff,
|
|
Share2,
|
|
SquarePen,
|
|
Trash2,
|
|
} from 'lucide-vue-next'
|
|
import { computed, ref } from 'vue'
|
|
|
|
import NotesRichTextEditor from '@/components/NotesRichTextEditor.vue'
|
|
import { useNotesStore } from '@/stores/notes'
|
|
import { useEasyShareStore } from '@/stores/easyshare'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import {
|
|
SkyActionSheet,
|
|
SkyAppPage,
|
|
SkyBlock,
|
|
SkyBlockTitle,
|
|
SkyButton,
|
|
SkyDialog,
|
|
SkyDialogButton,
|
|
SkyFab,
|
|
SkyLink,
|
|
SkyList,
|
|
SkyListButton,
|
|
SkyListItem,
|
|
SkyNavbar,
|
|
SkyNavbarBackLink,
|
|
SkyScrollArea,
|
|
SkySearchbar,
|
|
SkyToolbar,
|
|
} from '@/ui'
|
|
import type { Note } from '@/utils/notes'
|
|
import { noteBodyToPlainText } from '@/utils/noteRichText'
|
|
|
|
const phone = usePhoneStore()
|
|
const notes = useNotesStore()
|
|
const easyShare = useEasyShareStore()
|
|
const searchQuery = ref('')
|
|
const editorId = ref<string | null>(null)
|
|
const editorOpened = ref(false)
|
|
const draftBody = ref('')
|
|
const menuOpened = ref(false)
|
|
const listDeleteCandidateId = ref<string | null>(null)
|
|
const currentNote = computed(() =>
|
|
editorId.value
|
|
? notes.notes.find((note) => note.id === editorId.value)
|
|
: undefined,
|
|
)
|
|
|
|
const visibleNotes = computed(() => {
|
|
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
|
|
return [...notes.notes]
|
|
.filter((note) => {
|
|
if (!query) return true
|
|
return `${note.title}\n${noteBodyToPlainText(note.body)}`
|
|
.toLocaleLowerCase(phone.lang)
|
|
.includes(query)
|
|
})
|
|
.sort(
|
|
(left, right) =>
|
|
Number(right.pinned) - Number(left.pinned) ||
|
|
right.updatedAt - left.updatedAt,
|
|
)
|
|
})
|
|
const editorLabels = computed(() => ({
|
|
bold: phone.t('Apps.notes.tools.bold'),
|
|
bulletList: phone.t('Apps.notes.tools.bulletList'),
|
|
closeFormatting: phone.t('Common.close'),
|
|
decreaseText: phone.t('Apps.notes.tools.decreaseText'),
|
|
increaseText: phone.t('Apps.notes.tools.increaseText'),
|
|
italic: phone.t('Apps.notes.tools.italic'),
|
|
numberedList: phone.t('Apps.notes.tools.numberedList'),
|
|
quote: phone.t('Apps.notes.tools.quote'),
|
|
redo: phone.t('Apps.notes.tools.redo'),
|
|
strike: phone.t('Apps.notes.tools.strike'),
|
|
toolbar: phone.t('Apps.notes.tools.toolbar'),
|
|
underline: phone.t('Apps.notes.tools.underline'),
|
|
undo: phone.t('Apps.notes.tools.undo'),
|
|
}))
|
|
|
|
function noteTitle(note: Note): string {
|
|
return note.title.trim() || phone.t('Apps.notes.untitled')
|
|
}
|
|
|
|
function notePreview(note: Note): string {
|
|
return (
|
|
noteBodyToPlainText(note.body).trim().replace(/\s+/g, ' ') ||
|
|
phone.t('Apps.notes.noText')
|
|
)
|
|
}
|
|
|
|
function noteDate(note: Note): string {
|
|
const date = new Date(note.updatedAt)
|
|
const sameYear = date.getFullYear() === new Date().getFullYear()
|
|
return new Intl.DateTimeFormat(phone.lang, {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
...(sameYear ? {} : { year: 'numeric' }),
|
|
}).format(date)
|
|
}
|
|
|
|
function noteSubtitle(note: Note): string {
|
|
return `${noteDate(note)} · ${notePreview(note)}`
|
|
}
|
|
|
|
function titleFromDraftBody(body: string): string {
|
|
return noteBodyToPlainText(body).split('\n')[0]?.trim() ?? ''
|
|
}
|
|
|
|
function createNote(): void {
|
|
editorId.value = null
|
|
draftBody.value = ''
|
|
editorOpened.value = true
|
|
}
|
|
|
|
function editNote(note: Note): void {
|
|
editorId.value = note.id
|
|
draftBody.value = note.body
|
|
editorOpened.value = true
|
|
}
|
|
|
|
function requestListDelete(note: Note): void {
|
|
listDeleteCandidateId.value = note.id
|
|
}
|
|
|
|
function cancelListDelete(): void {
|
|
listDeleteCandidateId.value = null
|
|
}
|
|
|
|
function confirmListDelete(): void {
|
|
const noteId = listDeleteCandidateId.value
|
|
if (!noteId) return
|
|
notes.deleteNote(noteId)
|
|
listDeleteCandidateId.value = null
|
|
}
|
|
|
|
function persistDraft(): Note | undefined {
|
|
const draft = {
|
|
body: draftBody.value,
|
|
title:
|
|
titleFromDraftBody(draftBody.value) ||
|
|
currentNote.value?.title.trim() ||
|
|
'',
|
|
}
|
|
|
|
if (editorId.value) {
|
|
notes.updateNote(editorId.value, draft)
|
|
return notes.notes.find((note) => note.id === editorId.value)
|
|
}
|
|
if (!draft.title && !noteBodyToPlainText(draft.body).trim()) return undefined
|
|
|
|
const note = notes.createNote(draft)
|
|
editorId.value = note.id
|
|
return note
|
|
}
|
|
|
|
function saveAndClose(): void {
|
|
persistDraft()
|
|
menuOpened.value = false
|
|
editorOpened.value = false
|
|
}
|
|
|
|
function openMenu(): void {
|
|
if (!persistDraft()) return
|
|
menuOpened.value = true
|
|
}
|
|
|
|
function deleteNote(): void {
|
|
const note = persistDraft()
|
|
if (!note) return
|
|
notes.deleteNote(note.id)
|
|
menuOpened.value = false
|
|
editorOpened.value = false
|
|
}
|
|
|
|
function togglePinned(): void {
|
|
const note = persistDraft()
|
|
if (!note) return
|
|
notes.togglePinned(note.id)
|
|
menuOpened.value = false
|
|
}
|
|
|
|
function shareNote(): void {
|
|
const note = persistDraft()
|
|
if (!note) return
|
|
menuOpened.value = false
|
|
easyShare.open({
|
|
appId: 'notes',
|
|
copyText: noteBodyToPlainText(note.body) || note.title,
|
|
id: note.id,
|
|
kind: 'note',
|
|
subtitle: notePreview(note),
|
|
title: noteTitle(note),
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<sky-app-page
|
|
v-if="!editorOpened"
|
|
class="notes-list-page sky-ui-provider"
|
|
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
|
|
:aria-label="phone.t('Apps.notes.name')"
|
|
>
|
|
<sky-navbar
|
|
variant="large"
|
|
transparent
|
|
:title="phone.t('Apps.notes.name')"
|
|
/>
|
|
|
|
<SkyScrollArea as="main" class="notes-list-scroll">
|
|
<SkyList v-if="visibleNotes.length" inset strong>
|
|
<SkyListItem
|
|
v-for="note in visibleNotes"
|
|
:key="note.id"
|
|
link
|
|
link-component="button"
|
|
:title="noteTitle(note)"
|
|
:subtitle="noteSubtitle(note)"
|
|
:chevron="false"
|
|
strong-title="auto"
|
|
@click="editNote(note)"
|
|
>
|
|
<template v-if="note.pinned" #after>
|
|
<Pin :size="15" aria-hidden="true" />
|
|
</template>
|
|
<template #actions>
|
|
<SkyButton
|
|
:aria-label="phone.t('Apps.notes.deleteNote')"
|
|
clear
|
|
icon-only
|
|
rounded
|
|
@click.stop="requestListDelete(note)"
|
|
>
|
|
<Trash2 :size="18" aria-hidden="true" />
|
|
</SkyButton>
|
|
</template>
|
|
</SkyListItem>
|
|
</SkyList>
|
|
|
|
<template v-else>
|
|
<sky-block-title large>{{
|
|
phone.t(
|
|
searchQuery ? 'Apps.notes.noResults' : 'Apps.notes.emptyTitle',
|
|
)
|
|
}}</sky-block-title>
|
|
<sky-block strong inset>{{
|
|
phone.t(
|
|
searchQuery ? 'Apps.notes.noResultsBody' : 'Apps.notes.emptyBody',
|
|
)
|
|
}}</sky-block>
|
|
<sky-list v-if="!searchQuery" strong inset>
|
|
<sky-list-button link-component="button" @click="createNote">
|
|
{{ phone.t('Apps.notes.newNote') }}
|
|
</sky-list-button>
|
|
</sky-list>
|
|
</template>
|
|
</SkyScrollArea>
|
|
|
|
<SkyToolbar
|
|
class="notes-composer"
|
|
component="footer"
|
|
:aria-label="phone.t('Apps.notes.searchPlaceholder')"
|
|
>
|
|
<SkySearchbar
|
|
v-model="searchQuery"
|
|
:clear-label="phone.t('Common.clear')"
|
|
:label="phone.t('Apps.notes.searchPlaceholder')"
|
|
:placeholder="phone.t('Apps.notes.searchPlaceholder')"
|
|
/>
|
|
<SkyFab
|
|
:aria-label="phone.t('Apps.notes.newNote')"
|
|
variant="neutral"
|
|
@click="createNote"
|
|
>
|
|
<template #icon>
|
|
<SquarePen :size="21" aria-hidden="true" />
|
|
</template>
|
|
</SkyFab>
|
|
</SkyToolbar>
|
|
|
|
<SkyDialog
|
|
:opened="listDeleteCandidateId !== null"
|
|
:title="phone.t('Apps.notes.deleteTitle')"
|
|
:content="phone.t('Apps.notes.deleteBody')"
|
|
role="alertdialog"
|
|
@backdropclick="cancelListDelete"
|
|
@escape="cancelListDelete"
|
|
>
|
|
<template #buttons>
|
|
<SkyDialogButton @click="cancelListDelete">
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyDialogButton>
|
|
<SkyDialogButton
|
|
strong
|
|
class="notes-delete-confirm"
|
|
@click="confirmListDelete"
|
|
>
|
|
{{ phone.t('Common.delete') }}
|
|
</SkyDialogButton>
|
|
</template>
|
|
</SkyDialog>
|
|
</sky-app-page>
|
|
|
|
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
|
|
<sky-navbar :title="phone.t('Apps.notes.note')">
|
|
<template #left>
|
|
<sky-navbar-back-link
|
|
component="button"
|
|
:text="phone.t('Apps.notes.back')"
|
|
:aria-label="phone.t('Apps.notes.back')"
|
|
@click="saveAndClose"
|
|
/>
|
|
</template>
|
|
<template #right>
|
|
<sky-link
|
|
component="button"
|
|
icon-only
|
|
:aria-label="phone.t('Apps.notes.actions')"
|
|
@click="openMenu"
|
|
>
|
|
<Ellipsis :size="22" />
|
|
</sky-link>
|
|
</template>
|
|
</sky-navbar>
|
|
|
|
<div class="notes-editor-layout">
|
|
<NotesRichTextEditor
|
|
v-model="draftBody"
|
|
:dark="phone.isDarkMode"
|
|
:labels="editorLabels"
|
|
:placeholder="phone.t('Apps.notes.bodyPlaceholder')"
|
|
/>
|
|
</div>
|
|
|
|
<SkyActionSheet
|
|
class="notes-action-sheet sky-ui-provider"
|
|
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
|
|
:aria-label="phone.t('Apps.notes.actions')"
|
|
:opened="menuOpened"
|
|
@backdropclick="menuOpened = false"
|
|
@escape="menuOpened = false"
|
|
>
|
|
<div class="notes-action-menu">
|
|
<div class="notes-action-menu__choices">
|
|
<SkyButton block large tonal @click="shareNote">
|
|
<Share2 :size="19" aria-hidden="true" />
|
|
{{ phone.t('Apps.easyShare.name') }}
|
|
</SkyButton>
|
|
<SkyButton block large tonal @click="togglePinned">
|
|
<PinOff v-if="currentNote?.pinned" :size="19" aria-hidden="true" />
|
|
<Pin v-else :size="19" aria-hidden="true" />
|
|
{{
|
|
phone.t(
|
|
currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin',
|
|
)
|
|
}}
|
|
</SkyButton>
|
|
<SkyButton
|
|
block
|
|
class="notes-action-menu__danger"
|
|
large
|
|
tonal
|
|
@click="deleteNote"
|
|
>
|
|
<Trash2 :size="19" aria-hidden="true" />
|
|
{{ phone.t('Apps.notes.deleteNote') }}
|
|
</SkyButton>
|
|
</div>
|
|
<SkyButton block clear large @click="menuOpened = false">
|
|
{{ phone.t('Common.cancel') }}
|
|
</SkyButton>
|
|
</div>
|
|
</SkyActionSheet>
|
|
</sky-app-page>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.notes-list-page {
|
|
padding-bottom: 0 !important;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.notes-delete-confirm {
|
|
color: var(--sky-danger);
|
|
background: var(--sky-danger-soft);
|
|
}
|
|
|
|
.notes-editor-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.notes-editor-layout {
|
|
min-height: 0;
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.notes-action-menu {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 14px;
|
|
border: 1px solid var(--sky-hairline);
|
|
border-radius: 28px 28px 0 0;
|
|
background: var(--sky-surface);
|
|
box-shadow: 0 -18px 50px rgb(0 0 0 / 32%);
|
|
}
|
|
|
|
.notes-action-menu__choices {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.notes-action-menu :deep(.sky-button) {
|
|
justify-content: center;
|
|
border-radius: var(--sky-radius-control);
|
|
}
|
|
|
|
.notes-action-menu__danger {
|
|
color: var(--sky-danger);
|
|
background: var(--sky-danger-soft);
|
|
}
|
|
|
|
.notes-action-sheet :deep(.sky-action-sheet__panel) {
|
|
max-height: none;
|
|
padding: 0;
|
|
}
|
|
</style>
|