feat(webapp): add offline shell and iPhone install guidance
This commit is contained in:
@@ -1,52 +1,98 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const IOS_HINT_DISMISSED_KEY = 'phonecam-ios-install-hint-dismissed';
|
||||||
|
|
||||||
const createInitialState = () => ({
|
const createInitialState = () => ({
|
||||||
installAvailable: false,
|
installAvailable: false,
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
offlineReady: false,
|
offlineReady: false,
|
||||||
installing: false
|
installing: false,
|
||||||
|
isOffline: false,
|
||||||
|
isStandalone: false,
|
||||||
|
showIosInstallHint: false
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pwaState = writable(createInitialState());
|
export const pwaState = writable(createInitialState());
|
||||||
|
|
||||||
let deferredInstallPrompt: any = null;
|
let deferredInstallPrompt: any = null;
|
||||||
|
|
||||||
export const setInstallPrompt = (event: any) => {
|
const setPwaState = (partial: Record<string, unknown>) => {
|
||||||
deferredInstallPrompt = event;
|
|
||||||
pwaState.update((state) => ({
|
pwaState.update((state) => ({
|
||||||
...state,
|
...state,
|
||||||
installAvailable: true
|
...partial
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setInstallPrompt = (event: any) => {
|
||||||
|
deferredInstallPrompt = event;
|
||||||
|
setPwaState({
|
||||||
|
installAvailable: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const clearInstallPrompt = () => {
|
export const clearInstallPrompt = () => {
|
||||||
deferredInstallPrompt = null;
|
deferredInstallPrompt = null;
|
||||||
pwaState.update((state) => ({
|
setPwaState({
|
||||||
...state,
|
|
||||||
installAvailable: false,
|
installAvailable: false,
|
||||||
installing: false
|
installing: false
|
||||||
}));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setUpdateAvailable = (updateAvailable: boolean) => {
|
export const setUpdateAvailable = (updateAvailable: boolean) => {
|
||||||
pwaState.update((state) => ({
|
setPwaState({ updateAvailable });
|
||||||
...state,
|
|
||||||
updateAvailable
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setOfflineReady = (offlineReady: boolean) => {
|
export const setOfflineReady = (offlineReady: boolean) => {
|
||||||
pwaState.update((state) => ({
|
setPwaState({ offlineReady });
|
||||||
...state,
|
|
||||||
offlineReady
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dismissOfflineReady = () => {
|
export const dismissOfflineReady = () => {
|
||||||
pwaState.update((state) => ({
|
setPwaState({ offlineReady: false });
|
||||||
...state,
|
};
|
||||||
offlineReady: false
|
|
||||||
}));
|
export const setOnlineStatus = (isOnline: boolean) => {
|
||||||
|
setPwaState({ isOffline: !isOnline });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setStandaloneMode = (isStandalone: boolean) => {
|
||||||
|
setPwaState({ isStandalone });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIosSafariInstallable = () => {
|
||||||
|
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent || '';
|
||||||
|
const isIos = /iPad|iPhone|iPod/.test(userAgent);
|
||||||
|
const isWebkit = /WebKit/.test(userAgent);
|
||||||
|
const isCriOS = /CriOS/.test(userAgent);
|
||||||
|
const isFxiOS = /FxiOS/.test(userAgent);
|
||||||
|
|
||||||
|
return isIos && isWebkit && !isCriOS && !isFxiOS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncInstallHelpState = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStandalone =
|
||||||
|
window.matchMedia?.('(display-mode: standalone)').matches || (window.navigator as any)?.standalone === true;
|
||||||
|
const dismissed =
|
||||||
|
typeof localStorage !== 'undefined' && localStorage.getItem(IOS_HINT_DISMISSED_KEY) === 'true';
|
||||||
|
|
||||||
|
setPwaState({
|
||||||
|
isStandalone,
|
||||||
|
showIosInstallHint: isIosSafariInstallable() && !isStandalone && !dismissed && !deferredInstallPrompt
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dismissIosInstallHint = () => {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(IOS_HINT_DISMISSED_KEY, 'true');
|
||||||
|
}
|
||||||
|
setPwaState({ showIosInstallHint: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const promptInstall = async () => {
|
export const promptInstall = async () => {
|
||||||
@@ -54,21 +100,20 @@ export const promptInstall = async () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pwaState.update((state) => ({
|
setPwaState({
|
||||||
...state,
|
|
||||||
installing: true
|
installing: true
|
||||||
}));
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deferredInstallPrompt.prompt();
|
await deferredInstallPrompt.prompt();
|
||||||
const choice = await deferredInstallPrompt.userChoice;
|
const choice = await deferredInstallPrompt.userChoice;
|
||||||
clearInstallPrompt();
|
clearInstallPrompt();
|
||||||
|
syncInstallHelpState();
|
||||||
return choice?.outcome === 'accepted';
|
return choice?.outcome === 'accepted';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pwaState.update((state) => ({
|
setPwaState({
|
||||||
...state,
|
|
||||||
installing: false
|
installing: false
|
||||||
}));
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { appController } from '$lib/app/controller';
|
import { appController } from '$lib/app/controller';
|
||||||
import { dismissOfflineReady, promptInstall, pwaState } from '$lib/pwa';
|
import { dismissIosInstallHint, dismissOfflineReady, promptInstall, pwaState } from '$lib/pwa';
|
||||||
import { appState, unreadNotificationsCount } from '$lib/app/store';
|
import { appState, unreadNotificationsCount } from '$lib/app/store';
|
||||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js';
|
import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js';
|
||||||
@@ -40,6 +40,11 @@
|
|||||||
const reloadForUpdate = () => {
|
const reloadForUpdate = () => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
const offlineTitle = () => ($appState.session ? 'Offline Shell Mode' : 'Offline');
|
||||||
|
const offlineDescription = () =>
|
||||||
|
$appState.session
|
||||||
|
? 'Cached screens remain available, but sign-in refresh, live monitoring, camera control, and realtime updates need connectivity.'
|
||||||
|
: 'You can open the cached shell, but sign-in and device setup need an internet connection.';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div data-sim-page={pageKey} class="flex h-full w-full">
|
<div data-sim-page={pageKey} class="flex h-full w-full">
|
||||||
@@ -87,6 +92,20 @@
|
|||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if $pwaState.showIosInstallHint}
|
||||||
|
<Alert class="glass-card border-white/10 bg-background/95 shadow-lg">
|
||||||
|
<AlertTitle class="text-xs">Install on iPhone</AlertTitle>
|
||||||
|
<AlertDescription class="pr-8 text-xs text-gray-300">
|
||||||
|
In Safari, tap the Share button, then choose “Add to Home Screen” to install PhoneCam.
|
||||||
|
</AlertDescription>
|
||||||
|
<AlertAction>
|
||||||
|
<Button variant="ghost" size="sm" class="text-gray-300" onclick={dismissIosInstallHint}>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</AlertAction>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $pwaState.updateAvailable}
|
{#if $pwaState.updateAvailable}
|
||||||
<Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg">
|
<Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg">
|
||||||
<AlertTitle class="text-xs">Update Ready</AlertTitle>
|
<AlertTitle class="text-xs">Update Ready</AlertTitle>
|
||||||
@@ -255,6 +274,16 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||||
|
{#if $pwaState.isOffline}
|
||||||
|
<div class="px-4 pt-4 md:px-8 md:pt-6 lg:px-10">
|
||||||
|
<Alert class="glass-card border-amber-500/20 bg-[#141418]/95 shadow-lg">
|
||||||
|
<AlertTitle class="text-xs uppercase tracking-wide text-amber-300">{offlineTitle()}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs text-gray-300">
|
||||||
|
{offlineDescription()}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,14 @@
|
|||||||
import { registerSW } from 'virtual:pwa-register';
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { appController } from '$lib/app/controller';
|
import { appController } from '$lib/app/controller';
|
||||||
import { clearInstallPrompt, setInstallPrompt, setOfflineReady, setUpdateAvailable } from '$lib/pwa';
|
import {
|
||||||
|
clearInstallPrompt,
|
||||||
|
setInstallPrompt,
|
||||||
|
setOfflineReady,
|
||||||
|
setOnlineStatus,
|
||||||
|
setUpdateAvailable,
|
||||||
|
syncInstallHelpState
|
||||||
|
} from '$lib/pwa';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -22,20 +29,37 @@
|
|||||||
const handleBeforeInstallPrompt = (event) => {
|
const handleBeforeInstallPrompt = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setInstallPrompt(event);
|
setInstallPrompt(event);
|
||||||
|
syncInstallHelpState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAppInstalled = () => {
|
const handleAppInstalled = () => {
|
||||||
clearInstallPrompt();
|
clearInstallPrompt();
|
||||||
|
syncInstallHelpState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
setOnlineStatus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
setOnlineStatus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
setOnlineStatus(navigator.onLine);
|
||||||
|
syncInstallHelpState();
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
window.addEventListener('appinstalled', handleAppInstalled);
|
window.addEventListener('appinstalled', handleAppInstalled);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
void appController.init();
|
void appController.init();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
void appController.destroy();
|
void appController.destroy();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user