- Sleep mode: Large clock + weather + critical alerts (always-on, dimmed) - Active mode: Full dashboard grid (cameras, weather, maintenance, quick actions) - Auto-wake on tap, auto-sleep after 5 min inactivity - Hardware: Samsung Galaxy Tab A9+ with RAM Mounts (total €367) - Kiosk configuration: Single app lock, always-on display, adaptive brightness - Advanced: Motion detection auto-wake, PWA installable, offline mode - 7 days implementation (€5,600 dev cost)
23 KiB
NaviDocs Android Tablet Kiosk Mode - Wall-Mounted Boat Display
Created: 2025-11-14 Use Case: Wall-mounted Android tablet showing always-on boat status dashboard
User Requirements
Primary Goal: Wall-mounted tablet that shows critical boat information at a glance
Modes:
- Sleep Mode - Clock + weather + critical alerts (screen dimmed, always-on)
- Active Mode - Full NaviDocs interface (tap to wake)
Critical Data (Always Visible in Sleep Mode):
- Current time
- Weather (temp, wind, waves)
- Important pressing alerts (maintenance due, low fuel, camera offline)
Hardware Recommendations
Recommended Tablet:
Samsung Galaxy Tab A9+ (2023)
- ✅ 11" display (readable from 3m distance)
- ✅ 1920×1200 resolution (sharp text)
- ✅ Auto-brightness (adapts to cabin lighting)
- ✅ WiFi + LTE (works at marina or at sea with cellular)
- ✅ USB-C charging (can be wired to boat's 12V system via converter)
- ✅ Android 13+ (supports always-on display, kiosk mode)
- 💰 €229 (affordable for yacht market)
Alternative (Budget):
- Lenovo Tab M10 Plus (3rd Gen) - €199, 10.6" display
Alternative (Premium):
- Samsung Galaxy Tab S9 - €799, 11" AMOLED (better sunlight visibility)
Mounting:
RAM Mounts X-Grip Universal Tablet Mount
- ✅ Marine-grade (waterproof, corrosion-resistant)
- ✅ Adjustable arm (swivel for viewing angle)
- ✅ Vibration-dampening (boat engine vibrations)
- ✅ Quick release (remove for software updates)
- 💰 €89
Power: Victron Orion-Tr 12V to 5V USB Converter
- ✅ Hardwired to boat's 12V system
- ✅ Continuous charging (never dies)
- ✅ Marine-grade (protected from voltage spikes)
- 💰 €34
Kiosk Mode Design
1. Sleep Mode (Always-On Display)
Layout:
┌─────────────────────────────────────────┐
│ │
│ 14:32 │
│ Wednesday │
│ November 14 │
│ │
│ ☀️ 24°C 💨 12 kts 🌊 0.8m │
│ │
│ ⚠️ Oil change due in 3 days │
│ │
│ 🔴 Aft camera offline │
│ │
│ │
│ (Tap anywhere to wake) │
│ │
└─────────────────────────────────────────┘
Visual Design:
<template>
<div class="kiosk-sleep-mode" @click="wakeUp">
<!-- Clock (Large, Centered) -->
<div class="kiosk-clock">
<p class="time">{{ currentTime }}</p>
<p class="date">{{ currentDate }}</p>
</div>
<!-- Weather Strip (Garmin-style metrics) -->
<div class="weather-strip">
<div class="weather-metric">
<span class="icon">☀️</span>
<span class="value">{{ weather.temp }}°C</span>
</div>
<div class="weather-metric">
<span class="icon">💨</span>
<span class="value">{{ weather.windSpeed }} kts</span>
<span class="direction">{{ weather.windDirection }}</span>
</div>
<div class="weather-metric">
<span class="icon">🌊</span>
<span class="value">{{ weather.waveHeight }}m</span>
</div>
</div>
<!-- Critical Alerts (Max 3) -->
<div class="alert-list">
<div
v-for="alert in criticalAlerts.slice(0, 3)"
:key="alert.id"
:class="['alert-item', `severity-${alert.severity}`]"
>
<span class="alert-icon">{{ alert.icon }}</span>
<span class="alert-text">{{ alert.message }}</span>
</div>
</div>
<!-- Wake Hint -->
<p class="wake-hint">Tap anywhere to wake</p>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const currentTime = ref('')
const currentDate = ref('')
const weather = ref({
temp: 24,
windSpeed: 12,
windDirection: 'NE',
waveHeight: 0.8
})
const criticalAlerts = ref([
{ id: 1, severity: 'warning', icon: '⚠️', message: 'Oil change due in 3 days' },
{ id: 2, severity: 'critical', icon: '🔴', message: 'Aft camera offline' }
])
// Update clock every second
let clockInterval
onMounted(() => {
updateClock()
clockInterval = setInterval(updateClock, 1000)
// Fetch weather every 10 minutes
fetchWeather()
setInterval(fetchWeather, 10 * 60 * 1000)
// Fetch alerts every 1 minute
fetchAlerts()
setInterval(fetchAlerts, 60 * 1000)
})
onUnmounted(() => {
clearInterval(clockInterval)
})
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
currentDate.value = now.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
})
}
async function fetchWeather() {
const response = await fetch('/api/weather/current')
const data = await response.json()
weather.value = data
}
async function fetchAlerts() {
const response = await fetch('/api/alerts/critical')
const data = await response.json()
criticalAlerts.value = data
}
function wakeUp() {
// Transition to active mode
window.dispatchEvent(new CustomEvent('kiosk:wake'))
}
</script>
<style scoped>
.kiosk-sleep-mode {
background: linear-gradient(135deg, #0F172A 0%, #1E293B 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
/* Dim screen for sleep mode */
filter: brightness(0.4);
transition: filter 0.3s ease;
}
.kiosk-sleep-mode:active {
filter: brightness(1);
}
.kiosk-clock {
text-align: center;
margin-bottom: 64px;
}
.time {
font-size: 96px; /* Massive clock (readable from 5m) */
font-weight: 300;
line-height: 1;
margin-bottom: 16px;
font-variant-numeric: tabular-nums; /* Monospaced digits */
}
.date {
font-size: 28px;
color: rgba(255, 255, 255, 0.6);
}
.weather-strip {
display: flex;
gap: 64px;
margin-bottom: 64px;
}
.weather-metric {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.weather-metric .icon {
font-size: 48px;
}
.weather-metric .value {
font-size: 36px;
font-weight: 600;
color: #0EA5E9; /* Sky blue */
}
.weather-metric .direction {
font-size: 20px;
color: rgba(255, 255, 255, 0.6);
}
.alert-list {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 600px;
margin-bottom: 64px;
}
.alert-item {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 32px;
background: rgba(255, 255, 255, 0.05);
border-left: 4px solid;
border-radius: 8px;
}
.alert-item.severity-warning {
border-color: #F59E0B; /* Amber */
}
.alert-item.severity-critical {
border-color: #EF4444; /* Red */
}
.alert-icon {
font-size: 32px;
}
.alert-text {
font-size: 24px;
font-weight: 500;
}
.wake-hint {
font-size: 18px;
color: rgba(255, 255, 255, 0.4);
animation: pulse 3s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
</style>
2. Active Mode (Full Interface)
Tap Anywhere → Transition to Full Dashboard
<template>
<div class="kiosk-active-mode">
<!-- Top Status Bar (Always Visible) -->
<div class="kiosk-status-bar">
<div class="status-left">
<span class="time">{{ currentTime }}</span>
<span class="weather-compact">
24°C · 12 kts · 0.8m
</span>
</div>
<div class="status-right">
<span class="battery">🔋 86%</span>
<span class="wifi">📶 WiFi</span>
</div>
</div>
<!-- Main Dashboard Grid -->
<div class="dashboard-grid">
<!-- Camera Grid (4 feeds) -->
<div class="dashboard-card cameras">
<h2>Cameras</h2>
<div class="camera-grid">
<div v-for="camera in cameras" :key="camera.id" class="camera-tile">
<img :src="camera.thumbnail" alt="" />
<span :class="['status-dot', camera.online ? 'online' : 'offline']"></span>
<p class="camera-name">{{ camera.name }}</p>
</div>
</div>
</div>
<!-- Weather Detail -->
<div class="dashboard-card weather">
<h2>Weather</h2>
<div class="weather-detail">
<div class="current-temp">24°C</div>
<div class="forecast-strip">
<div v-for="day in forecast" :key="day.date" class="forecast-day">
<p>{{ day.dayName }}</p>
<p class="temp-high">{{ day.tempHigh }}°</p>
</div>
</div>
</div>
</div>
<!-- Maintenance Countdown -->
<div class="dashboard-card maintenance">
<h2>Maintenance</h2>
<div class="countdown-list">
<div v-for="task in upcomingMaintenance" :key="task.id" class="countdown-item">
<div class="countdown-value">{{ task.daysUntil }}</div>
<div class="countdown-label">days</div>
<p class="task-name">{{ task.name }}</p>
</div>
</div>
</div>
<!-- Quick Actions (Large Buttons) -->
<div class="dashboard-card quick-actions">
<button class="quick-action-btn" @click="$router.push('/inventory')">
<span class="icon">📦</span>
<span class="label">Inventory</span>
</button>
<button class="quick-action-btn" @click="$router.push('/expenses')">
<span class="icon">💰</span>
<span class="label">Expenses</span>
</button>
<button class="quick-action-btn" @click="$router.push('/contacts')">
<span class="icon">📞</span>
<span class="label">Contacts</span>
</button>
<button class="quick-action-btn" @click="enterSleepMode">
<span class="icon">🌙</span>
<span class="label">Sleep</span>
</button>
</div>
</div>
<!-- Auto-sleep after 5 minutes of inactivity -->
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const cameras = ref([
{ id: 1, name: 'Bow', thumbnail: '/camera-snapshots/bow.jpg', online: true },
{ id: 2, name: 'Stern', thumbnail: '/camera-snapshots/stern.jpg', online: true },
{ id: 3, name: 'Port', thumbnail: '/camera-snapshots/port.jpg', online: false },
{ id: 4, name: 'Starboard', thumbnail: '/camera-snapshots/starboard.jpg', online: true }
])
const forecast = ref([
{ date: '2025-11-14', dayName: 'Thu', tempHigh: 24 },
{ date: '2025-11-15', dayName: 'Fri', tempHigh: 26 },
{ date: '2025-11-16', dayName: 'Sat', tempHigh: 23 }
])
const upcomingMaintenance = ref([
{ id: 1, name: 'Oil change', daysUntil: 3 },
{ id: 2, name: 'Hull cleaning', daysUntil: 12 }
])
// Auto-sleep after 5 minutes of inactivity
let sleepTimeout
function resetSleepTimer() {
clearTimeout(sleepTimeout)
sleepTimeout = setTimeout(enterSleepMode, 5 * 60 * 1000) // 5 minutes
}
onMounted(() => {
// Reset sleep timer on any interaction
window.addEventListener('click', resetSleepTimer)
window.addEventListener('touchstart', resetSleepTimer)
resetSleepTimer()
})
onBeforeUnmount(() => {
clearTimeout(sleepTimeout)
window.removeEventListener('click', resetSleepTimer)
window.removeEventListener('touchstart', resetSleepTimer)
})
function enterSleepMode() {
window.dispatchEvent(new CustomEvent('kiosk:sleep'))
}
</script>
<style scoped>
.kiosk-active-mode {
background: linear-gradient(135deg, #0F172A 0%, #1E293B 100%);
min-height: 100vh;
color: rgba(255, 255, 255, 0.9);
}
.kiosk-status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 32px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 18px;
}
.status-left,
.status-right {
display: flex;
gap: 24px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 24px;
padding: 24px;
height: calc(100vh - 64px); /* Subtract status bar height */
}
.dashboard-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 24px;
overflow: hidden;
}
.dashboard-card h2 {
font-size: 24px;
margin-bottom: 20px;
color: #0EA5E9; /* Sky blue */
}
/* Camera Grid */
.cameras {
grid-column: 1 / 2;
grid-row: 1 / 3; /* Span 2 rows */
}
.camera-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.camera-tile {
position: relative;
aspect-ratio: 4/3;
background: #000;
border-radius: 12px;
overflow: hidden;
}
.camera-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.status-dot {
position: absolute;
top: 12px;
right: 12px;
width: 16px;
height: 16px;
border-radius: 50%;
}
.status-dot.online {
background: #10B981; /* Green */
box-shadow: 0 0 16px #10B981;
animation: pulse-dot 2s infinite;
}
.status-dot.offline {
background: #EF4444; /* Red */
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.camera-name {
position: absolute;
bottom: 8px;
left: 8px;
font-size: 14px;
font-weight: 600;
background: rgba(0, 0, 0, 0.7);
padding: 4px 12px;
border-radius: 4px;
}
/* Weather */
.weather {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
.current-temp {
font-size: 72px;
font-weight: 300;
color: #0EA5E9;
text-align: center;
margin-bottom: 24px;
}
.forecast-strip {
display: flex;
justify-content: space-around;
}
.forecast-day {
text-align: center;
}
.temp-high {
font-size: 28px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
/* Maintenance */
.maintenance {
grid-column: 2 / 3;
grid-row: 2 / 3;
}
.countdown-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.countdown-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
border-left: 4px solid #F59E0B; /* Amber */
}
.countdown-value {
font-size: 48px;
font-weight: 700;
color: #F59E0B;
min-width: 80px;
text-align: center;
}
.countdown-label {
font-size: 16px;
color: rgba(255, 255, 255, 0.6);
}
.task-name {
font-size: 20px;
font-weight: 500;
}
/* Quick Actions */
.quick-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.quick-action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(13, 148, 136, 0.2));
border: 1px solid rgba(14, 165, 233, 0.3);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.quick-action-btn:active {
transform: scale(0.95);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.4), rgba(13, 148, 136, 0.4));
}
.quick-action-btn .icon {
font-size: 48px;
}
.quick-action-btn .label {
font-size: 20px;
font-weight: 600;
}
</style>
Android Kiosk Mode Configuration
Step 1: Enable Developer Options
- Go to Settings → About tablet
- Tap Build number 7 times
- Developer options enabled
Step 2: Configure Always-On Display
- Settings → Display → Screen timeout → Never (keep screen always on)
- Settings → Display → Brightness → Adaptive brightness ON
- Settings → Developer options → Stay awake → ON (screen never sleeps when charging)
Step 3: Enable Kiosk Mode (Single App Lock)
Option A: Using Android Enterprise (Free)
# Install Google Play Kiosk app
adb install -r kiosk-mode.apk
# Lock to NaviDocs app
adb shell dpm set-device-owner com.navidocs.kiosk/.KioskDeviceAdminReceiver
# This prevents:
# - Home button (can't exit app)
# - Recent apps (can't switch apps)
# - Notifications (won't interrupt)
Option B: Using 3rd-Party Kiosk App
- Fully Kiosk Browser (€15/year) - Recommended
- Motion detection (wake on approach)
- Remote management (update URL from cloud)
- Scheduled reboots (prevent memory leaks)
- Screenshot API (remote monitoring)
Step 4: Prevent Screen Burn-In
// Rotate content every 10 minutes to prevent OLED burn-in
setInterval(() => {
// Shift clock position slightly
const clockEl = document.querySelector('.kiosk-clock')
const randomX = Math.random() * 20 - 10 // -10px to +10px
const randomY = Math.random() * 20 - 10
clockEl.style.transform = `translate(${randomX}px, ${randomY}px)`
}, 10 * 60 * 1000)
Auto-Wake on Approach (Motion Detection)
Using Tablet's Front Camera + TensorFlow.js
// Detect person approaching tablet
import * as tf from '@tensorflow/tfjs'
import * as cocoSsd from '@tensorflow-models/coco-ssd'
let model
let video
async function initMotionDetection() {
model = await cocoSsd.load()
video = document.createElement('video')
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true })
video.play()
// Check for person every 2 seconds
setInterval(detectPerson, 2000)
}
async function detectPerson() {
const predictions = await model.detect(video)
const personDetected = predictions.some(p => p.class === 'person' && p.score > 0.6)
if (personDetected && isInSleepMode) {
wakeUp()
}
}
Alternative: PIR Motion Sensor (Hardware)
- HC-SR501 PIR Sensor (€3)
- Connect to tablet via USB OTG + Arduino
- Triggers wake event when motion detected within 5m
Progressive Web App (PWA) Configuration
Make NaviDocs installable on Android home screen
// public/manifest.json
{
"name": "NaviDocs Kiosk",
"short_name": "NaviDocs",
"description": "Boat management dashboard",
"start_url": "/kiosk",
"display": "fullscreen", // Hide status bar and nav bar
"orientation": "landscape", // Force landscape for wall mount
"theme_color": "#0F172A",
"background_color": "#0F172A",
"icons": [
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Install prompt:
- User visits NaviDocs in Chrome
- Chrome shows "Add to Home Screen" banner
- Tap → App installs as standalone app
- Launch from home screen → Full-screen kiosk mode
Offline Mode (For At-Sea Use)
Service Worker for offline functionality:
// public/sw.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('navidocs-kiosk-v1').then((cache) => {
return cache.addAll([
'/',
'/kiosk',
'/styles/kiosk.css',
'/scripts/kiosk.js',
'/api/weather/last-known', // Cached weather data
'/camera-snapshots/bow.jpg',
'/camera-snapshots/stern.jpg'
])
})
)
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Return cached version if offline
return response || fetch(event.request)
})
)
})
Power Management
Continuous Charging Setup:
- Mount near 12V power outlet
- Use Victron Orion-Tr converter (12V → 5V USB-C)
- Enable battery saver:
- Settings → Battery → Battery Saver → ON when charging
- Limits background apps to extend battery lifespan
Prevent Overheating:
- Mount in shaded area (not direct sunlight)
- Leave 5cm air gap behind tablet (ventilation)
- Use Samsung's "Adaptive Battery" feature (learns usage patterns, throttles when idle)
Security Considerations
Prevent Unauthorized Access:
- Disable lock screen in kiosk mode (no PIN prompt, always shows dashboard)
- Admin settings behind hidden gesture (e.g., 5-finger tap on logo)
- Remote wipe capability (if tablet stolen)
// Hidden admin panel access
let tapCount = 0
let tapTimer
function handleLogoTap() {
tapCount++
clearTimeout(tapTimer)
tapTimer = setTimeout(() => { tapCount = 0 }, 2000) // Reset after 2s
if (tapCount === 5) {
// Show admin login
showAdminPanel()
}
}
Implementation Roadmap
Phase 1: Core Kiosk Mode (3 days)
- Build sleep mode view (clock + weather + alerts)
- Build active mode view (dashboard grid)
- Implement tap-to-wake transition
- Implement auto-sleep after 5 min inactivity
- Test on Samsung Galaxy Tab A9+
Phase 2: Android Configuration (1 day)
- Configure always-on display
- Set up kiosk mode (single app lock)
- Install Fully Kiosk Browser
- Configure auto-start on boot
Phase 3: Hardware Setup (1 day)
- Mount RAM Mounts X-Grip
- Wire Victron 12V→5V converter
- Test vibration dampening
- Test sunlight readability
Phase 4: Advanced Features (2 days)
- Implement motion detection (auto-wake on approach)
- Configure PWA (installable app)
- Set up offline mode (service worker)
- Implement screen burn-in prevention
Total Estimate: 7 days (€5,600 at €80/hr senior dev rate)
Hardware Costs:
- Samsung Galaxy Tab A9+ (11"): €229
- RAM Mounts X-Grip: €89
- Victron 12V→5V converter: €34
- USB-C cable (2m): €15
- Total Hardware: €367
Grand Total (Software + Hardware): €5,967
Testing Checklist
- Clock updates every second (no lag)
- Weather updates every 10 minutes
- Alerts fetch every 1 minute
- Tap-to-wake works on first tap
- Auto-sleep activates after exactly 5 minutes
- Screen brightness adapts to cabin lighting
- Readable from 3m distance in daylight
- Readable from 3m distance in darkness (not too bright)
- Camera thumbnails update every 30 seconds
- Works offline (shows last-known weather)
- Battery stays at 100% when charging
- Tablet doesn't overheat after 24 hours
- RAM mount withstands engine vibration
- Motion detection wakes tablet within 2 seconds
Future Enhancements
Voice Control (Alexa/Google Assistant Integration):
- "Alexa, show bow camera" → Full-screen camera view
- "Alexa, what's the weather tomorrow?" → Speaks forecast
- "Alexa, when is next maintenance?" → Speaks countdown
Face Recognition (Multi-User):
- Owner approaches → Shows owner dashboard
- Captain approaches → Shows captain dashboard
- Crew approaches → Shows crew tasks
Gestures:
- Swipe up → Show full timeline
- Swipe left → Cycle through cameras
- Swipe right → Show weather details
- Pinch zoom → Enlarge camera feed
Ambient Mode (Screensaver):
- Rotate through camera snapshots (slideshow)
- Show tide chart (for tidal areas)
- Show sunrise/sunset times