diff --git a/backend/main.py b/backend/main.py index d707063..45839e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,8 +3,10 @@ from fastapi import FastAPI, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from sqlalchemy.orm import Session +from typing import List, Dict +from pydantic import BaseModel from backend.database import engine, Base, get_db from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs @@ -18,7 +20,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.3.0" +VERSION = "0.3.1" app = FastAPI( title="Seismo Fleet Manager", description="Backend API for managing seismograph fleet status", @@ -104,6 +106,99 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request}) +# ===== PWA ROUTES ===== + +@app.get("/sw.js") +async def service_worker(): + """Serve service worker with proper headers for PWA""" + return FileResponse( + "backend/static/sw.js", + media_type="application/javascript", + headers={ + "Service-Worker-Allowed": "/", + "Cache-Control": "no-cache" + } + ) + + +@app.get("/offline-db.js") +async def offline_db_script(): + """Serve offline database script""" + return FileResponse( + "backend/static/offline-db.js", + media_type="application/javascript", + headers={"Cache-Control": "no-cache"} + ) + + +# Pydantic model for sync edits +class EditItem(BaseModel): + id: int + unitId: str + changes: Dict + timestamp: int + + +class SyncEditsRequest(BaseModel): + edits: List[EditItem] + + +@app.post("/api/sync-edits") +async def sync_edits(request: SyncEditsRequest, db: Session = Depends(get_db)): + """Process offline edit queue and sync to database""" + from backend.models import RosterUnit + + results = [] + synced_ids = [] + + for edit in request.edits: + try: + # Find the unit + unit = db.query(RosterUnit).filter_by(id=edit.unitId).first() + + if not unit: + results.append({ + "id": edit.id, + "status": "error", + "reason": f"Unit {edit.unitId} not found" + }) + continue + + # Apply changes + for key, value in edit.changes.items(): + if hasattr(unit, key): + # Handle boolean conversions + if key in ['deployed', 'retired']: + setattr(unit, key, value in ['true', True, 'True', '1', 1]) + else: + setattr(unit, key, value if value != '' else None) + + db.commit() + + results.append({ + "id": edit.id, + "status": "success" + }) + synced_ids.append(edit.id) + + except Exception as e: + db.rollback() + results.append({ + "id": edit.id, + "status": "error", + "reason": str(e) + }) + + synced_count = len(synced_ids) + + return JSONResponse({ + "synced": synced_count, + "total": len(request.edits), + "synced_ids": synced_ids, + "results": results + }) + + @app.get("/partials/roster-deployed", response_class=HTMLResponse) async def roster_deployed_partial(request: Request): """Partial template for deployed units tab""" @@ -114,12 +209,15 @@ async def roster_deployed_partial(request: Request): for unit_id, unit_data in snapshot["active"].items(): units_list.append({ "id": unit_id, - "status": unit_data["status"], - "age": unit_data["age"], - "last_seen": unit_data["last"], - "deployed": unit_data["deployed"], + "status": unit_data.get("status", "Unknown"), + "age": unit_data.get("age", "N/A"), + "last_seen": unit_data.get("last", "Never"), + "deployed": unit_data.get("deployed", False), "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), "last_calibrated": unit_data.get("last_calibrated"), "next_calibration_due": unit_data.get("next_calibration_due"), "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), @@ -149,12 +247,15 @@ async def roster_benched_partial(request: Request): for unit_id, unit_data in snapshot["benched"].items(): units_list.append({ "id": unit_id, - "status": unit_data["status"], - "age": unit_data["age"], - "last_seen": unit_data["last"], - "deployed": unit_data["deployed"], + "status": unit_data.get("status", "N/A"), + "age": unit_data.get("age", "N/A"), + "last_seen": unit_data.get("last", "Never"), + "deployed": unit_data.get("deployed", False), "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), "last_calibrated": unit_data.get("last_calibrated"), "next_calibration_due": unit_data.get("next_calibration_due"), "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), diff --git a/backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md b/backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md new file mode 100644 index 0000000..a62823f --- /dev/null +++ b/backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md @@ -0,0 +1,78 @@ +# PWA Icon Generation Instructions + +The PWA manifest requires 8 icon sizes for full compatibility across devices. + +## Required Icon Sizes + +- 72x72px +- 96x96px +- 128x128px +- 144x144px +- 152x152px +- 192x192px +- 384x384px +- 512x512px (maskable) + +## Design Guidelines + +**Background:** Navy blue (#142a66) +**Icon/Logo:** Orange (#f48b1c) +**Style:** Simple, recognizable design that works at small sizes + +## Quick Generation Methods + +### Option 1: Online PWA Icon Generator + +1. Visit: https://www.pwabuilder.com/imageGenerator +2. Upload a 512x512px source image +3. Download the generated icon pack +4. Copy PNG files to this directory + +### Option 2: ImageMagick (Command Line) + +If you have a 512x512px source image called `source-icon.png`: + +```bash +# From the icons directory +for size in 72 96 128 144 152 192 384 512; do + convert source-icon.png -resize ${size}x${size} icon-${size}.png +done +``` + +### Option 3: Photoshop/GIMP + +1. Create a 512x512px canvas +2. Add your design (navy background + orange icon) +3. Save/Export for each required size +4. Name files as: icon-72.png, icon-96.png, etc. + +## Temporary Placeholder + +For testing, you can use a simple colored square: + +```bash +# Generate simple colored placeholder icons +for size in 72 96 128 144 152 192 384 512; do + convert -size ${size}x${size} xc:#142a66 \ + -gravity center \ + -fill '#f48b1c' \ + -pointsize $((size / 2)) \ + -annotate +0+0 'SFM' \ + icon-${size}.png +done +``` + +## Verification + +After generating icons, verify: +- All 8 sizes exist in this directory +- Files are named exactly: icon-72.png, icon-96.png, etc. +- Images have transparent or navy background +- Logo/text is clearly visible at smallest size (72px) + +## Testing PWA Installation + +1. Open SFM in Chrome on Android or Safari on iOS +2. Look for "Install App" or "Add to Home Screen" prompt +3. Check that the correct icon appears in the install dialog +4. After installation, verify icon on home screen diff --git a/backend/static/icons/icon-128.png b/backend/static/icons/icon-128.png new file mode 100644 index 0000000..83af799 Binary files /dev/null and b/backend/static/icons/icon-128.png differ diff --git a/backend/static/icons/icon-128.png.svg b/backend/static/icons/icon-128.png.svg new file mode 100644 index 0000000..e812a63 --- /dev/null +++ b/backend/static/icons/icon-128.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-144.png b/backend/static/icons/icon-144.png new file mode 100644 index 0000000..d8d90b5 Binary files /dev/null and b/backend/static/icons/icon-144.png differ diff --git a/backend/static/icons/icon-144.png.svg b/backend/static/icons/icon-144.png.svg new file mode 100644 index 0000000..5de8b3a --- /dev/null +++ b/backend/static/icons/icon-144.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-152.png b/backend/static/icons/icon-152.png new file mode 100644 index 0000000..9ef75af Binary files /dev/null and b/backend/static/icons/icon-152.png differ diff --git a/backend/static/icons/icon-152.png.svg b/backend/static/icons/icon-152.png.svg new file mode 100644 index 0000000..a3f0850 --- /dev/null +++ b/backend/static/icons/icon-152.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-192.png b/backend/static/icons/icon-192.png new file mode 100644 index 0000000..3290b47 Binary files /dev/null and b/backend/static/icons/icon-192.png differ diff --git a/backend/static/icons/icon-192.png.svg b/backend/static/icons/icon-192.png.svg new file mode 100644 index 0000000..ef79877 --- /dev/null +++ b/backend/static/icons/icon-192.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-384.png b/backend/static/icons/icon-384.png new file mode 100644 index 0000000..2cf0aef Binary files /dev/null and b/backend/static/icons/icon-384.png differ diff --git a/backend/static/icons/icon-384.png.svg b/backend/static/icons/icon-384.png.svg new file mode 100644 index 0000000..f71f324 --- /dev/null +++ b/backend/static/icons/icon-384.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-512.png b/backend/static/icons/icon-512.png new file mode 100644 index 0000000..b2c82dd Binary files /dev/null and b/backend/static/icons/icon-512.png differ diff --git a/backend/static/icons/icon-512.png.svg b/backend/static/icons/icon-512.png.svg new file mode 100644 index 0000000..39b3068 --- /dev/null +++ b/backend/static/icons/icon-512.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-72.png b/backend/static/icons/icon-72.png new file mode 100644 index 0000000..d0d0359 Binary files /dev/null and b/backend/static/icons/icon-72.png differ diff --git a/backend/static/icons/icon-72.png.svg b/backend/static/icons/icon-72.png.svg new file mode 100644 index 0000000..5ebfd03 --- /dev/null +++ b/backend/static/icons/icon-72.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/icons/icon-96.png b/backend/static/icons/icon-96.png new file mode 100644 index 0000000..cbcff51 Binary files /dev/null and b/backend/static/icons/icon-96.png differ diff --git a/backend/static/icons/icon-96.png.svg b/backend/static/icons/icon-96.png.svg new file mode 100644 index 0000000..1217879 --- /dev/null +++ b/backend/static/icons/icon-96.png.svg @@ -0,0 +1,4 @@ + diff --git a/backend/static/manifest.json b/backend/static/manifest.json new file mode 100644 index 0000000..8d0c879 --- /dev/null +++ b/backend/static/manifest.json @@ -0,0 +1,78 @@ +{ + "name": "Seismo Fleet Manager", + "short_name": "SFM", + "description": "Real-time seismograph and modem fleet monitoring and management", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#142a66", + "theme_color": "#f48b1c", + "icons": [ + { + "src": "/static/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "/static/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/static/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "/static/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "/static/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/static/screenshots/dashboard.png", + "type": "image/png", + "sizes": "540x720", + "form_factor": "narrow" + } + ], + "categories": ["utilities", "productivity"], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View fleet status dashboard", + "url": "/", + "icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Fleet Roster", + "short_name": "Roster", + "description": "View and manage fleet roster", + "url": "/roster", + "icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/backend/static/mobile.css b/backend/static/mobile.css new file mode 100644 index 0000000..c8db026 --- /dev/null +++ b/backend/static/mobile.css @@ -0,0 +1,564 @@ +/* Mobile-specific styles for Seismo Fleet Manager */ +/* Touch-optimized, portrait-first design */ + +/* ===== MOBILE TOUCH TARGETS ===== */ +@media (max-width: 767px) { + /* Buttons - 44x44px minimum (iOS standard) */ + .btn, button:not(.tab-button), .button, a.button { + min-width: 44px; + min-height: 44px; + padding: 12px 16px; + } + + /* Icon-only buttons */ + .icon-button, .btn-icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + /* Form inputs - 48px height, 16px font prevents iOS zoom */ + input:not([type="checkbox"]):not([type="radio"]), + select, + textarea { + min-height: 48px; + font-size: 16px !important; + padding: 12px 16px; + } + + /* Checkboxes and radio buttons - larger touch targets */ + input[type="checkbox"], + input[type="radio"] { + width: 24px; + height: 24px; + min-height: 24px; + } + + /* Bottom nav buttons - 56px industry standard */ + .bottom-nav button { + min-height: 56px; + padding: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + } + + /* Increase spacing between clickable elements */ + .btn + .btn, + button + button { + margin-left: 8px; + } +} + +/* ===== HAMBURGER MENU ===== */ +.hamburger-btn { + position: fixed; + top: 1rem; + left: 1rem; + z-index: 50; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + transition: all 0.2s; +} + +.hamburger-btn:active { + transform: scale(0.95); +} + +.dark .hamburger-btn { + background-color: #1e293b; + border-color: #374151; +} + +/* Hamburger icon */ +.hamburger-icon { + width: 24px; + height: 24px; + display: flex; + flex-direction: column; + justify-content: space-around; +} + +.hamburger-line { + width: 100%; + height: 2px; + background-color: #374151; + transition: all 0.3s; +} + +.dark .hamburger-line { + background-color: #e5e7eb; +} + +/* Hamburger animation when menu open */ +.menu-open .hamburger-line:nth-child(1) { + transform: translateY(8px) rotate(45deg); +} + +.menu-open .hamburger-line:nth-child(2) { + opacity: 0; +} + +.menu-open .hamburger-line:nth-child(3) { + transform: translateY(-8px) rotate(-45deg); +} + +/* ===== SIDEBAR (RESPONSIVE) ===== */ +.sidebar { + position: fixed; + left: 0; + top: 0; + width: 16rem; /* 256px */ + height: 100vh; + z-index: 40; + transition: transform 0.3s ease-in-out; +} + +@media (max-width: 767px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } +} + +@media (min-width: 768px) { + .sidebar { + transform: translateX(0) !important; + } +} + +/* ===== BACKDROP ===== */ +.backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 30; + opacity: 0; + transition: opacity 0.3s ease-in-out; + pointer-events: none; +} + +.backdrop.show { + opacity: 1; + pointer-events: auto; +} + +@media (min-width: 768px) { + .backdrop { + display: none; + } +} + +/* ===== BOTTOM NAVIGATION ===== */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 4rem; + background-color: white; + border-top: 1px solid #e5e7eb; + z-index: 20; + box-shadow: 0 -1px 3px 0 rgb(0 0 0 / 0.1); +} + +.dark .bottom-nav { + background-color: #1e293b; + border-top-color: #374151; +} + +.bottom-nav-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: #6b7280; + transition: all 0.2s; + border: none; + background: none; + cursor: pointer; + width: 100%; + height: 100%; +} + +.bottom-nav-btn:active { + transform: scale(0.95); + background-color: #f3f4f6; +} + +.dark .bottom-nav-btn:active { + background-color: #374151; +} + +.bottom-nav-btn.active { + color: #f48b1c; /* seismo-orange */ +} + +.bottom-nav-btn svg { + width: 24px; + height: 24px; +} + +.bottom-nav-btn span { + font-size: 11px; + font-weight: 500; +} + +@media (min-width: 768px) { + .bottom-nav { + display: none; + } +} + +/* ===== MAIN CONTENT ADJUSTMENTS ===== */ +.main-content { + margin-left: 0; + padding-bottom: 5rem; /* 80px for bottom nav */ + min-height: 100vh; +} + +@media (min-width: 768px) { + .main-content { + margin-left: 16rem; /* 256px sidebar width */ + padding-bottom: 0; + } +} + +/* ===== MOBILE ROSTER CARDS ===== */ +.unit-card { + background-color: white; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + padding: 1rem; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + -webkit-tap-highlight-color: transparent; +} + +.unit-card:active { + transform: scale(0.98); +} + +.dark .unit-card { + background-color: #1e293b; +} + +.unit-card:hover { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); +} + +/* ===== UNIT DETAIL MODAL (BOTTOM SHEET) ===== */ +.unit-modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: flex-end; + justify-content: center; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease-in-out; +} + +.unit-modal.show { + pointer-events: auto; + opacity: 1; +} + +.unit-modal-backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); +} + +.unit-modal-content { + position: relative; + width: 100%; + max-height: 85vh; + background-color: white; + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1); + overflow-y: auto; + transform: translateY(100%); + transition: transform 0.3s ease-out; +} + +.unit-modal.show .unit-modal-content { + transform: translateY(0); +} + +.dark .unit-modal-content { + background-color: #1e293b; +} + +@media (min-width: 768px) { + .unit-modal { + align-items: center; + } + + .unit-modal-content { + max-width: 42rem; /* 672px */ + border-radius: 0.75rem; + transform: translateY(20px); + opacity: 0; + } + + .unit-modal.show .unit-modal-content { + transform: translateY(0); + opacity: 1; + } +} + +/* Modal handle bar (mobile only) */ +.modal-handle { + height: 4px; + width: 3rem; + background-color: #d1d5db; + border-radius: 9999px; + margin: 0.75rem auto 1rem; +} + +@media (min-width: 768px) { + .modal-handle { + display: none; + } +} + +/* ===== OFFLINE INDICATOR ===== */ +.offline-indicator { + position: fixed; + top: 0; + left: 0; + right: 0; + background-color: #eab308; /* yellow-500 */ + color: white; + text-align: center; + padding: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + z-index: 50; + transform: translateY(-100%); + transition: transform 0.3s ease-in-out; +} + +.offline-indicator.show { + transform: translateY(0); +} + +/* ===== SYNC TOAST ===== */ +.sync-toast { + position: fixed; + bottom: 6rem; /* Above bottom nav */ + left: 1rem; + right: 1rem; + background-color: #22c55e; /* green-500 */ + color: white; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + z-index: 50; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s, transform 0.3s; + pointer-events: none; +} + +.sync-toast.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +@media (min-width: 768px) { + .sync-toast { + bottom: 1rem; + left: auto; + right: 1rem; + max-width: 20rem; + } +} + +/* ===== MOBILE SEARCH BAR (STICKY) ===== */ +@media (max-width: 767px) { + .mobile-search-sticky { + position: sticky; + top: 0; + z-index: 10; + background-color: #f3f4f6; + margin: -1rem -1rem 1rem -1rem; + padding: 0.5rem 1rem; + } + + .dark .mobile-search-sticky { + background-color: #111827; + } +} + +@media (min-width: 768px) { + .mobile-search-sticky { + position: static; + background-color: transparent; + margin: 0; + padding: 0; + } +} + +/* ===== STATUS BADGES ===== */ +.status-dot { + width: 1rem; + height: 1rem; + border-radius: 9999px; + flex-shrink: 0; +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +/* ===== DEVICE TYPE BADGES ===== */ +.device-badge { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + display: inline-block; +} + +/* ===== MOBILE MAP HEIGHT ===== */ +@media (max-width: 767px) { + #fleet-map { + height: 16rem !important; /* 256px on mobile */ + } + + #unit-map { + height: 16rem !important; /* 256px on mobile */ + } +} + +/* ===== PENDING SYNC BADGE ===== */ +.pending-sync-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + background-color: #fef3c7; /* amber-100 */ + color: #92400e; /* amber-800 */ + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.dark .pending-sync-badge { + background-color: #78350f; + color: #fef3c7; +} + +.pending-sync-badge::before { + content: "⏳"; + font-size: 0.875rem; +} + +/* ===== MOBILE-SPECIFIC UTILITY CLASSES ===== */ +@media (max-width: 767px) { + .mobile-text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .mobile-text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + + .mobile-p-4 { + padding: 1rem; + } + + .mobile-mb-4 { + margin-bottom: 1rem; + } +} + +/* ===== ACCESSIBILITY ===== */ +/* Improve focus visibility on mobile */ +@media (max-width: 767px) { + button:focus-visible, + a:focus-visible, + input:focus-visible, + select:focus-visible, + textarea:focus-visible { + outline: 2px solid #f48b1c; + outline-offset: 2px; + } +} + +/* Prevent text selection on buttons (better mobile UX) */ +button, +.btn, +.button { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* ===== SMOOTH SCROLLING ===== */ +html { + scroll-behavior: smooth; +} + +/* Prevent overscroll bounce on iOS */ +body { + overscroll-behavior-y: none; +} + +/* ===== LOADING STATES ===== */ +.loading-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* ===== SAFE AREA SUPPORT (iOS notch) ===== */ +@supports (padding: env(safe-area-inset-bottom)) { + .bottom-nav { + padding-bottom: env(safe-area-inset-bottom); + height: calc(4rem + env(safe-area-inset-bottom)); + } + + .main-content { + padding-bottom: calc(5rem + env(safe-area-inset-bottom)); + } + + @media (min-width: 768px) { + .main-content { + padding-bottom: 0; + } + } +} diff --git a/backend/static/mobile.js b/backend/static/mobile.js new file mode 100644 index 0000000..6575f95 --- /dev/null +++ b/backend/static/mobile.js @@ -0,0 +1,588 @@ +/* Mobile JavaScript for Seismo Fleet Manager */ +/* Handles hamburger menu, modals, offline sync, and mobile interactions */ + +// ===== GLOBAL STATE ===== +let currentUnitData = null; +let isOnline = navigator.onLine; + +// ===== HAMBURGER MENU TOGGLE ===== +function toggleMenu() { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('backdrop'); + const hamburgerBtn = document.getElementById('hamburgerBtn'); + + if (sidebar && backdrop) { + const isOpen = sidebar.classList.contains('open'); + + if (isOpen) { + // Close menu + sidebar.classList.remove('open'); + backdrop.classList.remove('show'); + hamburgerBtn?.classList.remove('menu-open'); + document.body.style.overflow = ''; + } else { + // Open menu + sidebar.classList.add('open'); + backdrop.classList.add('show'); + hamburgerBtn?.classList.add('menu-open'); + document.body.style.overflow = 'hidden'; + } + } +} + +// Close menu when clicking backdrop +function closeMenuFromBackdrop() { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('backdrop'); + const hamburgerBtn = document.getElementById('hamburgerBtn'); + + if (sidebar && backdrop) { + sidebar.classList.remove('open'); + backdrop.classList.remove('show'); + hamburgerBtn?.classList.remove('menu-open'); + document.body.style.overflow = ''; + } +} + +// Close menu when window is resized to desktop +function handleResize() { + if (window.innerWidth >= 768) { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('backdrop'); + const hamburgerBtn = document.getElementById('hamburgerBtn'); + + if (sidebar && backdrop) { + sidebar.classList.remove('open'); + backdrop.classList.remove('show'); + hamburgerBtn?.classList.remove('menu-open'); + document.body.style.overflow = ''; + } + } +} + +// ===== UNIT DETAIL MODAL ===== +function openUnitModal(unitId, status = null, age = null) { + const modal = document.getElementById('unitModal'); + if (!modal) return; + + // Store the status info passed from the card + // Accept status if it's a non-empty string, use age if provided or default to '--' + const cardStatusInfo = (status && status !== '') ? { + status: status, + age: age || '--' + } : null; + + console.log('openUnitModal:', { unitId, status, age, cardStatusInfo }); + + // Fetch unit data and populate modal + fetchUnitDetails(unitId).then(unit => { + if (unit) { + currentUnitData = unit; + // Pass the card status info to the populate function + populateUnitModal(unit, cardStatusInfo); + modal.classList.add('show'); + document.body.style.overflow = 'hidden'; + } + }); +} + +function closeUnitModal(event) { + // Only close if clicking backdrop or close button + if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) { + return; + } + + const modal = document.getElementById('unitModal'); + if (modal) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + currentUnitData = null; + } +} + +async function fetchUnitDetails(unitId) { + try { + // Try to fetch from network first + const response = await fetch(`/api/roster/${unitId}`); + if (response.ok) { + const unit = await response.json(); + + // Save to IndexedDB if offline support is available + if (window.offlineDB) { + await window.offlineDB.saveUnit(unit); + } + + return unit; + } + } catch (error) { + console.log('Network fetch failed, trying offline storage:', error); + + // Fall back to offline storage + if (window.offlineDB) { + return await window.offlineDB.getUnit(unitId); + } + } + + return null; +} + +function populateUnitModal(unit, cardStatusInfo = null) { + // Set unit ID in header + const modalUnitId = document.getElementById('modalUnitId'); + if (modalUnitId) { + modalUnitId.textContent = unit.id; + } + + // Populate modal content + const modalContent = document.getElementById('modalContent'); + if (!modalContent) return; + + // Use status from card if provided, otherwise get from snapshot or derive from unit + let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit); + console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo }); + + const statusColor = statusInfo.status === 'OK' ? 'green' : + statusInfo.status === 'Pending' ? 'yellow' : + statusInfo.status === 'Missing' ? 'red' : 'gray'; + + const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' : + statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' : + statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' : + 'text-gray-600 dark:text-gray-400'; + + // Determine status label (show "Benched" instead of "Unknown" for non-deployed units) + let statusLabel = statusInfo.status; + if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) { + statusLabel = 'Benched'; + } + + // Create navigation URL for location + const createNavUrl = (address, coordinates) => { + if (address) { + // Use address for navigation + const encodedAddress = encodeURIComponent(address); + // Universal link that works on iOS and Android + return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`; + } else if (coordinates) { + // Use coordinates for navigation (format: lat,lon) + const encodedCoords = encodeURIComponent(coordinates); + return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`; + } + return null; + }; + + const navUrl = createNavUrl(unit.address, unit.coordinates); + + modalContent.innerHTML = ` + +
${unit.device_type || '--'}
+${unit.unit_type}
+${unit.project_id}
+${unit.address}
+ `} +${unit.coordinates}
+ `} +${unit.last_calibrated}
+${unit.next_calibration_due}
+${unit.deployed_with_modem_id}
+${unit.ip_address}
+${unit.phone_number}
+${unit.hardware_model}
+${unit.note}
+${unit.deployed ? 'Yes' : 'No'}
+${unit.retired ? 'Yes' : 'No'}
+SFM requires an internet connection for this page.
+Please check your connection and try again.
+ +