import { App } from '@capacitor/app'; import { Capacitor } from '@capacitor/core'; import { Keyboard } from '@capacitor/keyboard'; import { getToken, clearToken, setToken, isUsageTrialExpired } from './auth.js'; import { apiGet, fetchAndApplyMobileConfig, getStoredMobileConfig } from './api.js'; import { deriveMobileExperience, getStoredUserProfile, setStoredUserProfile, clearStoredUserProfile } from './mobile-profile.js'; import { getNotifications, getUnreadNotificationCount, markAllNotificationsRead, markAllNotificationsReadEverywhere, NOTIFICATIONS_UPDATED_EVENT, refreshRemoteNotifications, } from './notifications.js'; import { renderDashboard } from './views/dashboard.js'; import { renderActivation } from './views/activation.js'; import { renderLogin } from './views/login.js'; import { renderProximity } from './views/proximity.js'; import { renderAssigned } from './views/assigned.js'; import { renderTreatment } from './views/treatment.js'; import { renderFtthList } from './views/ftth-list.js'; import { renderFtthReport } from './views/ftth-report.js'; import { initializeTrackingService, stopTrackingService, syncTrackingService } from './tracking.js'; import { getMobileActivationState, syncMobileActivationState } from './activation.js'; import { syncUsageTrialWithServer } from './trial.js'; const SHELL_META = { '/dashboard': { title: 'Dashboard', subtitle: 'Pilotage rapide des interventions mobiles', }, '/assigned': { title: 'Incidents', subtitle: 'Traitement terrain et suivi des tickets', backTarget: '/dashboard', }, '/nearby': { title: 'Proximité agent', subtitle: 'Clients FTTH proches et itinéraires terrain', backTarget: '/dashboard', }, '/treatment': { title: 'Traitement incident', subtitle: 'Saisie terrain et clôture opérationnelle', backTarget: '/assigned', }, '/ftth': { title: 'FTTH B2B', subtitle: 'Portefeuille terrain et interventions assignées', backTarget: '/dashboard', }, '/ftth-report': { title: 'Fiche terrain', subtitle: 'Expérience mobile premium pour Android', backTarget: '/ftth', }, }; let activeShellRoot = null; let activeUserProfile = getStoredUserProfile(); let nativeBackListenerReady = false; let notificationPollingHandle = null; let responsiveViewportReady = false; let baselineViewportHeight = 0; let pendingFieldRevealHandle = null; let pendingViewportSyncFrame = null; let pendingViewportResetBaseline = false; let lastViewportMetrics = null; let pendingKeyboardViewportRevealHandle = null; let keyboardListenersReady = false; let nativeKeyboardInset = 0; const BOOT_NETWORK_TIMEOUT_MS = 4500; function parseHash() { const raw = (location.hash || '#/activate').slice(1); const [pathPart, queryPart] = raw.split('?'); const path = pathPart || '/activate'; const params = new URLSearchParams(queryPart || ''); return { path, params }; } function isTextEntryElement(target) { return target instanceof HTMLElement && target.matches('input:not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([type="range"]), textarea, select, [contenteditable="true"]'); } function syncViewportMetrics({ resetBaseline = false } = {}) { const root = document.documentElement; const viewport = window.visualViewport; const visibleHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0); const viewportOffsetTop = Math.round(viewport?.offsetTop || 0); if (resetBaseline || !baselineViewportHeight || visibleHeight >= baselineViewportHeight - 48) { baselineViewportHeight = Math.max(baselineViewportHeight, visibleHeight); } const viewportKeyboardInset = Math.max(0, baselineViewportHeight - visibleHeight - viewportOffsetTop); const keyboardInset = Math.max(viewportKeyboardInset, nativeKeyboardInset); const keyboardOpen = keyboardInset > 120 && isTextEntryElement(document.activeElement); const nextMetrics = { visibleHeight: Math.max(visibleHeight, 320), keyboardInset, keyboardOpen, }; if ( !resetBaseline && lastViewportMetrics && Math.abs(lastViewportMetrics.visibleHeight - nextMetrics.visibleHeight) <= 2 && Math.abs(lastViewportMetrics.keyboardInset - nextMetrics.keyboardInset) <= 2 && lastViewportMetrics.keyboardOpen === nextMetrics.keyboardOpen ) { return; } lastViewportMetrics = nextMetrics; root.style.setProperty('--app-height', `${nextMetrics.visibleHeight}px`); root.style.setProperty('--app-keyboard-offset', `${keyboardInset}px`); document.body.classList.toggle('keyboard-open', keyboardOpen); } function queueViewportMetricsSync({ resetBaseline = false } = {}) { pendingViewportResetBaseline = pendingViewportResetBaseline || resetBaseline; if (pendingViewportSyncFrame !== null) { return; } pendingViewportSyncFrame = window.requestAnimationFrame(() => { const shouldResetBaseline = pendingViewportResetBaseline; pendingViewportSyncFrame = null; pendingViewportResetBaseline = false; syncViewportMetrics({ resetBaseline: shouldResetBaseline }); }); } function findScrollableContainer(target) { let node = target?.parentElement || null; while (node) { const style = window.getComputedStyle(node); const overflowY = style.overflowY || style.overflow; const canScroll = /(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 4; if (canScroll) { return node; } node = node.parentElement; } return document.scrollingElement || document.documentElement; } function getFieldRevealPadding() { const keyboardInset = lastViewportMetrics?.keyboardInset || 0; return Math.max(24, Math.min(140, Math.round(keyboardInset * 0.35) + 24)); } function revealFocusedField(target, delay = 220) { if (!isTextEntryElement(target)) { return; } if (pendingFieldRevealHandle) { window.clearTimeout(pendingFieldRevealHandle); } pendingFieldRevealHandle = window.setTimeout(() => { if (!document.contains(target)) { return; } const container = findScrollableContainer(target); const viewportHeight = Math.round(window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight || 0); const keyboardInset = lastViewportMetrics?.keyboardInset || 0; const padding = getFieldRevealPadding(); const visibleBottom = Math.max(120, viewportHeight - Math.max(keyboardInset, padding)); target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' }); const targetRect = target.getBoundingClientRect(); if (container instanceof HTMLElement && container !== document.body && container !== document.documentElement) { const containerRect = container.getBoundingClientRect(); const topLimit = containerRect.top + padding; const bottomLimit = Math.min(containerRect.bottom - padding, visibleBottom); if (targetRect.bottom > bottomLimit) { container.scrollTop += targetRect.bottom - bottomLimit; } else if (targetRect.top < topLimit) { container.scrollTop -= topLimit - targetRect.top; } return; } if (targetRect.bottom > visibleBottom) { window.scrollBy({ top: targetRect.bottom - visibleBottom, behavior: 'auto' }); } else if (targetRect.top < padding) { window.scrollBy({ top: targetRect.top - padding, behavior: 'auto' }); } }, delay); } function revealActiveFieldDuringKeyboardTransition(delay = 80) { const activeTarget = document.activeElement; if (!isTextEntryElement(activeTarget)) { return; } if (pendingKeyboardViewportRevealHandle) { window.clearTimeout(pendingKeyboardViewportRevealHandle); } pendingKeyboardViewportRevealHandle = window.setTimeout(() => { pendingKeyboardViewportRevealHandle = null; if (!lastViewportMetrics?.keyboardOpen) { return; } revealFocusedField(activeTarget, 0); }, delay); } function setupResponsiveViewportHandling() { if (responsiveViewportReady) { return; } responsiveViewportReady = true; if (!keyboardListenersReady && Capacitor.getPlatform() === 'android') { keyboardListenersReady = true; Keyboard.addListener('keyboardDidShow', (event) => { nativeKeyboardInset = Math.max(0, Math.round(event?.keyboardHeight || 0)); queueViewportMetricsSync(); revealActiveFieldDuringKeyboardTransition(0); }); Keyboard.addListener('keyboardDidHide', () => { nativeKeyboardInset = 0; queueViewportMetricsSync({ resetBaseline: true }); }); } const handleViewportChange = () => { queueViewportMetricsSync(); revealActiveFieldDuringKeyboardTransition(); }; queueViewportMetricsSync({ resetBaseline: true }); window.addEventListener('resize', handleViewportChange); window.addEventListener('orientationchange', () => { window.setTimeout(() => queueViewportMetricsSync({ resetBaseline: true }), 180); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', handleViewportChange); } document.addEventListener('focusin', (event) => { const target = event.target; if (!isTextEntryElement(target)) { return; } queueViewportMetricsSync(); revealFocusedField(target, 180); }, true); document.addEventListener('focusout', () => { if (pendingFieldRevealHandle) { window.clearTimeout(pendingFieldRevealHandle); pendingFieldRevealHandle = null; } if (pendingKeyboardViewportRevealHandle) { window.clearTimeout(pendingKeyboardViewportRevealHandle); pendingKeyboardViewportRevealHandle = null; } window.setTimeout(() => queueViewportMetricsSync(), 180); }, true); } function setStatus(root, text, kind = 'secondary') { const el = root.querySelector('[data-role="status"]'); if (!el) return; el.className = `mobile-status alert alert-${kind} py-2 mb-0`; el.textContent = text; } function getShellMeta(path, experience) { const baseMeta = SHELL_META[path] || { title: 'Insuite Technicien', subtitle: 'Console mobile terrain', }; if (path === '/ftth') { return { ...baseMeta, title: experience.moduleLabel, subtitle: experience.moduleHeroSubtitle, backTarget: '/dashboard', }; } if (path === '/ftth-report') { return { ...baseMeta, title: experience.technicianType === 'raccordement' ? 'Fiche raccordement' : 'Fiche terrain', subtitle: experience.technicianType === 'raccordement' ? 'Saisie terrain et suivi opérationnel des raccordements clients' : baseMeta.subtitle, backTarget: '/ftth', }; } return baseMeta; } function renderBottomNavigation(path, experience) { return experience.navItems.map((item) => { const isActive = item.activePaths.includes(path); return ` ${item.label} `; }).join(''); } function layoutShell(path, experience) { const meta = getShellMeta(path, experience); const showBack = Boolean(meta.backTarget); const focusedFormRoutes = new Set(['/activate', '/login', '/ftth-report']); const isFocusedFormRoute = focusedFormRoutes.has(path); const root = document.getElementById('app'); root.innerHTML = `