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:
parent
ff12646844
commit
d238af55da
8 changed files with 630 additions and 145 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">⋮</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">›</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();
|
||||
}
|
||||
|
||||
|
|
|
|||
7
frontend/lib/chartjs-plugin-zoom.min.js
vendored
Normal file
7
frontend/lib/chartjs-plugin-zoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
frontend/lib/hammer.min.js
vendored
Normal file
7
frontend/lib/hammer.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue