Android support + Settings screen + Mobile UX fixes

Settings: profile button, offline maps toggle, GPS permission toggle.
Import: unified GPX/FTB, detect by extension.
Mobile UX: bigger nav, no native alerts, safe area insets.
Track: seq for GPX, stored session stats in detail view.
This commit is contained in:
4aiman 2026-05-14 14:17:57 +03:00
commit d238af55da
8 changed files with 630 additions and 145 deletions

View file

@ -60,6 +60,7 @@ body {
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding-top: calc(12px + var(--safe-area-top));
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
@ -198,29 +199,7 @@ body {
transform: scale(0.95);
}
/* Bottom nav */
.bottom-nav {
display: flex;
justify-content: space-around;
padding: 10px 0;
background: var(--surface);
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
}
.bottom-nav button {
background: none;
border: none;
color: var(--muted);
font-size: 12px;
padding: 4px 16px;
cursor: pointer;
}
.bottom-nav button.active {
color: var(--primary);
}
/* Bottom nav — see .nav-btn below for button styles */
/* Modal / add entry overlay */
.modal-overlay {
@ -379,9 +358,11 @@ body {
.bottom-nav {
order: 2;
display: flex;
background: var(--surface);
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
padding-bottom: var(--safe-area-bottom);
}
.screen-content {
@ -392,18 +373,27 @@ body {
}
.nav-btn {
flex: 1;
background: none;
border: none;
color: var(--muted);
font-size: 13px;
padding: 8px 12px;
font-size: 12px;
padding: 14px 4px;
padding-bottom: calc(14px + var(--safe-area-bottom));
cursor: pointer;
font-family: inherit;
text-align: center;
-webkit-tap-highlight-color: rgba(187,134,252,0.15);
}
.nav-btn:active {
background: rgba(187,134,252,0.1);
}
.nav-btn.active {
color: var(--primary);
font-weight: 600;
border-top: 2px solid var(--primary);
}
/* Day navigation */
@ -647,6 +637,82 @@ body {
/* Products screen */
.products-screen, .stub-screen, .settings-screen {
padding: 16px;
padding-top: calc(16px + var(--safe-area-top));
}
.settings-section {
margin-bottom: 16px;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
}
.settings-section-title {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 14px 4px;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.settings-item:last-child { border-bottom: none; }
.settings-item:active { background: rgba(255,255,255,0.03); }
.settings-item-title {
font-size: 15px;
color: var(--on-surface);
}
.settings-item-desc {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.settings-arrow {
font-size: 20px;
color: var(--muted);
}
.settings-toggle {
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--border);
position: relative;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s;
}
.settings-toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--on-bg);
transition: transform 0.2s;
}
.settings-toggle.on {
background: var(--primary);
}
.settings-toggle.on::after {
transform: translateX(20px);
}
.products-screen h2, .stub-screen h2, .settings-screen h2 {
@ -879,8 +945,67 @@ body {
z-index: 1;
}
/* Recording screen */
.rec-screen {
display: flex;
flex-direction: column;
height: 100%;
}
.rec-map {
flex: 1;
min-height: 300px;
}
.rec-stats {
display: flex;
justify-content: space-around;
padding: 12px 8px;
background: var(--surface);
border-top: 1px solid var(--border);
}
.rec-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.rec-val {
font-size: 22px;
font-weight: 700;
color: var(--on-surface);
}
.rec-lbl {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
}
.rec-bottom {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
padding-bottom: calc(8px + var(--safe-area-bottom));
background: var(--surface);
}
.rec-bottom .rec-status {
font-size: 12px;
color: var(--muted);
flex: 1;
}
.rec-bottom .gps-rec {
padding: 12px 32px;
flex-shrink: 0;
}
.gps-header {
padding: 10px 12px;
padding-top: calc(10px + var(--safe-area-top));
background: var(--surface);
border-bottom: 1px solid var(--border);
}
@ -1013,6 +1138,14 @@ body {
border-radius: 0;
}
.sd-map-full .leaflet-top {
top: var(--safe-area-top) !important;
}
.sd-map-full .leaflet-bottom {
bottom: var(--safe-area-bottom) !important;
}
.sd-map-fs-btn {
position: relative;
z-index: 10000;
@ -1023,13 +1156,14 @@ body {
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--on-bg);
width: 32px;
height: 32px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
font-size: 18px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.zoom-display {

View file

@ -14,6 +14,8 @@
then main.js uses them. Tauri injects window.__TAURI__ before page scripts. -->
<script src="lib/leaflet.js"></script>
<script src="lib/chart.min.js"></script>
<script src="lib/hammer.min.js"></script>
<script src="lib/chartjs-plugin-zoom.min.js"></script>
<script src="lib/jszip.min.js"></script>
<script src="js/db.js"></script>
<script src="js/bmr.js"></script>

View file

@ -366,13 +366,13 @@ async function getActivityTotalsForDate(date) {
// ---------------------------------------------------------------------------
async function createGpsSession(date, startTime, activityType) {
const id = Date.now();
await execute(
`INSERT INTO gps_sessions (date, start_time, activity_type, source)
VALUES (?1, ?2, ?3, 'recorded')`,
[date, startTime, activityType]
`INSERT INTO gps_sessions (id, date, start_time, activity_type, source)
VALUES (?1, ?2, ?3, ?4, 'recorded')`,
[id, date, startTime, activityType]
);
const rows = await select("SELECT last_insert_rowid() as id");
return rows[0].id;
return id;
}
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs) {
@ -424,7 +424,11 @@ async function importGpxSession(session, points) {
session.max_speed_kmh, session.elevation_gain_m, session.elevation_loss_m,
session.source ?? "gpx_import"]
);
const rows = await select("SELECT last_insert_rowid() as id");
// Get id of just-inserted session by matching unique fields
const rows = await select(
"SELECT id FROM gps_sessions WHERE date=?1 AND start_time=?2 AND source=?3 ORDER BY id DESC LIMIT 1",
[session.date, session.start_time, session.source ?? "gpx_import"]
);
const sid = rows[0].id;
// Batch insert points — build multi-row INSERT to avoid 1 IPC per point
const BATCH = 50;
@ -489,4 +493,27 @@ window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.importGpxSession = importGpxSession;
// JS-side migrations — safe for any DB state (checks before altering)
async function runMigrations() {
async function hasColumn(table, col) {
const info = await select(`PRAGMA table_info(${table})`);
return info.some(r => r.name === col);
}
if (!(await hasColumn("products", "composition"))) {
await execute("ALTER TABLE products ADD COLUMN composition TEXT");
}
if (!(await hasColumn("products", "name_lower"))) {
await execute("ALTER TABLE products ADD COLUMN name_lower TEXT");
}
if (!(await hasColumn("gps_points", "seq"))) {
await execute("ALTER TABLE gps_points ADD COLUMN seq INTEGER");
}
}
window.DB._migrationsReady = runMigrations().then(() => {
console.log("[db.js] migrations done");
}).catch(e => {
console.error("[db.js] migration error:", e);
});
console.log("[db.js] window.DB ready, keys:", Object.keys(window.DB).length);

View file

@ -133,11 +133,15 @@ GPS.startRecording = async function(activityType, weightKg) {
GPS.recording.watchId = navigator.geolocation.watchPosition(
async (pos) => {
// Skip inaccurate fixes (>100m accuracy = unreliable)
if (pos.coords.accuracy > 100) return;
const p = {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
altitude: pos.coords.altitude,
speed_ms: pos.coords.speed,
accuracy: pos.coords.accuracy,
ts: new Date(pos.timestamp).toISOString(),
};
GPS.recording.points.push(p);
@ -150,7 +154,7 @@ GPS.startRecording = async function(activityType, weightKg) {
console.error("GPS error:", err.message);
if (GPS.recording.onError) GPS.recording.onError(err);
},
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 10000 }
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
);
};
@ -231,8 +235,8 @@ GPS.importGPX = async function(xmlString, weightKg) {
source: "gpx_import",
};
const dbPoints = points.map(p => ({
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed,
const dbPoints = points.map((p, idx) => ({
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
}));
return DB.importGpxSession(session, dbPoints);

View file

@ -24,10 +24,22 @@ async function init() {
return;
}
// Wait for DB migrations
if (DB._migrationsReady) await DB._migrationsReady;
// Apply safe area insets (Android status bar / nav bar)
try {
const insets = await window.__TAURI__.core.invoke("get_safe_insets");
if (insets.top || insets.bottom) {
document.documentElement.style.setProperty("--safe-area-top", insets.top + "px");
document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px");
}
} catch {}
state.profile = await DB.getFullProfile();
state.latestWeight = await DB.getLatestWeight();
if (!state.profile.height_cm) {
if (false) {
renderScreen("settings");
} else {
renderScreen("today");
@ -63,7 +75,7 @@ function buildNav(active) {
{ id: "products", label: "Продукты" },
{ id: "gps", label: "Трек" },
{ id: "supplements", label: "Добавки" },
{ id: "settings", label: "Профиль" },
{ id: "settings", label: "Настройки" },
].forEach(item => {
const btn = document.createElement("button");
btn.className = "nav-btn" + (item.id === active ? " active" : "");
@ -688,8 +700,7 @@ async function renderGpsScreen(container) {
<button id="gps-menu-btn" class="gps-menu-btn">&#x22EE;</button>
<div id="gps-menu" class="gps-menu hidden">
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import-gpx">Импорт GPX</div>
<div class="gps-menu-item" id="gps-import-ftb">Импорт FitoTrack (.ftb)</div>
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
</div>
</div>
@ -718,20 +729,19 @@ async function renderGpsScreen(container) {
// Recording button
container.querySelector("#gps-rec-btn").onclick = async () => {
if (GPS.recording.active) {
const result = await GPS.stopRecording();
renderScreen("gps");
showRecordingScreen();
} else {
const type = container.querySelector("#gps-type").value;
const wkg = state.latestWeight?.weight_kg ?? 80;
await GPS.startRecording(type, wkg);
setupLiveUpdates(container);
container.querySelector("#gps-rec-btn").textContent = "Стоп";
container.querySelector("#gps-rec-btn").classList.add("recording");
showRecordingScreen();
}
};
// If already recording, hook up live updates
if (GPS.recording.active) setupLiveUpdates(container);
if (GPS.recording.active) {
// If already recording, go straight to recording screen
showRecordingScreen();
}
// Session click → detail view
container.querySelectorAll(".gps-session-item").forEach(item => {
@ -759,39 +769,171 @@ async function renderGpsScreen(container) {
container.querySelector("#gps-clear-cache").onclick = async () => {
menu.classList.add("hidden");
const count = await TileCache.count();
if (!confirm(`Удалить ${count} кэшированных тайлов?`)) return;
await TileCache.clear();
alert("Кэш очищен");
console.log("Cache cleared");
};
// GPX import
container.querySelector("#gps-import-gpx").onclick = () => {
// Unified import — detect by extension
container.querySelector("#gps-import").onclick = () => {
menu.classList.add("hidden");
pickFile(".gpx", async (text) => {
pickFile("", async (data, file) => {
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
const ext = file.name.split(".").pop().toLowerCase();
console.log("[IMPORT] ext:", ext);
const wkg = state.latestWeight?.weight_kg ?? 80;
await GPS.importGPX(text, wkg);
renderScreen("gps");
});
};
// FitoTrack .ftb import
container.querySelector("#gps-import-ftb").onclick = () => {
menu.classList.add("hidden");
pickFile(".ftb,.zip", async (blob, file) => {
console.log("[FTB] file:", file.name, "size:", blob.byteLength);
try {
const wkg = state.latestWeight?.weight_kg ?? 80;
const count = await GPS.importFTB(blob, wkg);
alert(`Импортировано ${count} тренировок`);
if (ext === "gpx") {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
await GPS.importGPX(text, wkg);
} else if (ext === "ftb" || ext === "zip") {
const buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
console.log("[IMPORT] FTB buf size:", buf.byteLength);
const count = await GPS.importFTB(buf, wkg);
console.log("[IMPORT] done, count:", count);
} else {
console.error("[IMPORT] Unsupported file type: " + ext);
}
renderScreen("gps");
} catch (e) {
console.error("[FTB] import error:", e);
alert("FTB import error: " + (e.message || e));
console.error("[IMPORT] error:", e, String(e));
}
}, true);
};
}
function showRecordingScreen() {
showModal(`
<div class="rec-screen">
<div id="rec-map" class="rec-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">GPS...</div>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
</div>`,
() => {});
// Hide modal OK/Cancel buttons
const overlay = document.querySelector(".modal-overlay");
const btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
setTimeout(() => {
const mapEl = document.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false }).setView([55.75, 37.62], 15);
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
const borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
const trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map);
let posMarker = null;
// Center on user
navigator.geolocation?.getCurrentPosition(
pos => {
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
document.querySelector("#rec-status").textContent = "GPS OK";
},
() => { document.querySelector("#rec-status").textContent = "GPS не найден"; },
{ enableHighAccuracy: true, timeout: 10000 }
);
// Live point updates
const distEl = document.querySelector("#rec-dist");
const spdEl = document.querySelector("#rec-speed");
const timeEl = document.querySelector("#rec-time");
const elevEl = document.querySelector("#rec-elev");
const statusEl = document.querySelector("#rec-status");
// Speed buffer for median
const speedBuf = [];
GPS.recording.onPoint = (p) => {
const ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
// Stats
const pts = GPS.recording.points;
const stats = GPS.computeStats(pts, GPS.recording.weightKg);
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
// Speed: from device speed or computed from distance
let curSpeed = 0;
if (p.speed_ms != null && p.speed_ms >= 0) {
curSpeed = p.speed_ms * 3.6;
} else if (pts.length >= 2) {
// Fallback: compute from last 2 points
const prev = pts[pts.length - 2];
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
if (dt > 0) curSpeed = (d / dt) * 3.6;
}
speedBuf.push(curSpeed);
if (speedBuf.length > 5) speedBuf.shift();
const sorted = speedBuf.slice().sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] || 0;
if (spdEl) spdEl.textContent = median.toFixed(1);
// Elevation
const alt = p.altitude;
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
// Pause detection — only if we have enough data and speed is actually low
if (speedBuf.length >= 3 && median < 1) {
statusEl.textContent = "Пауза";
statusEl.style.color = "#ffb74d";
} else {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Timer
const timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
const sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
// Stop button
document.querySelector("#rec-stop").onclick = async () => {
const result = await GPS.stopRecording();
clearInterval(timer);
// Ask for name
// TODO: custom naming modal
// Close modal
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("gps");
};
// Restore trail if already recording
if (GPS.recording.points.length > 0) {
const coords = GPS.recording.points.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
map.fitBounds(trailLine.getBounds().pad(0.1));
}
}, 200);
}
function setupLiveUpdates(container) {
const distEl = container.querySelector("#gs-dist");
const spdEl = container.querySelector("#gs-speed");
@ -926,11 +1068,31 @@ function showMapDownloadModal() {
// ---------------------------------------------------------------------------
async function showSessionDetail(sessionId) {
try {
console.log("[SD] loading session", sessionId);
const session = await DB.getGpsSession(sessionId);
if (!session) return;
if (!session) { console.error("[SD] session not found"); return; }
console.log("[SD] loading points");
const rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
const points = GPS.filterNoise(rawPoints, session.activity_type);
const stats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? 80);
console.log("[SD] filtered to", points.length, "points");
// Use stored session stats if available (computed correctly at import time),
// fallback to recomputing from points
const storedStats = session.total_distance_m != null;
const stats = storedStats ? {
distance: Math.round(session.total_distance_m || 0),
avgSpeed: session.avg_speed_kmh || 0,
maxSpeed: session.max_speed_kmh || 0,
kcal: Math.round(session.total_kcal || 0),
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? 80);
// Compute duration from points regardless
if (points.length >= 2) {
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
}
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
const rawSpeeds = [];
@ -944,22 +1106,26 @@ async function showSessionDetail(sessionId) {
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
}
}
// 9-point moving average on top
// 31-point moving average — kills GPS jitter from phone-in-pocket
const HALF_WIN = 15;
const smoothed = rawSpeeds.map((_, i) => {
let sum = 0, count = 0;
for (let j = Math.max(0, i - 4); j <= Math.min(rawSpeeds.length - 1, i + 4); j++) {
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
sum += rawSpeeds[j]; count++;
}
return Math.round(sum / count * 10) / 10;
});
// maxSpeed from smoothed graph (not raw)
const graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
// Elevation data
const elevations = points.filter(p => p.altitude_gps_m != null).map(p => p.altitude_gps_m);
const minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
const maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
const t0 = new Date(points[0]?.ts).getTime();
const t0 = points.length ? new Date(points[0].ts).getTime() : 0;
const timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
const actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", other: "Другое" };
@ -1000,7 +1166,7 @@ async function showSessionDetail(sessionId) {
<div class="sd-title">Скорость</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<canvas id="sd-speed-chart" height="150"></canvas>
</div>
@ -1036,7 +1202,11 @@ async function showSessionDetail(sessionId) {
if (!mapEl || !points.length) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false });
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false });
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
@ -1126,7 +1296,7 @@ async function showSessionDetail(sessionId) {
map.setView(ll, 15);
L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map);
},
err => alert("GPS: " + err.message),
err => console.error("GPS error:", err.message),
{ enableHighAccuracy: true, timeout: 10000 }
);
};
@ -1144,6 +1314,9 @@ async function showSessionDetail(sessionId) {
setTimeout(() => {
const ctx = document.querySelector("#sd-speed-chart");
if (!ctx || !smoothed.length) return;
// Update maxSpeed display to match smoothed graph
const maxEl = document.querySelector("#sd-max-speed");
if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч";
new Chart(ctx, {
type: "line",
data: {
@ -1159,7 +1332,14 @@ async function showSessionDetail(sessionId) {
},
options: {
responsive: true,
plugins: { legend: { display: false } },
plugins: {
legend: { display: false },
zoom: {
pan: { enabled: true, mode: "x" },
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
pan: { enabled: false },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
@ -1192,7 +1372,14 @@ async function showSessionDetail(sessionId) {
},
options: {
responsive: true,
plugins: { legend: { display: false } },
plugins: {
legend: { display: false },
zoom: {
pan: { enabled: true, mode: "x" },
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
pan: { enabled: false },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
@ -1207,13 +1394,13 @@ async function showSessionDetail(sessionId) {
setTimeout(() => {
const del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
if (!confirm("Удалить тренировку?")) return;
await DB.deleteGpsSession(sessionId);
document.querySelector(".modal-overlay")?.remove();
history.back();
renderScreen("gps");
};
}, 100);
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
}
// ---------------------------------------------------------------------------
@ -1409,84 +1596,149 @@ function showEditSupplementModal(existing) {
async function renderSettings(container) {
const p = state.profile;
const profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
<div class="settings-screen">
<h2>Профиль</h2>
<div class="form-group">
<label>Рост (см)</label>
<input id="s-height" type="number" inputmode="numeric"
value="${p.height_cm ?? ""}" placeholder="178" />
</div>
<div class="form-group">
<label>Год рождения</label>
<input id="s-year" type="number" inputmode="numeric"
value="${p.birth_year ?? ""}" placeholder="1988" />
</div>
<div class="form-group">
<label>Пол</label>
<div class="type-chips">
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
<h2>Настройки</h2>
<div class="settings-section">
<div class="settings-item" id="s-open-profile">
<div class="settings-item-left">
<div class="settings-item-title">Профиль</div>
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
</div>
<span class="settings-arrow">&#x203A;</span>
</div>
</div>
<div class="form-group">
<label>BMR по формуле (ккал/день)</label>
<input id="s-bmr-formula" type="number" inputmode="numeric"
value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="s-bmr-est" type="number" inputmode="numeric"
value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>
<div class="form-group" style="margin-top:20px">
<label>Карты</label>
<div class="type-chips">
<button class="type-chip ${TileCache.offlineMode ? "" : "selected"}" data-map="online">Онлайн</button>
<button class="type-chip ${TileCache.offlineMode ? "selected" : ""}" data-map="offline">Оффлайн</button>
<div class="settings-section">
<div class="settings-section-title">Карты</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Оффлайн режим</div>
<div class="settings-item-desc">Тайлы только из кэша</div>
</div>
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Разрешения</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Геолокация (GPS)</div>
<div class="settings-item-desc" id="s-gps-status">Проверка...</div>
</div>
<button class="settings-toggle" id="s-gps-toggle"></button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">О приложении</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">BalDav</div>
<div class="settings-item-desc">Версия ${document.querySelector("meta[name=version]")?.content || "dev"}</div>
</div>
</div>
<span style="font-size:11px;color:var(--muted)">Оффлайн: тайлы только из кэша, интернет не используется</span>
</div>
<button id="save-profile" class="btn-primary full-width">Сохранить</button>
</div>`;
// Profile button → open profile modal
container.querySelector("#s-open-profile").onclick = () => showProfileModal();
// Offline toggle
const offToggle = container.querySelector("#s-offline-toggle");
offToggle.onclick = () => {
const newVal = !TileCache.offlineMode;
TileCache.setOfflineMode(newVal);
offToggle.classList.toggle("on", newVal);
};
// GPS permission check
const gpsStatus = container.querySelector("#s-gps-status");
const gpsToggle = container.querySelector("#s-gps-toggle");
if (navigator.permissions && navigator.permissions.query) {
try {
const result = await navigator.permissions.query({ name: "geolocation" });
gpsStatus.textContent = result.state === "granted" ? "Разрешено" : result.state === "denied" ? "Запрещено" : "Не запрошено";
gpsToggle.classList.toggle("on", result.state === "granted");
} catch { gpsStatus.textContent = "Недоступно"; }
}
gpsToggle.onclick = () => {
navigator.geolocation.getCurrentPosition(
() => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); },
() => { gpsStatus.textContent = "Запрещено"; gpsToggle.classList.remove("on"); },
{ enableHighAccuracy: true, timeout: 5000 }
);
};
}
function showProfileModal() {
const p = state.profile;
let selectedSex = p.sex ?? "m";
container.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
container.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
container.querySelectorAll("[data-map]").forEach(btn => {
btn.onclick = () => {
container.querySelectorAll("[data-map]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
TileCache.setOfflineMode(btn.dataset.map === "offline");
};
});
showModal(`
<h3>Профиль</h3>
<div class="form-group">
<label>Рост (см)</label>
<input id="m-height" type="number" inputmode="numeric" value="${p.height_cm ?? ""}" placeholder="178" />
</div>
<div class="form-group">
<label>Год рождения</label>
<input id="m-year" type="number" inputmode="numeric" value="${p.birth_year ?? ""}" placeholder="1988" />
</div>
<div class="form-group">
<label>Пол</label>
<div class="type-chips">
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
</div>
</div>
<div class="form-group">
<label>BMR по формуле (ккал/день)</label>
<input id="m-bmr-formula" type="number" inputmode="numeric" value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="m-bmr-est" type="number" inputmode="numeric" value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>`,
async overlay => {
const height = parseInt(overlay.querySelector("#m-height").value);
const year = parseInt(overlay.querySelector("#m-year").value);
const bFormula = parseInt(overlay.querySelector("#m-bmr-formula").value) || null;
const bEst = parseInt(overlay.querySelector("#m-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) return false;
container.querySelector("#save-profile").onclick = async () => {
const height = parseInt(container.querySelector("#s-height").value);
const year = parseInt(container.querySelector("#s-year").value);
const bFormula = parseInt(container.querySelector("#s-bmr-formula").value) || null;
const bEst = parseInt(container.querySelector("#s-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) { alert("Введите рост и год рождения"); return; }
await DB.setProfileValue("height_cm", height);
await DB.setProfileValue("height_cm", height);
await DB.setProfileValue("birth_year", year);
await DB.setProfileValue("sex", selectedSex);
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
await DB.setProfileValue("sex", selectedSex);
if (bFormula) await DB.setProfileValue("bmr_formula", bFormula);
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
state.profile = { ...state.profile,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
bmr_formula: bFormula ? String(bFormula) : null,
bmr_estimated: bEst ? String(bEst) : null };
renderScreen("today");
};
});
// Sex chip handler inside modal
setTimeout(() => {
const overlay = document.querySelector(".modal-overlay");
if (!overlay) return;
overlay.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
}, 100);
}
// ---------------------------------------------------------------------------
@ -1588,12 +1840,18 @@ function addZoomDisplay(map) {
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.onchange = async () => {
if (accept) input.accept = accept;
input.style.display = "none";
document.body.appendChild(input);
input.addEventListener("change", async () => {
console.log("[pickFile] change event, files:", input.files.length);
const file = input.files[0];
document.body.removeChild(input);
if (!file) return;
console.log("[pickFile] file:", file.name, file.size);
try {
if (asBinary) {
const buf = await file.arrayBuffer();
@ -1603,9 +1861,9 @@ function pickFile(accept, callback, asBinary) {
await callback(text, file);
}
} catch (e) {
alert("Import error: " + e.message);
console.error("[pickFile] error:", e);
}
};
});
input.click();
}

File diff suppressed because one or more lines are too long

7
frontend/lib/hammer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,46 @@
use tauri_plugin_sql::{Migration, MigrationKind};
#[allow(unused_imports)]
use tauri::Manager;
use serde::Serialize;
#[derive(Serialize)]
struct SafeInsets {
top: i32,
bottom: i32,
left: i32,
right: i32,
}
#[cfg(target_os = "android")]
#[tauri::command]
fn get_safe_insets(app: tauri::AppHandle) -> SafeInsets {
let data_dir = app.path().app_data_dir().unwrap_or_default();
let insets_file = data_dir.join("safe_insets.json");
for _ in 0..10 {
if let Ok(content) = std::fs::read_to_string(&insets_file) {
if let Ok(insets) = serde_json::from_str::<serde_json::Value>(&content) {
return SafeInsets {
top: insets["top"].as_i64().unwrap_or(0) as i32,
bottom: insets["bottom"].as_i64().unwrap_or(0) as i32,
left: insets["left"].as_i64().unwrap_or(0) as i32,
right: insets["right"].as_i64().unwrap_or(0) as i32,
};
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
SafeInsets { top: 0, bottom: 0, left: 0, right: 0 }
}
#[cfg(not(target_os = "android"))]
#[tauri::command]
fn get_safe_insets() -> SafeInsets {
SafeInsets { top: 0, bottom: 0, left: 0, right: 0 }
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = vec![
let migrations: Vec<Migration> = vec![
Migration {
version: 1,
description: "full schema v1",
@ -183,6 +222,12 @@ pub fn run() {
sql: "ALTER TABLE products ADD COLUMN composition TEXT;",
kind: MigrationKind::Up,
},
Migration {
version: 3,
description: "reserved",
sql: "SELECT 1;",
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
@ -191,6 +236,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![get_safe_insets])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}