diff --git a/app/public/app-ui.css b/app/public/app-ui.css index 6322ad7..a87ee01 100644 --- a/app/public/app-ui.css +++ b/app/public/app-ui.css @@ -324,3 +324,9 @@ body.map-fs #tab-unified-map { padding: 0; } .alert-full p { margin: 0 0 0.6rem; } .alert-pre { white-space: pre-wrap; } .alert-meta { font-size: 0.74rem; color: var(--ink-light); } + +/* Location permission status line */ +.geo-status { font-size: 0.8rem; line-height: 1.45; color: var(--ink-light); margin: 0.5rem 0.15rem 0; padding: 0.5rem 0.75rem; border-radius: 12px; background: var(--paper-darker); } +.geo-status.hidden { display: none; } +.geo-status.ok { color: #22c55e; } +.geo-status.warn { color: var(--ink); background: color-mix(in srgb, #ef4444 14%, var(--paper-darker)); border: 1px solid color-mix(in srgb, #ef4444 45%, transparent); } diff --git a/app/public/app-ui.js b/app/public/app-ui.js index 2fa23a7..5d96637 100644 --- a/app/public/app-ui.js +++ b/app/public/app-ui.js @@ -405,6 +405,36 @@ function centerMap(city, force) { $('map-recenter-btn')?.addEventListener('click', () => { if (state.currentCity) centerMap(state.currentCity, true); }); })(); + +// ── Location permission status (so "blocked" is visible, not guessed) ──────── +const GeoStatus = (() => { + const el = $('geo-status'); + let status = null; + async function render() { + if (!el || !navigator.permissions || !navigator.geolocation) return; + try { + if (!status) { + status = await navigator.permissions.query({ name: 'geolocation' }); + status.addEventListener('change', render); + } + } catch { return; } + const s = status.state; + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + const standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; + if (s === 'granted') { el.className = 'geo-status ok'; el.textContent = '📍 Location: allowed. Tap ⌖ to use it.'; } + else if (s === 'prompt') { el.className = 'geo-status'; el.textContent = '📍 Location: not asked yet. Tap ⌖ and choose Allow when your phone asks.'; } + else if (s === 'denied') { + el.className = 'geo-status warn'; + el.textContent = ios + ? `📍 Location: your phone has this ${standalone ? 'app' : 'site'} set to Don't Allow. Fix it in Settings → Privacy & Security → Location Services → ${standalone ? '"NeCloud Weather"' : 'Safari Websites'}. A Safari content blocker can also do this: try aA → Turn Off Content Blockers.` + : '📍 Location: blocked for this site. Click the lock icon in the address bar → Location → Allow.'; + } else { el.classList.add('hidden'); return; } + el.classList.remove('hidden'); + } + render(); + return { refresh: render }; +})(); + // ── Public hooks ───────────────────────────────────────────────────────────── let lastOutlook = null; window.AppUI = { @@ -427,6 +457,7 @@ window.AppUI = { if (stationFresh) renderStationContext(); }, centerMap, + refreshGeoStatus: () => GeoStatus.refresh(), onAir(air) { renderAqi(air); }, refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fxResize(c); fxSeed(fx.kind); if (fx.parts.length) fxFrame(performance.now()); } }, onStation() { syncTopbarLocation(); renderStationContext(); }, diff --git a/app/public/app.js b/app/public/app.js index 47631cd..1cc53e1 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -2273,26 +2273,63 @@ searchBtn.addEventListener('click', doSearch); searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); // ── Geolocation ─────────────────────────────────────────────────────────────── -locateBtn.addEventListener('click', () => { +// Why a location request failed, in plain language (the browser tells us: 1 denied, 2 unavailable, 3 timeout) +const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); +const IS_STANDALONE = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; +async function geoPermissionState() { + try { return (await navigator.permissions.query({ name: 'geolocation' })).state; } catch { return 'unknown'; } +} +function geoErrorHelp(err, perm) { + const code = err && err.code; + const tag = ` (error ${code ?? '?'}${perm && perm !== 'unknown' ? ', permission: ' + perm : ''})`; + if (code === 1) { + if (IS_IOS) { + const where = IS_STANDALONE + ? 'Settings → Privacy & Security → Location Services → "NeCloud Weather" → While Using the App' + : 'Settings → Privacy & Security → Location Services → Safari Websites → While Using, then in Safari tap aA → Website Settings → Location → Allow'; + return `Your phone says location is off for ${IS_STANDALONE ? 'this app' : 'this site'}. Turn it on in ${where}. If you use a Safari content blocker, also try aA → Turn Off Content Blockers for this site.${tag}`; + } + return `Location is blocked for this site. Click the lock icon in the address bar → Location → Allow, then try again.${tag}`; + } + if (code === 2) return `Your device couldn't find your position yet (no GPS or Wi-Fi fix). Make sure Location Services is on, turn off Low Power Mode, and try again, ideally on Wi-Fi or outdoors.${tag}`; + if (code === 3) return `Timed out waiting for your location. Try again in a moment.${tag}`; + return `Could not get your location.${tag}`; +} +function geoOnce(opts) { + return new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, opts)); +} +// A slow or cold GPS often times out the first time: retry once with a patient, high-accuracy request. +// A real "denied" is final, so don't retry that. +async function getPositionRobust() { + try { + return await geoOnce({ enableHighAccuracy: false, timeout: 12000, maximumAge: 5 * 60 * 1000 }); + } catch (e) { + if (e && e.code === 1) throw e; + return await geoOnce({ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }); + } +} + +locateBtn.addEventListener('click', async () => { if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; } clearErr(); hide(weatherDiv); show(loadingEl); - - navigator.geolocation.getCurrentPosition(async pos => { + try { + const pos = await getPositionRobust(); const { latitude: lat, longitude: lon } = pos.coords; try { const city = await buildDetectedCity(lat, lon); await loadAll(city, 'gps-button'); } catch { showErr('Could not fetch weather for your location.'); - } finally { - hide(loadingEl); } - }, () => { + } catch (err) { + if (state.currentWeather) show(weatherDiv); + showErr(geoErrorHelp(err, await geoPermissionState())); + } finally { hide(loadingEl); - showErr('Location access denied.'); - }); + if (window.AppUI && window.AppUI.refreshGeoStatus) window.AppUI.refreshGeoStatus(); + } }); unitsBtn.addEventListener('click', () => { diff --git a/app/public/index.html b/app/public/index.html index 00d246f..37cc1d2 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -449,6 +449,7 @@ +