Screens.kt
| 1 | @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) |
| 2 | |
| 3 | package de.upapp |
| 4 | |
| 5 | import android.text.format.DateFormat |
| 6 | import androidx.annotation.StringRes |
| 7 | import androidx.compose.foundation.background |
| 8 | import androidx.compose.foundation.horizontalScroll |
| 9 | import androidx.compose.foundation.layout.Arrangement |
| 10 | import androidx.compose.foundation.layout.Box |
| 11 | import androidx.compose.foundation.layout.Column |
| 12 | import androidx.compose.foundation.layout.ColumnScope |
| 13 | import androidx.compose.foundation.layout.ExperimentalLayoutApi |
| 14 | import androidx.compose.foundation.layout.FlowRow |
| 15 | import androidx.compose.foundation.layout.Row |
| 16 | import androidx.compose.foundation.layout.Spacer |
| 17 | import androidx.compose.foundation.layout.fillMaxWidth |
| 18 | import androidx.compose.foundation.layout.padding |
| 19 | import androidx.compose.foundation.layout.size |
| 20 | import androidx.compose.foundation.layout.width |
| 21 | import androidx.compose.foundation.rememberScrollState |
| 22 | import androidx.compose.foundation.shape.CircleShape |
| 23 | import androidx.compose.foundation.verticalScroll |
| 24 | import androidx.compose.material.icons.Icons |
| 25 | import androidx.compose.material.icons.filled.Edit |
| 26 | import androidx.compose.material.icons.filled.Lock |
| 27 | import androidx.compose.material.icons.outlined.Info |
| 28 | import androidx.compose.material3.AlertDialog |
| 29 | import androidx.compose.material3.Button |
| 30 | import androidx.compose.material3.Card |
| 31 | import androidx.compose.material3.CardDefaults |
| 32 | import androidx.compose.material3.DatePicker |
| 33 | import androidx.compose.material3.DatePickerDialog |
| 34 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 35 | import androidx.compose.material3.FilledTonalButton |
| 36 | import androidx.compose.material3.FilterChip |
| 37 | import androidx.compose.material3.HorizontalDivider |
| 38 | import androidx.compose.material3.Icon |
| 39 | import androidx.compose.material3.IconButton |
| 40 | import androidx.compose.material3.LinearProgressIndicator |
| 41 | import androidx.compose.material3.MaterialTheme |
| 42 | import androidx.compose.material3.ModalBottomSheet |
| 43 | import androidx.compose.material3.OutlinedButton |
| 44 | import androidx.compose.material3.OutlinedTextField |
| 45 | import androidx.compose.material3.SegmentedButton |
| 46 | import androidx.compose.material3.SegmentedButtonDefaults |
| 47 | import androidx.compose.material3.SingleChoiceSegmentedButtonRow |
| 48 | import androidx.compose.material3.Surface |
| 49 | import androidx.compose.material3.Switch |
| 50 | import androidx.compose.material3.Text |
| 51 | import androidx.compose.material3.TextButton |
| 52 | import androidx.compose.material3.TimePicker |
| 53 | import androidx.compose.material3.DropdownMenuItem |
| 54 | import androidx.compose.material3.ExposedDropdownMenuAnchorType |
| 55 | import androidx.compose.material3.ExposedDropdownMenuBox |
| 56 | import androidx.compose.material3.ExposedDropdownMenuDefaults |
| 57 | import androidx.compose.material3.rememberDatePickerState |
| 58 | import androidx.compose.material3.rememberModalBottomSheetState |
| 59 | import androidx.compose.material3.rememberTimePickerState |
| 60 | import androidx.compose.runtime.Composable |
| 61 | import androidx.compose.runtime.getValue |
| 62 | import androidx.compose.runtime.mutableStateOf |
| 63 | import androidx.compose.runtime.remember |
| 64 | import androidx.compose.runtime.saveable.rememberSaveable |
| 65 | import androidx.compose.runtime.setValue |
| 66 | import androidx.compose.ui.Alignment |
| 67 | import androidx.compose.ui.Modifier |
| 68 | import androidx.compose.ui.graphics.Color |
| 69 | import androidx.compose.ui.platform.LocalConfiguration |
| 70 | import androidx.compose.ui.platform.LocalContext |
| 71 | import androidx.compose.ui.res.stringResource |
| 72 | import androidx.compose.ui.text.font.FontFamily |
| 73 | import androidx.compose.ui.unit.dp |
| 74 | import java.time.DayOfWeek |
| 75 | import java.time.Instant |
| 76 | import java.time.LocalDate |
| 77 | import java.time.LocalTime |
| 78 | import java.time.ZoneId |
| 79 | import java.time.ZoneOffset |
| 80 | import java.time.ZonedDateTime |
| 81 | import java.time.format.DateTimeFormatter |
| 82 | import java.time.format.FormatStyle |
| 83 | import java.time.format.TextStyle |
| 84 | import java.util.Locale |
| 85 | import kotlin.math.roundToInt |
| 86 | |
| 87 | enum class Topic(@StringRes val title: Int, @StringRes val body: Int, @StringRes val note: Int? = null) { |
| 88 | BATTERY(R.string.battery, R.string.info_battery), |
| 89 | STATUS(R.string.charging_status, R.string.info_status), |
| 90 | RANGE(R.string.range, R.string.info_range), |
| 91 | NEXT(R.string.next_departure, R.string.info_next), |
| 92 | CAR(R.string.car, R.string.info_car), |
| 93 | TIMERS(R.string.departure_timers, R.string.info_timers, R.string.note_timers_utc), |
| 94 | PROFILES(R.string.charging_locations, R.string.info_profiles), |
| 95 | OPTIONS(R.string.options, R.string.info_options), |
| 96 | EXT_SUPPLY(R.string.climate_ext_supply, R.string.info_ext_supply), |
| 97 | MAX_CURRENT(R.string.max_current, R.string.info_max_current), |
| 98 | MIN_LEVEL(R.string.min_level, R.string.info_min_level), |
| 99 | TEMPERATURE(R.string.temperature, R.string.info_temperature), |
| 100 | PROVIDERS(R.string.offpeak, R.string.info_providers, R.string.note_offpeak_utc), |
| 101 | PATH(R.string.path, R.string.info_path), |
| 102 | POST(R.string.json_body, R.string.info_post, R.string.note_post), |
| 103 | PIN(R.string.registration, R.string.info_pin, R.string.note_pin), |
| 104 | READ_ONLY(R.string.read_only_title, R.string.info_read_only), |
| 105 | } |
| 106 | |
| 107 | private val TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) |
| 108 | private val DATE = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) |
| 109 | |
| 110 | private fun localOffset() = ZoneId.systemDefault().rules.getOffset(Instant.now()).totalSeconds |
| 111 | |
| 112 | private fun temperatureLabel(t: Double) = "$t °C" |
| 113 | |
| 114 | private fun operationsLabel(ops: List<String>) = |
| 115 | listOfNotNull("🔌".takeIf { "charge" in ops }, "❄️/🔥".takeIf { "climate" in ops }).joinToString(" ") |
| 116 | |
| 117 | // Shared shell for screens |
| 118 | |
| 119 | @Composable |
| 120 | private fun Screen(content: @Composable ColumnScope.() -> Unit) = Column( |
| 121 | Modifier.verticalScroll(rememberScrollState()).padding(16.dp), |
| 122 | verticalArrangement = Arrangement.spacedBy(12.dp), |
| 123 | content = content, |
| 124 | ) |
| 125 | |
| 126 | @Composable |
| 127 | fun InfoButton(topic: Topic) { |
| 128 | var open by remember { mutableStateOf(false) } |
| 129 | IconButton(onClick = { open = true }, modifier = Modifier.size(32.dp)) { |
| 130 | Icon(Icons.Outlined.Info, stringResource(R.string.more_info), Modifier.size(18.dp)) |
| 131 | } |
| 132 | if (open) Sheet(onDismiss = { open = false }) { |
| 133 | Text(stringResource(topic.title), style = MaterialTheme.typography.headlineSmall) |
| 134 | stringResource(topic.body).split("\n\n").forEach { Text(it) } |
| 135 | topic.note?.let { Note(stringResource(it)) } |
| 136 | Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { |
| 137 | TextButton(onClick = { open = false }) { Text(stringResource(R.string.close)) } |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | @Composable |
| 143 | private fun Sheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) = |
| 144 | ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { |
| 145 | Column( |
| 146 | Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 24.dp).padding(bottom = 24.dp), |
| 147 | verticalArrangement = Arrangement.spacedBy(12.dp), |
| 148 | content = content, |
| 149 | ) |
| 150 | } |
| 151 | |
| 152 | @Composable |
| 153 | private fun Note(text: String) = Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = MaterialTheme.shapes.small) { |
| 154 | Text(text, Modifier.padding(horizontal = 12.dp, vertical = 8.dp), style = MaterialTheme.typography.bodySmall) |
| 155 | } |
| 156 | |
| 157 | @Composable |
| 158 | private fun Section(@StringRes title: Int, topic: Topic, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) = |
| 159 | Card(modifier.fillMaxWidth()) { |
| 160 | Column(Modifier.padding(16.dp)) { |
| 161 | Row(verticalAlignment = Alignment.CenterVertically) { |
| 162 | Text(stringResource(title), style = MaterialTheme.typography.titleSmall) |
| 163 | InfoButton(topic) |
| 164 | } |
| 165 | content() |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | @Composable |
| 170 | private fun SectionTitle(@StringRes title: Int, topic: Topic) = Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { |
| 171 | Text(stringResource(title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) |
| 172 | InfoButton(topic) |
| 173 | } |
| 174 | |
| 175 | @Composable |
| 176 | private fun KeyValue(@StringRes key: Int, value: String, topic: Topic? = null) = |
| 177 | Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { |
| 178 | Text(stringResource(key), color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 179 | topic?.let { InfoButton(it) } |
| 180 | Spacer(Modifier.weight(1f)) |
| 181 | Text(value) |
| 182 | } |
| 183 | |
| 184 | @Composable |
| 185 | private fun EditButton(onClick: () -> Unit) = FilledTonalButton(onClick = onClick) { |
| 186 | Icon(Icons.Default.Edit, null, Modifier.size(18.dp)) |
| 187 | Spacer(Modifier.width(8.dp)) |
| 188 | Text(stringResource(R.string.edit)) |
| 189 | } |
| 190 | |
| 191 | @Composable |
| 192 | private fun locale(): Locale = LocalConfiguration.current.locales[0] |
| 193 | |
| 194 | @Composable |
| 195 | private fun dayLetter(d: DayOfWeek) = d.getDisplayName(TextStyle.NARROW, locale()) |
| 196 | |
| 197 | @Composable |
| 198 | private fun Days(days: Set<DayOfWeek>) = Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
| 199 | DayOfWeek.entries.forEach { d -> |
| 200 | val on = d in days |
| 201 | Box( |
| 202 | Modifier.size(28.dp).background(if (on) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape), |
| 203 | contentAlignment = Alignment.Center, |
| 204 | ) { |
| 205 | Text(dayLetter(d), style = MaterialTheme.typography.labelMedium, color = if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant) |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // Overview |
| 211 | |
| 212 | private val PLUG_STATES = mapOf("plugged" to R.string.plugged_in, "unPlugged" to R.string.not_plugged_in) |
| 213 | private val CHARGE_STATES = mapOf( |
| 214 | "idle" to R.string.state_not_charging, "running" to R.string.state_charging, "completed" to R.string.state_completed, |
| 215 | "conservating" to R.string.state_conserving, |
| 216 | ) |
| 217 | private val CLIMATE_STATES = mapOf( |
| 218 | "idle" to R.string.climate_off, "running" to R.string.state_running, "completed" to R.string.state_completed, |
| 219 | "abortedIgnitionOn" to R.string.climate_ignition_on, "abortedNoExternalSupply" to R.string.climate_no_supply, |
| 220 | "abortedBatteryLow" to R.string.climate_battery_low, |
| 221 | ) |
| 222 | |
| 223 | @Composable |
| 224 | private fun stateLabel(value: String, known: Map<String, Int>) = when { |
| 225 | value in known -> stringResource(known.getValue(value)) |
| 226 | value.startsWith("aborted") -> stringResource(R.string.state_aborted) |
| 227 | else -> "–" |
| 228 | } |
| 229 | |
| 230 | @Composable |
| 231 | fun Overview(vm: MainViewModel) = Screen { |
| 232 | vm.battery?.let { b -> |
| 233 | Section(R.string.battery, Topic.BATTERY) { |
| 234 | Text(b.soc?.let { "${it.roundToInt()} %" } ?: "–", style = MaterialTheme.typography.displayMedium) |
| 235 | b.soc?.let { soc -> LinearProgressIndicator(progress = { (soc / 100).toFloat() }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) } |
| 236 | } |
| 237 | } |
| 238 | vm.status?.let { s -> |
| 239 | Section(R.string.charging_status, Topic.STATUS) { |
| 240 | s.plug?.let { KeyValue(R.string.plug, stateLabel(it, PLUG_STATES)) } |
| 241 | s.charge?.let { KeyValue(R.string.charging_state, listOfNotNull(stateLabel(it, CHARGE_STATES), s.chargeMode).joinToString(" · ")) } |
| 242 | s.remainingMinutes?.takeIf { s.charge == "running" }?.let { KeyValue(R.string.remaining_time, "%d:%02d h".format(it / 60, it % 60)) } |
| 243 | s.climate?.let { KeyValue(R.string.climate_state, stateLabel(it, CLIMATE_STATES)) } |
| 244 | s.outsideTemperature?.let { t -> |
| 245 | KeyValue(R.string.outside_temperature, "$t " + if (s.temperatureUnit?.contains("fahrenheit", true) == true) "°F" else "°C") |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| 250 | Section(R.string.range, Topic.RANGE, Modifier.weight(1f)) { |
| 251 | Text(vm.range?.value?.let { "${it.roundToInt()} ${vm.range?.unit}" } ?: "–", style = MaterialTheme.typography.headlineMedium) |
| 252 | } |
| 253 | Section(R.string.next_departure, Topic.NEXT, Modifier.weight(1f)) { |
| 254 | val cm = vm.charging |
| 255 | val next = cm?.let { nextDeparture(it.timers.map { t -> t.shifted(localOffset()) }, ZonedDateTime.now()) } |
| 256 | if (next == null) { |
| 257 | Text(stringResource(R.string.none), style = MaterialTheme.typography.headlineMedium) |
| 258 | } else { |
| 259 | Text(next.second.format(TIME), style = MaterialTheme.typography.headlineMedium) |
| 260 | val day = next.second.dayOfWeek.getDisplayName(TextStyle.SHORT, locale()) |
| 261 | Text("$day · ${cm.profiles.find { it.id == next.first.profileId }?.name ?: "–"}", color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | vm.info?.let { i -> |
| 266 | Section(R.string.car, Topic.CAR) { |
| 267 | KeyValue(R.string.vin, i.vin) |
| 268 | KeyValue(R.string.type, i.type) |
| 269 | KeyValue(R.string.car_clock, "${i.date} ${i.time}") |
| 270 | KeyValue(R.string.language, i.language) |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // Charging manager |
| 276 | |
| 277 | @Composable |
| 278 | fun Charging(vm: MainViewModel) { |
| 279 | val cm = vm.charging ?: return Waiting(null) |
| 280 | val offset = remember { localOffset() } |
| 281 | var timer by remember { mutableStateOf<Timer?>(null) } |
| 282 | var provider by remember { mutableStateOf<Provider?>(null) } |
| 283 | var profile by remember { mutableStateOf<Profile?>(null) } |
| 284 | var options by remember { mutableStateOf<Options?>(null) } |
| 285 | val vin = vm.car?.vin.orEmpty() |
| 286 | val unnamed = stringResource(R.string.offpeak) |
| 287 | fun owner(p: Provider) = cm.profiles.find { it.providerId == p.id }?.name ?: unnamed |
| 288 | val off = stringResource(R.string.off) |
| 289 | fun window(p: Provider) = if (p.active) "${p.start.format(TIME)} – ${p.end.format(TIME)}" else off |
| 290 | fun offpeak(id: String?) = cm.providers.find { it.id == id }?.shifted(offset)?.let(::window) |
| 291 | val currents = remember(vin) { maxCurrents(vin) } |
| 292 | |
| 293 | Screen { |
| 294 | if (!vm.writesEnabled) Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { |
| 295 | Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { |
| 296 | Icon(Icons.Default.Lock, null, Modifier.size(18.dp)) |
| 297 | Text(stringResource(R.string.read_only), Modifier.weight(1f).padding(horizontal = 12.dp), style = MaterialTheme.typography.bodyMedium) |
| 298 | InfoButton(Topic.READ_ONLY) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | if (cm.timers.isEmpty() && cm.profiles.isEmpty()) Note(stringResource(R.string.no_charging_data)) |
| 303 | |
| 304 | SectionTitle(R.string.departure_timers, Topic.TIMERS) |
| 305 | cm.timers.forEach { utc -> |
| 306 | val t = utc.shifted(offset) |
| 307 | Card { |
| 308 | Row(Modifier.padding(16.dp)) { |
| 309 | Column(Modifier.weight(1f)) { |
| 310 | Text(t.departureTime.format(TIME), style = MaterialTheme.typography.displaySmall) |
| 311 | val name = cm.profiles.find { it.id == t.profileId }?.name ?: "–" |
| 312 | Text("${stringResource(R.string.timer_n, t.index)} · $name", color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 313 | if (t.cyclic) Days(t.weekdays) else Text(t.departureDate?.format(DATE) ?: "–") |
| 314 | if (vm.writesEnabled) Box(Modifier.padding(top = 12.dp)) { EditButton { timer = t } } |
| 315 | } |
| 316 | Switch( |
| 317 | checked = t.active, |
| 318 | onCheckedChange = if (vm.writesEnabled) { on -> vm.save { write(utc.copy(state = if (on) TIMER_ON else TIMER_OFF)) } } else null, |
| 319 | enabled = vm.writesEnabled, |
| 320 | ) |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | SectionTitle(R.string.charging_locations, Topic.PROFILES) |
| 326 | cm.profiles.forEach { p -> |
| 327 | Card { |
| 328 | Column(Modifier.padding(16.dp)) { |
| 329 | Row { |
| 330 | Text(p.name, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) |
| 331 | Text(operationsLabel(p.operations)) |
| 332 | } |
| 333 | KeyValue(R.string.charge_to, "${p.targetLevel} %") |
| 334 | KeyValue(R.string.max_current, "${p.maxCurrent} A") |
| 335 | offpeak(p.providerId)?.let { KeyValue(R.string.offpeak_short, it) } |
| 336 | if (vm.writesEnabled) EditButton { profile = p } |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | if (vm.writesEnabled && cm.profiles.size < MAX_LOCATIONS) OutlinedButton(onClick = { profile = newProfile(vin) }) { |
| 341 | Text(stringResource(R.string.add_location)) |
| 342 | } |
| 343 | |
| 344 | SectionTitle(R.string.options, Topic.OPTIONS) |
| 345 | cm.options?.let { o -> Card { |
| 346 | Column(Modifier.padding(16.dp)) { |
| 347 | KeyValue(R.string.climate_ext_supply, stringResource(if (o.climateExtSupply) R.string.yes else R.string.no), Topic.EXT_SUPPLY) |
| 348 | KeyValue(R.string.max_current, "${o.maxCurrent} A", Topic.MAX_CURRENT) |
| 349 | KeyValue(R.string.min_level, "${o.minLevel} %", Topic.MIN_LEVEL) |
| 350 | KeyValue(R.string.temperature, temperatureLabel(o.temperature), Topic.TEMPERATURE) |
| 351 | if (vm.writesEnabled) EditButton { options = o } |
| 352 | } |
| 353 | } } |
| 354 | |
| 355 | SectionTitle(R.string.offpeak, Topic.PROVIDERS) |
| 356 | Card { |
| 357 | Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { |
| 358 | // The car answers 403 to writes on the entry of the options profile. |
| 359 | cm.providers.filter { it.id != cm.options?.providerId }.forEachIndexed { i, utc -> |
| 360 | val p = utc.shifted(offset) |
| 361 | if (i > 0) HorizontalDivider() |
| 362 | Row(Modifier.padding(vertical = 8.dp)) { |
| 363 | Column(Modifier.weight(1f)) { |
| 364 | Text(owner(p), style = MaterialTheme.typography.titleMedium) |
| 365 | Text(window(p)) |
| 366 | if (p.active) Days(p.weekdays) |
| 367 | if (vm.writesEnabled && p.active) Box(Modifier.padding(top = 12.dp)) { EditButton { provider = p } } |
| 368 | } |
| 369 | Switch( |
| 370 | checked = p.active, |
| 371 | onCheckedChange = if (vm.writesEnabled) { on -> |
| 372 | // Switching on opens the editor with the maps+more default window 22:00 to 06:00. |
| 373 | if (on) provider = p.copy(start = LocalTime.of(22, 0), end = LocalTime.of(6, 0), weekdays = p.weekdays.ifEmpty { DayOfWeek.entries.toSet() }) |
| 374 | else vm.save { write(utc.off()) } |
| 375 | } else null, |
| 376 | enabled = vm.writesEnabled, |
| 377 | ) |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | timer?.let { t -> |
| 385 | TimerEditor(t, cm.profiles, offset, onDismiss = { timer = null }) { edited -> |
| 386 | timer = null |
| 387 | vm.save { write(edited.shifted(-offset)) } |
| 388 | } |
| 389 | } |
| 390 | provider?.let { p -> |
| 391 | ProviderEditor(p, owner(p), onDismiss = { provider = null }) { edited -> |
| 392 | provider = null |
| 393 | vm.save { write(edited.shifted(-offset)) } |
| 394 | } |
| 395 | } |
| 396 | profile?.let { p -> |
| 397 | val deletable = p.uri.isNotEmpty() && cm.timers.none { it.profileId == p.id } |
| 398 | ProfileEditor(p, currents, onDismiss = { profile = null }, onDelete = if (deletable) { { profile = null; vm.save { delete(p) } } } else null) { edited -> |
| 399 | profile = null |
| 400 | vm.save { if (edited.uri.isEmpty()) create(edited) else write(edited) } |
| 401 | } |
| 402 | } |
| 403 | options?.let { o -> |
| 404 | OptionsEditor(o, currents, onDismiss = { options = null }) { edited -> |
| 405 | options = null |
| 406 | vm.save { write(edited) } |
| 407 | } |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | // Editors |
| 412 | |
| 413 | @Composable |
| 414 | private fun Label(@StringRes text: Int) = |
| 415 | Text(stringResource(text), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 416 | |
| 417 | @Composable |
| 418 | private fun Actions(onCancel: () -> Unit, modifier: Modifier = Modifier, onSave: () -> Unit) = |
| 419 | Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { |
| 420 | TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) } |
| 421 | Button(onClick = onSave) { Text(stringResource(R.string.save)) } |
| 422 | } |
| 423 | |
| 424 | @Composable |
| 425 | private fun RepeatToggle(cyclic: Boolean, onChange: (Boolean) -> Unit) = SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { |
| 426 | SegmentedButton(selected = cyclic, onClick = { onChange(true) }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text(stringResource(R.string.weekly)) } |
| 427 | SegmentedButton(selected = !cyclic, onClick = { onChange(false) }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text(stringResource(R.string.once)) } |
| 428 | } |
| 429 | |
| 430 | @Composable |
| 431 | private fun DayPicker(days: Set<DayOfWeek>, onChange: (Set<DayOfWeek>) -> Unit) = FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
| 432 | DayOfWeek.entries.forEach { d -> |
| 433 | FilterChip(selected = d in days, onClick = { onChange(if (d in days) days - d else days + d) }, label = { Text(dayLetter(d)) }) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | @Composable |
| 438 | private fun <T> Dropdown(@StringRes label: Int, options: List<T>, selected: T, text: (T) -> String, onSelect: (T) -> Unit) { |
| 439 | var open by remember { mutableStateOf(false) } |
| 440 | ExposedDropdownMenuBox(expanded = open, onExpandedChange = { open = it }) { |
| 441 | OutlinedTextField( |
| 442 | text(selected), {}, readOnly = true, singleLine = true, |
| 443 | label = { Text(stringResource(label)) }, |
| 444 | trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(open) }, |
| 445 | modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(), |
| 446 | ) |
| 447 | ExposedDropdownMenu(expanded = open, onDismissRequest = { open = false }) { |
| 448 | options.forEach { DropdownMenuItem(text = { Text(text(it)) }, onClick = { onSelect(it); open = false }) } |
| 449 | } |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | /** A time as a button. Opens the clock dial, so no keyboard pops up. */ |
| 454 | @Composable |
| 455 | private fun TimeField(@StringRes label: Int, value: LocalTime, onChange: (LocalTime) -> Unit) { |
| 456 | var open by remember { mutableStateOf(false) } |
| 457 | Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { |
| 458 | Text(stringResource(label), Modifier.weight(1f)) |
| 459 | OutlinedButton(onClick = { open = true }) { Text(value.format(TIME), style = MaterialTheme.typography.titleLarge) } |
| 460 | } |
| 461 | if (open) { |
| 462 | val state = rememberTimePickerState(value.hour, value.minute, DateFormat.is24HourFormat(LocalContext.current)) |
| 463 | AlertDialog( |
| 464 | onDismissRequest = { open = false }, |
| 465 | confirmButton = { TextButton(onClick = { onChange(LocalTime.of(state.hour, state.minute)); open = false }) { Text(stringResource(R.string.ok)) } }, |
| 466 | dismissButton = { TextButton(onClick = { open = false }) { Text(stringResource(R.string.cancel)) } }, |
| 467 | text = { TimePicker(state) }, |
| 468 | ) |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | @Composable |
| 473 | private fun SwitchRow(@StringRes text: Int, checked: Boolean, onChange: (Boolean) -> Unit) = |
| 474 | Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { |
| 475 | Text(stringResource(text), Modifier.weight(1f)) |
| 476 | Switch(checked = checked, onCheckedChange = onChange) |
| 477 | } |
| 478 | |
| 479 | @Composable |
| 480 | private fun Error(@StringRes text: Int?) = text?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } |
| 481 | |
| 482 | /** Edits [local], a timer already moved to local time. */ |
| 483 | @Composable |
| 484 | private fun TimerEditor(local: Timer, profiles: List<Profile>, offset: Int, onDismiss: () -> Unit, onSave: (Timer) -> Unit) { |
| 485 | var draft by remember { mutableStateOf(local) } |
| 486 | var pickDate by remember { mutableStateOf(false) } |
| 487 | var error by remember { mutableStateOf<Int?>(null) } |
| 488 | Sheet(onDismiss) { |
| 489 | Text(stringResource(R.string.timer_n, draft.index), style = MaterialTheme.typography.headlineSmall) |
| 490 | TimeField(R.string.departure_time_local, draft.departureTime) { draft = draft.copy(departureTime = it) } |
| 491 | Text( |
| 492 | stringResource(R.string.car_stores_utc, draft.shifted(-offset).departureTime.format(TIME)), |
| 493 | style = MaterialTheme.typography.bodySmall, |
| 494 | ) |
| 495 | Label(R.string.repeat) |
| 496 | RepeatToggle(draft.cyclic) { draft = draft.copy(cyclic = it) } |
| 497 | if (draft.cyclic) { |
| 498 | Label(R.string.days) |
| 499 | DayPicker(draft.weekdays) { draft = draft.copy(weekdays = it) } |
| 500 | } else { |
| 501 | Label(R.string.date) |
| 502 | OutlinedButton(onClick = { pickDate = true }) { |
| 503 | Text(draft.departureDate?.format(DATE) ?: stringResource(R.string.pick_date)) |
| 504 | } |
| 505 | } |
| 506 | Dropdown(R.string.charging_location, profiles, profiles.find { it.id == draft.profileId }, { it?.name ?: "–" }) { draft = draft.copy(profileId = it?.id) } |
| 507 | SwitchRow(R.string.timer_active, draft.active) { draft = draft.copy(state = if (it) TIMER_ON else TIMER_OFF) } |
| 508 | Error(error) |
| 509 | Actions(onDismiss) { |
| 510 | val result = draft |
| 511 | error = when { |
| 512 | result.cyclic && result.weekdays.isEmpty() -> R.string.err_pick_day |
| 513 | !result.cyclic && result.departureDate == null -> R.string.err_pick_date |
| 514 | else -> null |
| 515 | } |
| 516 | if (error == null) onSave(result) |
| 517 | } |
| 518 | } |
| 519 | if (pickDate) { |
| 520 | val state = rememberDatePickerState(draft.departureDate?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()) |
| 521 | DatePickerDialog( |
| 522 | onDismissRequest = { pickDate = false }, |
| 523 | confirmButton = { |
| 524 | TextButton(onClick = { |
| 525 | state.selectedDateMillis?.let { draft = draft.copy(departureDate = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate()) } |
| 526 | pickDate = false |
| 527 | }) { Text(stringResource(R.string.ok)) } |
| 528 | }, |
| 529 | ) { DatePicker(state) } |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | @Composable |
| 534 | private fun ProviderEditor(p: Provider, title: String, onDismiss: () -> Unit, onSave: (Provider) -> Unit) { |
| 535 | var draft by remember { mutableStateOf(p) } |
| 536 | Sheet(onDismiss) { |
| 537 | Text(title, style = MaterialTheme.typography.headlineSmall) |
| 538 | TimeField(R.string.start, draft.start) { draft = draft.copy(start = it) } |
| 539 | TimeField(R.string.end, draft.end) { draft = draft.copy(end = it) } |
| 540 | when { |
| 541 | draft.start == draft.end -> Text(stringResource(R.string.empty_window), style = MaterialTheme.typography.bodySmall) |
| 542 | draft.end < draft.start -> Text(stringResource(R.string.overnight), style = MaterialTheme.typography.bodySmall) |
| 543 | } |
| 544 | Label(R.string.days) |
| 545 | DayPicker(draft.weekdays) { draft = draft.copy(weekdays = it) } |
| 546 | Actions(onDismiss) { onSave(draft) } |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | @Composable |
| 551 | private fun ProfileEditor(p: Profile, currents: List<Int>, onDismiss: () -> Unit, onDelete: (() -> Unit)?, onSave: (Profile) -> Unit) { |
| 552 | var draft by remember { mutableStateOf(p) } |
| 553 | var error by remember { mutableStateOf<Int?>(null) } |
| 554 | var confirmDelete by remember { mutableStateOf(false) } |
| 555 | Sheet(onDismiss) { |
| 556 | Text(if (p.uri.isEmpty()) stringResource(R.string.new_location) else p.name, style = MaterialTheme.typography.headlineSmall) |
| 557 | OutlinedTextField( |
| 558 | draft.name, |
| 559 | { draft = draft.copy(name = it.take(NAME_MAX)) }, |
| 560 | Modifier.fillMaxWidth(), |
| 561 | label = { Text(stringResource(R.string.name)) }, |
| 562 | singleLine = true, |
| 563 | ) |
| 564 | Dropdown(R.string.charge_to, TARGET_LEVELS, draft.targetLevel, { "$it %" }) { draft = draft.copy(targetLevel = it) } |
| 565 | Dropdown(R.string.max_current, currents, draft.maxCurrent, { "$it A" }) { draft = draft.copy(maxCurrent = it) } |
| 566 | SwitchRow(R.string.op_charge, "charge" in draft.operations) { draft = draft.copy(operations = draft.operations.toggle("charge", it)) } |
| 567 | SwitchRow(R.string.op_climate, "climate" in draft.operations) { draft = draft.copy(operations = draft.operations.toggle("climate", it)) } |
| 568 | Error(error) |
| 569 | if (p.uri.isNotEmpty() && onDelete == null) Text(stringResource(R.string.location_in_use), style = MaterialTheme.typography.bodySmall) |
| 570 | Row(verticalAlignment = Alignment.CenterVertically) { |
| 571 | if (onDelete != null) TextButton(onClick = { confirmDelete = true }) { |
| 572 | Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) |
| 573 | } |
| 574 | Actions(onDismiss, Modifier.weight(1f)) { |
| 575 | error = if (draft.name.isBlank()) R.string.err_name else null |
| 576 | if (error == null) onSave(draft.copy(name = draft.name.trim())) |
| 577 | } |
| 578 | } |
| 579 | } |
| 580 | if (confirmDelete && onDelete != null) AlertDialog( |
| 581 | onDismissRequest = { confirmDelete = false }, |
| 582 | title = { Text(stringResource(R.string.delete_location_title, p.name)) }, |
| 583 | text = { Text(stringResource(R.string.delete_location_text)) }, |
| 584 | confirmButton = { TextButton(onClick = onDelete) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) } }, |
| 585 | dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.cancel)) } }, |
| 586 | ) |
| 587 | } |
| 588 | |
| 589 | @Composable |
| 590 | private fun OptionsEditor(o: Options, currents: List<Int>, onDismiss: () -> Unit, onSave: (Options) -> Unit) { |
| 591 | var draft by remember { mutableStateOf(o) } |
| 592 | Sheet(onDismiss) { |
| 593 | Text(stringResource(R.string.options), style = MaterialTheme.typography.headlineSmall) |
| 594 | SwitchRow(R.string.climate_ext_supply, draft.climateExtSupply) { draft = draft.copy(operations = draft.operations.toggle(EXT_SUPPLY, it)) } |
| 595 | Dropdown(R.string.max_current, currents, draft.maxCurrent, { "$it A" }) { draft = draft.copy(maxCurrent = it) } |
| 596 | Dropdown(R.string.min_level, MIN_LEVELS, draft.minLevel, { "$it %" }) { draft = draft.copy(minLevel = it) } |
| 597 | Dropdown(R.string.temperature, TEMPERATURES, draft.temperature, ::temperatureLabel) { draft = draft.copy(temperature = it) } |
| 598 | Actions(onDismiss) { onSave(draft) } |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | private fun List<String>.toggle(value: String, on: Boolean) = if (on) (this - value) + value else this - value |
| 603 | |
| 604 | // Console |
| 605 | |
| 606 | @Composable |
| 607 | fun Console(vm: MainViewModel) = Screen { |
| 608 | var path by rememberSaveable { mutableStateOf("/car/batteries") } |
| 609 | var body by rememberSaveable { mutableStateOf("{}") } |
| 610 | OutlinedTextField( |
| 611 | path, { path = it }, Modifier.fillMaxWidth(), |
| 612 | label = { Text(stringResource(R.string.path)) }, |
| 613 | trailingIcon = { InfoButton(Topic.PATH) }, |
| 614 | singleLine = true, |
| 615 | textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), |
| 616 | ) |
| 617 | Button(onClick = { vm.console("GET", path, null) }, enabled = !vm.busy) { Text("GET") } |
| 618 | OutlinedTextField( |
| 619 | body, { body = it }, Modifier.fillMaxWidth(), |
| 620 | label = { Text(stringResource(R.string.json_body)) }, |
| 621 | trailingIcon = { InfoButton(Topic.POST) }, |
| 622 | enabled = vm.writesEnabled, |
| 623 | minLines = 3, |
| 624 | textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), |
| 625 | ) |
| 626 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| 627 | FilledTonalButton(onClick = { vm.console("POST", path, body) }, enabled = vm.writesEnabled && !vm.busy) { Text("POST") } |
| 628 | if (!vm.writesEnabled) Text(stringResource(R.string.post_disabled), style = MaterialTheme.typography.bodySmall) |
| 629 | } |
| 630 | Surface(color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) { |
| 631 | Text( |
| 632 | vm.consoleOut.ifEmpty { stringResource(R.string.response_hint) }, |
| 633 | Modifier.horizontalScroll(rememberScrollState()).padding(12.dp), |
| 634 | fontFamily = FontFamily.Monospace, |
| 635 | style = MaterialTheme.typography.bodySmall, |
| 636 | ) |
| 637 | } |
| 638 | } |
| 639 |