637 lines
14 KiB
Markdown
637 lines
14 KiB
Markdown
|
|
# Mobile & Hybrid Apps
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Quasar Framework
|
||
|
|
|
||
|
|
### Project Setup
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Create new Quasar project
|
||
|
|
npm init quasar
|
||
|
|
|
||
|
|
# Add Quasar to existing Vue project
|
||
|
|
npm install quasar @quasar/extras
|
||
|
|
npm install -D @quasar/vite-plugin
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// vite.config.ts - Quasar plugin setup
|
||
|
|
import { defineConfig } from 'vite'
|
||
|
|
import vue from '@vitejs/plugin-vue'
|
||
|
|
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
|
||
|
|
|
||
|
|
export default defineConfig({
|
||
|
|
plugins: [
|
||
|
|
vue({
|
||
|
|
template: { transformAssetUrls }
|
||
|
|
}),
|
||
|
|
quasar({
|
||
|
|
sassVariables: 'src/quasar-variables.scss'
|
||
|
|
})
|
||
|
|
]
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### Quasar Configuration
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// quasar.config.js
|
||
|
|
export default configure((ctx) => ({
|
||
|
|
// Build modes: spa, pwa, ssr, capacitor, electron, bex
|
||
|
|
boot: ['axios', 'i18n'],
|
||
|
|
|
||
|
|
css: ['app.scss'],
|
||
|
|
|
||
|
|
extras: [
|
||
|
|
'roboto-font',
|
||
|
|
'material-icons'
|
||
|
|
],
|
||
|
|
|
||
|
|
framework: {
|
||
|
|
plugins: ['Notify', 'Dialog', 'Loading', 'LocalStorage'],
|
||
|
|
config: {
|
||
|
|
notify: { position: 'top-right' },
|
||
|
|
loading: { spinnerColor: 'primary' }
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
build: {
|
||
|
|
target: { browser: ['es2022', 'firefox115', 'chrome115', 'safari14'] },
|
||
|
|
vueRouterMode: 'history'
|
||
|
|
}
|
||
|
|
}))
|
||
|
|
```
|
||
|
|
|
||
|
|
### Quasar Components with Composition API
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { useQuasar } from 'quasar'
|
||
|
|
|
||
|
|
const $q = useQuasar()
|
||
|
|
|
||
|
|
function showNotification() {
|
||
|
|
$q.notify({
|
||
|
|
message: 'Action completed successfully',
|
||
|
|
type: 'positive',
|
||
|
|
position: 'top',
|
||
|
|
timeout: 3000
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function showConfirmDialog() {
|
||
|
|
$q.dialog({
|
||
|
|
title: 'Confirm',
|
||
|
|
message: 'Are you sure you want to proceed?',
|
||
|
|
cancel: true,
|
||
|
|
persistent: true
|
||
|
|
}).onOk(() => {
|
||
|
|
// User confirmed
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function showLoading() {
|
||
|
|
$q.loading.show({ message: 'Processing...' })
|
||
|
|
await doAsyncWork()
|
||
|
|
$q.loading.hide()
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Layout System
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<template>
|
||
|
|
<q-layout view="lHh Lpr lFf">
|
||
|
|
<q-header elevated>
|
||
|
|
<q-toolbar>
|
||
|
|
<q-btn flat dense round icon="menu" @click="toggleLeftDrawer" />
|
||
|
|
<q-toolbar-title>My App</q-toolbar-title>
|
||
|
|
<q-btn flat round icon="person" />
|
||
|
|
</q-toolbar>
|
||
|
|
</q-header>
|
||
|
|
|
||
|
|
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
|
||
|
|
<q-list>
|
||
|
|
<q-item clickable v-ripple to="/dashboard">
|
||
|
|
<q-item-section avatar>
|
||
|
|
<q-icon name="dashboard" />
|
||
|
|
</q-item-section>
|
||
|
|
<q-item-section>Dashboard</q-item-section>
|
||
|
|
</q-item>
|
||
|
|
</q-list>
|
||
|
|
</q-drawer>
|
||
|
|
|
||
|
|
<q-page-container>
|
||
|
|
<router-view />
|
||
|
|
</q-page-container>
|
||
|
|
</q-layout>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { ref } from 'vue'
|
||
|
|
|
||
|
|
const leftDrawerOpen = ref(false)
|
||
|
|
|
||
|
|
function toggleLeftDrawer() {
|
||
|
|
leftDrawerOpen.value = !leftDrawerOpen.value
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Platform Detection
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { useQuasar } from 'quasar'
|
||
|
|
|
||
|
|
const $q = useQuasar()
|
||
|
|
|
||
|
|
// Platform detection
|
||
|
|
const isMobile = $q.platform.is.mobile
|
||
|
|
const isIOS = $q.platform.is.ios
|
||
|
|
const isAndroid = $q.platform.is.android
|
||
|
|
const isDesktop = $q.platform.is.desktop
|
||
|
|
const isCapacitor = $q.platform.is.capacitor
|
||
|
|
|
||
|
|
// Screen utilities
|
||
|
|
const isSmallScreen = $q.screen.lt.md
|
||
|
|
const screenWidth = $q.screen.width
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div>
|
||
|
|
<MobileNav v-if="isMobile" />
|
||
|
|
<DesktopNav v-else />
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Capacitor Integration
|
||
|
|
|
||
|
|
### Setup
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Add Capacitor to Quasar
|
||
|
|
quasar mode add capacitor
|
||
|
|
|
||
|
|
# Initialize Capacitor
|
||
|
|
cd src-capacitor
|
||
|
|
npx cap init "App Name" "com.example.app"
|
||
|
|
|
||
|
|
# Add platforms
|
||
|
|
npx cap add android
|
||
|
|
npx cap add ios
|
||
|
|
|
||
|
|
# Sync and run
|
||
|
|
npx cap sync
|
||
|
|
npx cap open android
|
||
|
|
```
|
||
|
|
|
||
|
|
### Capacitor Configuration
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// capacitor.config.ts
|
||
|
|
import type { CapacitorConfig } from '@capacitor/cli'
|
||
|
|
|
||
|
|
const config: CapacitorConfig = {
|
||
|
|
appId: 'com.example.myapp',
|
||
|
|
appName: 'My App',
|
||
|
|
webDir: 'dist/spa',
|
||
|
|
server: {
|
||
|
|
androidScheme: 'https',
|
||
|
|
// For development
|
||
|
|
url: 'http://192.168.1.100:9000',
|
||
|
|
cleartext: true
|
||
|
|
},
|
||
|
|
plugins: {
|
||
|
|
SplashScreen: {
|
||
|
|
launchAutoHide: false,
|
||
|
|
showSpinner: true
|
||
|
|
},
|
||
|
|
PushNotifications: {
|
||
|
|
presentationOptions: ['badge', 'sound', 'alert']
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export default config
|
||
|
|
```
|
||
|
|
|
||
|
|
### Native Plugins with TypeScript
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/useCamera.ts
|
||
|
|
import { ref } from 'vue'
|
||
|
|
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
|
||
|
|
|
||
|
|
export function useCamera() {
|
||
|
|
const photo = ref<string | null>(null)
|
||
|
|
const error = ref<string | null>(null)
|
||
|
|
|
||
|
|
async function takePhoto() {
|
||
|
|
try {
|
||
|
|
const image = await Camera.getPhoto({
|
||
|
|
resultType: CameraResultType.Uri,
|
||
|
|
source: CameraSource.Camera,
|
||
|
|
quality: 90
|
||
|
|
})
|
||
|
|
photo.value = image.webPath ?? null
|
||
|
|
} catch (e) {
|
||
|
|
error.value = (e as Error).message
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function pickFromGallery() {
|
||
|
|
try {
|
||
|
|
const image = await Camera.getPhoto({
|
||
|
|
resultType: CameraResultType.Uri,
|
||
|
|
source: CameraSource.Photos,
|
||
|
|
quality: 90
|
||
|
|
})
|
||
|
|
photo.value = image.webPath ?? null
|
||
|
|
} catch (e) {
|
||
|
|
error.value = (e as Error).message
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { photo, error, takePhoto, pickFromGallery }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/useGeolocation.ts
|
||
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||
|
|
import { Geolocation, Position } from '@capacitor/geolocation'
|
||
|
|
|
||
|
|
export function useGeolocation() {
|
||
|
|
const position = ref<Position | null>(null)
|
||
|
|
const error = ref<string | null>(null)
|
||
|
|
let watchId: string | null = null
|
||
|
|
|
||
|
|
async function getCurrentPosition() {
|
||
|
|
try {
|
||
|
|
position.value = await Geolocation.getCurrentPosition({
|
||
|
|
enableHighAccuracy: true
|
||
|
|
})
|
||
|
|
} catch (e) {
|
||
|
|
error.value = (e as Error).message
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function watchPosition() {
|
||
|
|
watchId = await Geolocation.watchPosition(
|
||
|
|
{ enableHighAccuracy: true },
|
||
|
|
(pos, err) => {
|
||
|
|
if (err) {
|
||
|
|
error.value = err.message
|
||
|
|
} else if (pos) {
|
||
|
|
position.value = pos
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function stopWatching() {
|
||
|
|
if (watchId) {
|
||
|
|
Geolocation.clearWatch({ id: watchId })
|
||
|
|
watchId = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onUnmounted(stopWatching)
|
||
|
|
|
||
|
|
return { position, error, getCurrentPosition, watchPosition, stopWatching }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Push Notifications
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/usePushNotifications.ts
|
||
|
|
import { ref, onMounted } from 'vue'
|
||
|
|
import { PushNotifications, Token, PushNotificationSchema } from '@capacitor/push-notifications'
|
||
|
|
import { Capacitor } from '@capacitor/core'
|
||
|
|
|
||
|
|
export function usePushNotifications() {
|
||
|
|
const token = ref<string | null>(null)
|
||
|
|
const notifications = ref<PushNotificationSchema[]>([])
|
||
|
|
|
||
|
|
async function register() {
|
||
|
|
if (!Capacitor.isNativePlatform()) return
|
||
|
|
|
||
|
|
const permission = await PushNotifications.requestPermissions()
|
||
|
|
if (permission.receive !== 'granted') return
|
||
|
|
|
||
|
|
await PushNotifications.register()
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
if (!Capacitor.isNativePlatform()) return
|
||
|
|
|
||
|
|
PushNotifications.addListener('registration', (t: Token) => {
|
||
|
|
token.value = t.value
|
||
|
|
})
|
||
|
|
|
||
|
|
PushNotifications.addListener('pushNotificationReceived', (notification) => {
|
||
|
|
notifications.value.push(notification)
|
||
|
|
})
|
||
|
|
|
||
|
|
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
|
||
|
|
// Handle notification tap
|
||
|
|
console.log('Action:', action.actionId)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
return { token, notifications, register }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### App Lifecycle
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/useAppLifecycle.ts
|
||
|
|
import { onMounted, onUnmounted } from 'vue'
|
||
|
|
import { App } from '@capacitor/app'
|
||
|
|
import { Capacitor } from '@capacitor/core'
|
||
|
|
|
||
|
|
export function useAppLifecycle() {
|
||
|
|
onMounted(() => {
|
||
|
|
if (!Capacitor.isNativePlatform()) return
|
||
|
|
|
||
|
|
App.addListener('appStateChange', ({ isActive }) => {
|
||
|
|
if (isActive) {
|
||
|
|
// App came to foreground
|
||
|
|
refreshData()
|
||
|
|
} else {
|
||
|
|
// App went to background
|
||
|
|
saveState()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
App.addListener('backButton', ({ canGoBack }) => {
|
||
|
|
if (!canGoBack) {
|
||
|
|
App.exitApp()
|
||
|
|
} else {
|
||
|
|
window.history.back()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
App.removeAllListeners()
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## PWA & Service Workers
|
||
|
|
|
||
|
|
### Workbox Configuration
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// quasar.config.js
|
||
|
|
export default configure((ctx) => ({
|
||
|
|
pwa: {
|
||
|
|
workboxMode: 'GenerateSW', // or 'InjectManifest'
|
||
|
|
|
||
|
|
workboxOptions: {
|
||
|
|
skipWaiting: true,
|
||
|
|
clientsClaim: true,
|
||
|
|
cleanupOutdatedCaches: true,
|
||
|
|
|
||
|
|
// Cache strategies
|
||
|
|
runtimeCaching: [
|
||
|
|
{
|
||
|
|
// Cache API responses
|
||
|
|
urlPattern: /^https:\/\/api\./,
|
||
|
|
handler: 'NetworkFirst',
|
||
|
|
options: {
|
||
|
|
cacheName: 'api-cache',
|
||
|
|
networkTimeoutSeconds: 10,
|
||
|
|
expiration: {
|
||
|
|
maxEntries: 100,
|
||
|
|
maxAgeSeconds: 60 * 60 * 24 // 24 hours
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
// Cache images
|
||
|
|
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
|
||
|
|
handler: 'CacheFirst',
|
||
|
|
options: {
|
||
|
|
cacheName: 'image-cache',
|
||
|
|
expiration: {
|
||
|
|
maxEntries: 50,
|
||
|
|
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
// Cache fonts
|
||
|
|
urlPattern: /\.(?:woff|woff2|ttf|eot)$/,
|
||
|
|
handler: 'CacheFirst',
|
||
|
|
options: {
|
||
|
|
cacheName: 'font-cache',
|
||
|
|
expiration: {
|
||
|
|
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}))
|
||
|
|
```
|
||
|
|
|
||
|
|
### Web App Manifest
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// quasar.config.js
|
||
|
|
export default configure((ctx) => ({
|
||
|
|
pwa: {
|
||
|
|
manifest: {
|
||
|
|
name: 'My Progressive App',
|
||
|
|
short_name: 'MyApp',
|
||
|
|
description: 'A Progressive Web Application',
|
||
|
|
display: 'standalone',
|
||
|
|
orientation: 'portrait',
|
||
|
|
background_color: '#ffffff',
|
||
|
|
theme_color: '#1976D2',
|
||
|
|
start_url: '/',
|
||
|
|
icons: [
|
||
|
|
{
|
||
|
|
src: 'icons/icon-128x128.png',
|
||
|
|
sizes: '128x128',
|
||
|
|
type: 'image/png'
|
||
|
|
},
|
||
|
|
{
|
||
|
|
src: 'icons/icon-512x512.png',
|
||
|
|
sizes: '512x512',
|
||
|
|
type: 'image/png'
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}))
|
||
|
|
```
|
||
|
|
|
||
|
|
### Install Prompt Handling
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/usePWAInstall.ts
|
||
|
|
import { ref, onMounted } from 'vue'
|
||
|
|
|
||
|
|
interface BeforeInstallPromptEvent extends Event {
|
||
|
|
prompt(): Promise<void>
|
||
|
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||
|
|
}
|
||
|
|
|
||
|
|
export function usePWAInstall() {
|
||
|
|
const canInstall = ref(false)
|
||
|
|
const isInstalled = ref(false)
|
||
|
|
let deferredPrompt: BeforeInstallPromptEvent | null = null
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
// Check if already installed
|
||
|
|
isInstalled.value = window.matchMedia('(display-mode: standalone)').matches
|
||
|
|
|
||
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||
|
|
e.preventDefault()
|
||
|
|
deferredPrompt = e as BeforeInstallPromptEvent
|
||
|
|
canInstall.value = true
|
||
|
|
})
|
||
|
|
|
||
|
|
window.addEventListener('appinstalled', () => {
|
||
|
|
isInstalled.value = true
|
||
|
|
canInstall.value = false
|
||
|
|
deferredPrompt = null
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
async function install() {
|
||
|
|
if (!deferredPrompt) return false
|
||
|
|
|
||
|
|
await deferredPrompt.prompt()
|
||
|
|
const { outcome } = await deferredPrompt.userChoice
|
||
|
|
|
||
|
|
deferredPrompt = null
|
||
|
|
canInstall.value = false
|
||
|
|
|
||
|
|
return outcome === 'accepted'
|
||
|
|
}
|
||
|
|
|
||
|
|
return { canInstall, isInstalled, install }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### PWA Update Flow
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/usePWAUpdate.ts
|
||
|
|
import { ref, onMounted } from 'vue'
|
||
|
|
import { useQuasar } from 'quasar'
|
||
|
|
|
||
|
|
export function usePWAUpdate() {
|
||
|
|
const $q = useQuasar()
|
||
|
|
const needsUpdate = ref(false)
|
||
|
|
let registration: ServiceWorkerRegistration | null = null
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
if (!('serviceWorker' in navigator)) return
|
||
|
|
|
||
|
|
navigator.serviceWorker.ready.then((reg) => {
|
||
|
|
registration = reg
|
||
|
|
|
||
|
|
reg.addEventListener('updatefound', () => {
|
||
|
|
const newWorker = reg.installing
|
||
|
|
if (!newWorker) return
|
||
|
|
|
||
|
|
newWorker.addEventListener('statechange', () => {
|
||
|
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||
|
|
needsUpdate.value = true
|
||
|
|
promptUpdate()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
function promptUpdate() {
|
||
|
|
$q.notify({
|
||
|
|
message: 'A new version is available',
|
||
|
|
timeout: 0,
|
||
|
|
actions: [
|
||
|
|
{
|
||
|
|
label: 'Update',
|
||
|
|
color: 'white',
|
||
|
|
handler: updateApp
|
||
|
|
},
|
||
|
|
{
|
||
|
|
label: 'Later',
|
||
|
|
color: 'white'
|
||
|
|
}
|
||
|
|
]
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateApp() {
|
||
|
|
if (registration?.waiting) {
|
||
|
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||
|
|
}
|
||
|
|
window.location.reload()
|
||
|
|
}
|
||
|
|
|
||
|
|
return { needsUpdate, updateApp }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Offline Detection
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// composables/useOnlineStatus.ts
|
||
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||
|
|
|
||
|
|
export function useOnlineStatus() {
|
||
|
|
const isOnline = ref(navigator.onLine)
|
||
|
|
|
||
|
|
function updateOnlineStatus() {
|
||
|
|
isOnline.value = navigator.onLine
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
window.addEventListener('online', updateOnlineStatus)
|
||
|
|
window.addEventListener('offline', updateOnlineStatus)
|
||
|
|
})
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
window.removeEventListener('online', updateOnlineStatus)
|
||
|
|
window.removeEventListener('offline', updateOnlineStatus)
|
||
|
|
})
|
||
|
|
|
||
|
|
return { isOnline }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Quick Reference
|
||
|
|
|
||
|
|
| Pattern | Use Case |
|
||
|
|
|---------|----------|
|
||
|
|
| `useQuasar()` | Access Quasar plugins ($q) |
|
||
|
|
| `$q.platform.is.*` | Platform detection |
|
||
|
|
| `$q.notify()` | Toast notifications |
|
||
|
|
| `$q.dialog()` | Modal dialogs |
|
||
|
|
| `@capacitor/camera` | Native camera access |
|
||
|
|
| `@capacitor/geolocation` | GPS location |
|
||
|
|
| `@capacitor/push-notifications` | Push notifications |
|
||
|
|
| `workboxMode: 'GenerateSW'` | Auto-generate service worker |
|
||
|
|
| `runtimeCaching` | Workbox cache strategies |
|
||
|
|
| `beforeinstallprompt` | PWA install prompt |
|
||
|
|
| `navigator.serviceWorker.ready` | Service worker lifecycle |
|