Стандарты
Мобильная разработка
Статус: уточняется. Стандарт в процессе разработки.
Стандарт определяет правила разработки с учётом мобильных платформ (iOS, Android через Capacitor).
Правило: Mobile-Ready с первого дня
Каждая фича должна работать на мобильных устройствах. Не откладывай адаптацию на потом.
Определение платформы
Composable usePlatform
// composables/usePlatform.ts
import { Capacitor } from '@capacitor/core'
export function usePlatform() {
const platform = Capacitor.getPlatform() // 'web' | 'ios' | 'android'
const isNative = Capacitor.isNativePlatform()
const isIOS = platform === 'ios'
const isAndroid = platform === 'android'
const isWeb = platform === 'web'
return { platform, isNative, isIOS, isAndroid, isWeb }
}
Условный рендеринг
<template>
<!-- Разный UI для платформ -->
<UButton v-if="isAndroid" icon="i-lucide-arrow-left" @click="goBack">
Назад
</UButton>
<!-- iOS использует нативный свайп -->
<div v-if="isIOS" class="pt-[env(safe-area-inset-top)]">
<!-- iOS-specific layout -->
</div>
<!-- Веб-версия -->
<nav v-if="isWeb" class="hidden md:flex">
<!-- Desktop navigation -->
</nav>
</template>
<script setup>
const { isIOS, isAndroid, isWeb } = usePlatform()
</script>
Safe Areas
Обязательный учёт вырезов
На устройствах с notch (iPhone X+), dynamic island, закруглёнными углами экрана обязательно использовать safe areas.
<template>
<div class="min-h-screen flex flex-col">
<!-- Header с учётом верхнего выреза -->
<header class="pt-[env(safe-area-inset-top)] bg-default">
<div class="px-4 py-3">
<h1>Заголовок</h1>
</div>
</header>
<!-- Основной контент -->
<main class="flex-1 overflow-auto">
<slot />
</main>
<!-- Footer/TabBar с учётом нижнего выреза -->
<footer class="pb-[env(safe-area-inset-bottom)] bg-default border-t">
<nav class="px-4 py-2">
<!-- Tab bar -->
</nav>
</footer>
</div>
</template>
Tailwind-классы для safe areas
/* app/assets/css/safe-areas.css */
@layer utilities {
.pt-safe {
padding-top: env(safe-area-inset-top);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.pl-safe {
padding-left: env(safe-area-inset-left);
}
.pr-safe {
padding-right: env(safe-area-inset-right);
}
.px-safe {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.py-safe {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
}
<!-- Использование -->
<div class="pt-safe pb-safe px-4">
Контент с учётом safe areas
</div>
Touch-targets
Минимальные размеры
Все интерактивные элементы должны иметь touch-target минимум 44×44px.
<!-- ✅ Хорошо: достаточный размер для касания -->
<UButton size="lg" class="min-h-11 min-w-11">
<UIcon name="i-lucide-plus" />
</UButton>
<!-- ❌ Плохо: слишком маленький -->
<button class="w-6 h-6">
<UIcon name="i-lucide-plus" />
</button>
Увеличение через padding
<!-- Иконка 24px, но touch-target 44px -->
<button class="p-2.5">
<UIcon name="i-lucide-menu" class="w-6 h-6" />
</button>
Нативные возможности
Камера
// composables/useCamera.ts
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
export function useCamera() {
const { isNative } = usePlatform()
async function takePhoto(): Promise<string | null> {
if (!isNative) {
// Fallback: file input для веба
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.capture = 'environment'
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
} else {
resolve(null)
}
}
input.click()
})
}
try {
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
})
return `data:image/jpeg;base64,${photo.base64String}`
} catch {
return null
}
}
async function pickFromGallery(): Promise<string | null> {
if (!isNative) {
return takePhoto() // Тот же fallback
}
try {
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Base64,
source: CameraSource.Photos,
})
return `data:image/jpeg;base64,${photo.base64String}`
} catch {
return null
}
}
return { takePhoto, pickFromGallery }
}
Геолокация
// composables/useGeolocation.ts
import { Geolocation } from '@capacitor/geolocation'
export function useGeolocation() {
const position = ref<{ lat: number; lng: number } | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function getCurrentPosition() {
loading.value = true
error.value = null
try {
const result = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 10000,
})
position.value = {
lat: result.coords.latitude,
lng: result.coords.longitude,
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Ошибка геолокации'
} finally {
loading.value = false
}
}
return { position, loading, error, getCurrentPosition }
}
Haptic Feedback
// composables/useHaptics.ts
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics'
export function useHaptics() {
const { isNative } = usePlatform()
async function impact(style: 'light' | 'medium' | 'heavy' = 'medium') {
if (!isNative) return
const styles = {
light: ImpactStyle.Light,
medium: ImpactStyle.Medium,
heavy: ImpactStyle.Heavy,
}
await Haptics.impact({ style: styles[style] })
}
async function notification(type: 'success' | 'warning' | 'error') {
if (!isNative) return
const types = {
success: NotificationType.Success,
warning: NotificationType.Warning,
error: NotificationType.Error,
}
await Haptics.notification({ type: types[type] })
}
async function vibrate() {
if (!isNative) return
await Haptics.vibrate()
}
return { impact, notification, vibrate }
}
<script setup>
const { impact, notification } = useHaptics()
async function handleDelete() {
await impact('heavy')
// ... delete logic
await notification('success')
}
</script>
Офлайн-режим
Определение статуса сети
// composables/useNetwork.ts
import { Network } from '@capacitor/network'
export function useNetwork() {
const isOnline = ref(true)
const connectionType = ref<string>('unknown')
async function checkStatus() {
const status = await Network.getStatus()
isOnline.value = status.connected
connectionType.value = status.connectionType
}
onMounted(() => {
checkStatus()
Network.addListener('networkStatusChange', (status) => {
isOnline.value = status.connected
connectionType.value = status.connectionType
})
})
onUnmounted(() => {
Network.removeAllListeners()
})
return { isOnline, connectionType, checkStatus }
}
Индикатор офлайн
<template>
<Transition
enter-active-class="transition-transform duration-200"
leave-active-class="transition-transform duration-150"
enter-from-class="-translate-y-full"
leave-to-class="-translate-y-full"
>
<div
v-if="!isOnline"
class="fixed top-0 inset-x-0 bg-warning text-warning-foreground text-center py-2 text-sm z-50"
>
Нет подключения к интернету
</div>
</Transition>
</template>
<script setup>
const { isOnline } = useNetwork()
</script>
Клавиатура
Управление виртуальной клавиатурой
// composables/useKeyboard.ts
import { Keyboard } from '@capacitor/keyboard'
export function useKeyboard() {
const { isNative } = usePlatform()
const isKeyboardVisible = ref(false)
const keyboardHeight = ref(0)
if (isNative) {
Keyboard.addListener('keyboardWillShow', (info) => {
isKeyboardVisible.value = true
keyboardHeight.value = info.keyboardHeight
})
Keyboard.addListener('keyboardWillHide', () => {
isKeyboardVisible.value = false
keyboardHeight.value = 0
})
}
async function hide() {
if (isNative) {
await Keyboard.hide()
}
}
return { isKeyboardVisible, keyboardHeight, hide }
}
Скролл при фокусе на input
<template>
<div ref="formRef" class="space-y-4">
<UInput
v-model="form.email"
placeholder="Email"
@focus="scrollToInput"
/>
</div>
</template>
<script setup>
const formRef = ref<HTMLElement | null>(null)
const { isKeyboardVisible, keyboardHeight } = useKeyboard()
function scrollToInput(event: FocusEvent) {
const input = event.target as HTMLElement
setTimeout(() => {
input.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300) // Ждём анимацию клавиатуры
}
</script>
Pull-to-Refresh
<template>
<div
ref="containerRef"
class="overflow-auto"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Pull indicator -->
<div
class="flex justify-center py-4 transition-transform"
:style="{ transform: `translateY(${pullDistance}px)` }"
>
<UIcon
v-if="!refreshing"
name="i-lucide-arrow-down"
class="transition-transform"
:class="{ 'rotate-180': pullDistance > threshold }"
/>
<UIcon
v-else
name="i-lucide-loader-2"
class="animate-spin"
/>
</div>
<!-- Content -->
<slot />
</div>
</template>
<script setup>
const props = defineProps<{
onRefresh: () => Promise<void>
}>()
const threshold = 80
const pullDistance = ref(0)
const refreshing = ref(false)
let startY = 0
function onTouchStart(e: TouchEvent) {
startY = e.touches[0].clientY
}
function onTouchMove(e: TouchEvent) {
if (refreshing.value) return
const deltaY = e.touches[0].clientY - startY
if (deltaY > 0) {
pullDistance.value = Math.min(deltaY * 0.5, threshold * 1.5)
}
}
async function onTouchEnd() {
if (pullDistance.value > threshold && !refreshing.value) {
refreshing.value = true
await props.onRefresh()
refreshing.value = false
}
pullDistance.value = 0
}
</script>
Чек-лист
Каждая страница/фича
- Работает на мобильном (проверено в эмуляторе)
- Safe areas учтены (header, footer)
- Touch-targets минимум 44×44px
- Нет hover-only взаимодействий
- Формы работают с виртуальной клавиатурой
Нативные возможности
- Fallback для веба если используется нативный API
- Запрос permissions перед использованием (камера, геолокация)
- Graceful degradation при отказе в permissions
Офлайн
- Индикатор статуса сети
- Критичные данные кэшируются
- Retry логика для failed requests