Screens.kt
⎇
Raw
1@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
2
3package de.upapp
4
5import android.text.format.DateFormat
6import androidx.annotation.StringRes
7import androidx.compose.foundation.background
8import androidx.compose.foundation.horizontalScroll
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.ColumnScope
13import androidx.compose.foundation.layout.ExperimentalLayoutApi
14import androidx.compose.foundation.layout.FlowRow
15import androidx.compose.foundation.layout.Row
16import androidx.compose.foundation.layout.Spacer
17import androidx.compose.foundation.layout.fillMaxWidth
18import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.width
21import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.shape.CircleShape
23import androidx.compose.foundation.verticalScroll
24import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.filled.Edit
26import androidx.compose.material.icons.filled.Lock
27import androidx.compose.material.icons.outlined.Info
28import androidx.compose.material3.AlertDialog
29import androidx.compose.material3.Button
30import androidx.compose.material3.Card
31import androidx.compose.material3.CardDefaults
32import androidx.compose.material3.DatePicker
33import androidx.compose.material3.DatePickerDialog
34import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.FilledTonalButton
36import androidx.compose.material3.FilterChip
37import androidx.compose.material3.HorizontalDivider
38import androidx.compose.material3.Icon
39import androidx.compose.material3.IconButton
40import androidx.compose.material3.LinearProgressIndicator
41import androidx.compose.material3.MaterialTheme
42import androidx.compose.material3.ModalBottomSheet
43import androidx.compose.material3.OutlinedButton
44import androidx.compose.material3.OutlinedTextField
45import androidx.compose.material3.SegmentedButton
46import androidx.compose.material3.SegmentedButtonDefaults
47import androidx.compose.material3.SingleChoiceSegmentedButtonRow
48import androidx.compose.material3.Surface
49import androidx.compose.material3.Switch
50import androidx.compose.material3.Text
51import androidx.compose.material3.TextButton
52import androidx.compose.material3.TimePicker
53import androidx.compose.material3.DropdownMenuItem
54import androidx.compose.material3.ExposedDropdownMenuAnchorType
55import androidx.compose.material3.ExposedDropdownMenuBox
56import androidx.compose.material3.ExposedDropdownMenuDefaults
57import androidx.compose.material3.rememberDatePickerState
58import androidx.compose.material3.rememberModalBottomSheetState
59import androidx.compose.material3.rememberTimePickerState
60import androidx.compose.runtime.Composable
61import androidx.compose.runtime.getValue
62import androidx.compose.runtime.mutableStateOf
63import androidx.compose.runtime.remember
64import androidx.compose.runtime.saveable.rememberSaveable
65import androidx.compose.runtime.setValue
66import androidx.compose.ui.Alignment
67import androidx.compose.ui.Modifier
68import androidx.compose.ui.graphics.Color
69import androidx.compose.ui.platform.LocalConfiguration
70import androidx.compose.ui.platform.LocalContext
71import androidx.compose.ui.res.stringResource
72import androidx.compose.ui.text.font.FontFamily
73import androidx.compose.ui.unit.dp
74import java.time.DayOfWeek
75import java.time.Instant
76import java.time.LocalDate
77import java.time.LocalTime
78import java.time.ZoneId
79import java.time.ZoneOffset
80import java.time.ZonedDateTime
81import java.time.format.DateTimeFormatter
82import java.time.format.FormatStyle
83import java.time.format.TextStyle
84import java.util.Locale
85import kotlin.math.roundToInt
86
87enum 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
107private val TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
108private val DATE = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
109
110private fun localOffset() = ZoneId.systemDefault().rules.getOffset(Instant.now()).totalSeconds
111
112private fun temperatureLabel(t: Double) = "$t °C"
113
114private 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
120private 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
127fun 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
143private 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
153private 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
158private 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
170private 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
176private 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
185private 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
192private fun locale(): Locale = LocalConfiguration.current.locales[0]
193
194@Composable
195private fun dayLetter(d: DayOfWeek) = d.getDisplayName(TextStyle.NARROW, locale())
196
197@Composable
198private 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
212private val PLUG_STATES = mapOf("plugged" to R.string.plugged_in, "unPlugged" to R.string.not_plugged_in)
213private 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)
217private 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
224private 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
231fun 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
278fun 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
414private fun Label(@StringRes text: Int) =
415 Text(stringResource(text), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
416
417@Composable
418private 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
425private 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
431private 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
438private 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
455private 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
473private 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
480private 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
484private 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
534private 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
551private 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
590private 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
602private fun List<String>.toggle(value: String, on: Boolean) = if (on) (this - value) + value else this - value
603
604// Console
605
606@Composable
607fun 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