# 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 ``` ### Layout System ```vue ``` ### Platform Detection ```vue ``` --- ## 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(null) const error = ref(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(null) const error = ref(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(null) const notifications = ref([]) 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 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 |