Стандарты

Мобильная разработка

Статус: уточняется. Стандарт в процессе разработки.

Стандарт определяет правила разработки с учётом мобильных платформ (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

Связанные документы