HomeScreen.kt
⎇
Raw
1package net.lexcom.opentracker.ui
2
3import android.Manifest
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.material3.Button
11import androidx.compose.material3.Card
12import androidx.compose.material3.CardDefaults
13import androidx.compose.material3.MaterialTheme
14import androidx.compose.material3.Text
15import androidx.compose.material3.TextButton
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.getValue
18import androidx.compose.runtime.produceState
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.unit.dp
22import kotlinx.coroutines.delay
23import net.lexcom.opentracker.TrackerState
24import net.lexcom.opentracker.wire.Point
25
26/**
27 * The main screen: permission banner, live map, status, start/stop.
28 *
29 * Stateless by design. Everything it draws arrives as [TrackerState.Snapshot]
30 * and everything it triggers leaves through a lambda, so the screen has no
31 * opinion on whether the service is bound, started or dead. That keeps the one
32 * piece worth testing — the formatting below — reachable from a JVM test.
33 *
34 * Strings are Kotlin constants because this app has no res/values/strings.xml
35 * and is not localized. See the lint block in build.gradle.kts.
36 */
37
38private const val TITLE_MISSING_PERMS = "Not everything is allowed yet"
39private const val LABEL_GRANT = "Grant"
40private const val LABEL_START = "Start sharing"
41private const val LABEL_STOP = "Stop sharing"
42private const val LABEL_SIGN_OUT = "Sign out"
43
44/** How often the "last fix" age is recomputed. */
45private const val AGE_TICK_MS = 10_000L
46
47@Composable
48fun HomeScreen(
49 state: TrackerState.Snapshot,
50 missingPermissions: List<String>,
51 serverHost: String,
52 onGrantPermissions: () -> Unit,
53 onStart: () -> Unit,
54 onStop: () -> Unit,
55 onSignOut: () -> Unit,
56) {
57 Column(Modifier.fillMaxSize()) {
58 if (missingPermissions.isNotEmpty()) {
59 PermissionBanner(missingPermissions, onGrantPermissions)
60 }
61 // The map takes whatever height the rest leaves.
62 MapView(state.last, Modifier.fillMaxWidth().weight(1f))
63 StatusCard(state, serverHost)
64 Button(
65 onClick = if (state.running) onStop else onStart,
66 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
67 ) {
68 Text(if (state.running) LABEL_STOP else LABEL_START)
69 }
70 TextButton(
71 onClick = onSignOut,
72 modifier = Modifier.align(Alignment.CenterHorizontally),
73 ) {
74 Text(LABEL_SIGN_OUT)
75 }
76 }
77}
78
79@Composable
80private fun PermissionBanner(missing: List<String>, onGrant: () -> Unit) {
81 Card(
82 modifier = Modifier.fillMaxWidth().padding(16.dp),
83 colors = CardDefaults.cardColors(
84 containerColor = MaterialTheme.colorScheme.errorContainer,
85 contentColor = MaterialTheme.colorScheme.onErrorContainer,
86 ),
87 ) {
88 Row(
89 modifier = Modifier.fillMaxWidth().padding(16.dp),
90 verticalAlignment = Alignment.CenterVertically,
91 horizontalArrangement = Arrangement.SpaceBetween,
92 ) {
93 Column(Modifier.weight(1f)) {
94 Text(TITLE_MISSING_PERMS, style = MaterialTheme.typography.titleSmall)
95 Text(
96 "Still needed: ${describeMissing(missing)}.",
97 style = MaterialTheme.typography.bodyMedium,
98 )
99 }
100 TextButton(onClick = onGrant) { Text(LABEL_GRANT) }
101 }
102 }
103}
104
105@Composable
106private fun StatusCard(state: TrackerState.Snapshot, serverHost: String) {
107 // Recomposition is driven by the service reporting, which stops when nothing
108 // moves. Without this tick the age would freeze at whatever it read last and
109 // claim a two hour old fix is seconds old.
110 val nowMs by produceState(System.currentTimeMillis()) {
111 while (true) {
112 delay(AGE_TICK_MS)
113 value = System.currentTimeMillis()
114 }
115 }
116 Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
117 Column(Modifier.padding(16.dp)) {
118 Text(state.status, style = MaterialTheme.typography.titleMedium)
119 Text(
120 "Motion ${state.motion.name.lowercase()} · ${state.queued} queued",
121 style = MaterialTheme.typography.bodyMedium,
122 )
123 Text(serverHost, style = MaterialTheme.typography.bodySmall)
124 state.last?.let {
125 Text(describeFix(it, nowMs), style = MaterialTheme.typography.bodySmall)
126 }
127 }
128 }
129}
130
131/**
132 * The age of a fix, as a person would say it.
133 *
134 * [tsSeconds] is [Point.ts], a Unix timestamp in *seconds* (an unsigned u32 on
135 * the wire, widened to a Long here). [nowMs] is milliseconds, because that is
136 * what the platform clock gives.
137 *
138 * A future timestamp is clamped to zero rather than rendered. The device clock
139 * and the fix clock are not the same clock, so a few seconds of skew is normal,
140 * and "-3s ago" would read as a bug in the app rather than in the clock.
141 */
142internal fun formatAge(tsSeconds: Long, nowMs: Long): String {
143 val seconds = (nowMs / 1000 - tsSeconds).coerceAtLeast(0)
144 return when {
145 seconds < 60 -> "${seconds}s ago"
146 seconds < 3600 -> "${seconds / 60}m ago"
147 else -> "${seconds / 3600}h ago"
148 }
149}
150
151/** [accDm] is decimetres, the wire unit. Rounded to whole metres for display. */
152internal fun formatAccuracy(accDm: Int?): String? =
153 accDm?.let { "±${(it + 5) / 10} m" }
154
155internal fun describeFix(point: Point, nowMs: Long): String =
156 listOfNotNull("Last fix ${formatAge(point.ts, nowMs)}", formatAccuracy(point.accDm))
157 .joinToString(" · ")
158
159/**
160 * Plain words for the permissions still missing.
161 *
162 * The raw `android.permission.*` strings mean nothing to a user, and the two
163 * location permissions are requested as a pair, so naming both would read as a
164 * duplicate.
165 */
166internal fun describeMissing(missing: List<String>): String =
167 missing.map(::permissionLabel).distinct().joinToString(", ")
168
169private fun permissionLabel(permission: String): String = when (permission) {
170 Manifest.permission.ACCESS_FINE_LOCATION,
171 Manifest.permission.ACCESS_COARSE_LOCATION,
172 -> "your location"
173 Manifest.permission.ACCESS_BACKGROUND_LOCATION -> "location while the app is closed"
174 Manifest.permission.POST_NOTIFICATIONS -> "notifications"
175 else -> permission.substringAfterLast('.').lowercase().replace('_', ' ')
176}
177