@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) package de.upapp import android.text.format.DateFormat import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle import java.util.Locale import kotlin.math.roundToInt enum class Topic(@StringRes val title: Int, @StringRes val body: Int, @StringRes val note: Int? = null) { BATTERY(R.string.battery, R.string.info_battery), STATUS(R.string.charging_status, R.string.info_status), RANGE(R.string.range, R.string.info_range), NEXT(R.string.next_departure, R.string.info_next), CAR(R.string.car, R.string.info_car), TIMERS(R.string.departure_timers, R.string.info_timers, R.string.note_timers_utc), PROFILES(R.string.charging_locations, R.string.info_profiles), OPTIONS(R.string.options, R.string.info_options), EXT_SUPPLY(R.string.climate_ext_supply, R.string.info_ext_supply), MAX_CURRENT(R.string.max_current, R.string.info_max_current), MIN_LEVEL(R.string.min_level, R.string.info_min_level), TEMPERATURE(R.string.temperature, R.string.info_temperature), PROVIDERS(R.string.offpeak, R.string.info_providers, R.string.note_offpeak_utc), PATH(R.string.path, R.string.info_path), POST(R.string.json_body, R.string.info_post, R.string.note_post), PIN(R.string.registration, R.string.info_pin, R.string.note_pin), READ_ONLY(R.string.read_only_title, R.string.info_read_only), } private val TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val DATE = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) private fun localOffset() = ZoneId.systemDefault().rules.getOffset(Instant.now()).totalSeconds private fun temperatureLabel(t: Double) = "$t °C" private fun operationsLabel(ops: List) = listOfNotNull("🔌".takeIf { "charge" in ops }, "❄️/🔥".takeIf { "climate" in ops }).joinToString(" ") // Shared shell for screens @Composable private fun Screen(content: @Composable ColumnScope.() -> Unit) = Column( Modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content, ) @Composable fun InfoButton(topic: Topic) { var open by remember { mutableStateOf(false) } IconButton(onClick = { open = true }, modifier = Modifier.size(32.dp)) { Icon(Icons.Outlined.Info, stringResource(R.string.more_info), Modifier.size(18.dp)) } if (open) Sheet(onDismiss = { open = false }) { Text(stringResource(topic.title), style = MaterialTheme.typography.headlineSmall) stringResource(topic.body).split("\n\n").forEach { Text(it) } topic.note?.let { Note(stringResource(it)) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = { open = false }) { Text(stringResource(R.string.close)) } } } } @Composable private fun Sheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) = ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { Column( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 24.dp).padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content, ) } @Composable private fun Note(text: String) = Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = MaterialTheme.shapes.small) { Text(text, Modifier.padding(horizontal = 12.dp, vertical = 8.dp), style = MaterialTheme.typography.bodySmall) } @Composable private fun Section(@StringRes title: Int, topic: Topic, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) = Card(modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(stringResource(title), style = MaterialTheme.typography.titleSmall) InfoButton(topic) } content() } } @Composable private fun SectionTitle(@StringRes title: Int, topic: Topic) = Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) InfoButton(topic) } @Composable private fun KeyValue(@StringRes key: Int, value: String, topic: Topic? = null) = Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(key), color = MaterialTheme.colorScheme.onSurfaceVariant) topic?.let { InfoButton(it) } Spacer(Modifier.weight(1f)) Text(value) } @Composable private fun EditButton(onClick: () -> Unit) = FilledTonalButton(onClick = onClick) { Icon(Icons.Default.Edit, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.edit)) } @Composable private fun locale(): Locale = LocalConfiguration.current.locales[0] @Composable private fun dayLetter(d: DayOfWeek) = d.getDisplayName(TextStyle.NARROW, locale()) @Composable private fun Days(days: Set) = Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { DayOfWeek.entries.forEach { d -> val on = d in days Box( Modifier.size(28.dp).background(if (on) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape), contentAlignment = Alignment.Center, ) { Text(dayLetter(d), style = MaterialTheme.typography.labelMedium, color = if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant) } } } // Overview private val PLUG_STATES = mapOf("plugged" to R.string.plugged_in, "unPlugged" to R.string.not_plugged_in) private val CHARGE_STATES = mapOf( "idle" to R.string.state_not_charging, "running" to R.string.state_charging, "completed" to R.string.state_completed, "conservating" to R.string.state_conserving, ) private val CLIMATE_STATES = mapOf( "idle" to R.string.climate_off, "running" to R.string.state_running, "completed" to R.string.state_completed, "abortedIgnitionOn" to R.string.climate_ignition_on, "abortedNoExternalSupply" to R.string.climate_no_supply, "abortedBatteryLow" to R.string.climate_battery_low, ) @Composable private fun stateLabel(value: String, known: Map) = when { value in known -> stringResource(known.getValue(value)) value.startsWith("aborted") -> stringResource(R.string.state_aborted) else -> "–" } @Composable fun Overview(vm: MainViewModel) = Screen { vm.battery?.let { b -> Section(R.string.battery, Topic.BATTERY) { Text(b.soc?.let { "${it.roundToInt()} %" } ?: "–", style = MaterialTheme.typography.displayMedium) b.soc?.let { soc -> LinearProgressIndicator(progress = { (soc / 100).toFloat() }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) } } } vm.status?.let { s -> Section(R.string.charging_status, Topic.STATUS) { s.plug?.let { KeyValue(R.string.plug, stateLabel(it, PLUG_STATES)) } s.charge?.let { KeyValue(R.string.charging_state, listOfNotNull(stateLabel(it, CHARGE_STATES), s.chargeMode).joinToString(" · ")) } s.remainingMinutes?.takeIf { s.charge == "running" }?.let { KeyValue(R.string.remaining_time, "%d:%02d h".format(it / 60, it % 60)) } s.climate?.let { KeyValue(R.string.climate_state, stateLabel(it, CLIMATE_STATES)) } s.outsideTemperature?.let { t -> KeyValue(R.string.outside_temperature, "$t " + if (s.temperatureUnit?.contains("fahrenheit", true) == true) "°F" else "°C") } } } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Section(R.string.range, Topic.RANGE, Modifier.weight(1f)) { Text(vm.range?.value?.let { "${it.roundToInt()} ${vm.range?.unit}" } ?: "–", style = MaterialTheme.typography.headlineMedium) } Section(R.string.next_departure, Topic.NEXT, Modifier.weight(1f)) { val cm = vm.charging val next = cm?.let { nextDeparture(it.timers.map { t -> t.shifted(localOffset()) }, ZonedDateTime.now()) } if (next == null) { Text(stringResource(R.string.none), style = MaterialTheme.typography.headlineMedium) } else { Text(next.second.format(TIME), style = MaterialTheme.typography.headlineMedium) val day = next.second.dayOfWeek.getDisplayName(TextStyle.SHORT, locale()) Text("$day · ${cm.profiles.find { it.id == next.first.profileId }?.name ?: "–"}", color = MaterialTheme.colorScheme.onSurfaceVariant) } } } vm.info?.let { i -> Section(R.string.car, Topic.CAR) { KeyValue(R.string.vin, i.vin) KeyValue(R.string.type, i.type) KeyValue(R.string.car_clock, "${i.date} ${i.time}") KeyValue(R.string.language, i.language) } } } // Charging manager @Composable fun Charging(vm: MainViewModel) { val cm = vm.charging ?: return Waiting(null) val offset = remember { localOffset() } var timer by remember { mutableStateOf(null) } var provider by remember { mutableStateOf(null) } var profile by remember { mutableStateOf(null) } var options by remember { mutableStateOf(null) } val vin = vm.car?.vin.orEmpty() val unnamed = stringResource(R.string.offpeak) fun owner(p: Provider) = cm.profiles.find { it.providerId == p.id }?.name ?: unnamed val off = stringResource(R.string.off) fun window(p: Provider) = if (p.active) "${p.start.format(TIME)} – ${p.end.format(TIME)}" else off fun offpeak(id: String?) = cm.providers.find { it.id == id }?.shifted(offset)?.let(::window) val currents = remember(vin) { maxCurrents(vin) } Screen { if (!vm.writesEnabled) Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.Lock, null, Modifier.size(18.dp)) Text(stringResource(R.string.read_only), Modifier.weight(1f).padding(horizontal = 12.dp), style = MaterialTheme.typography.bodyMedium) InfoButton(Topic.READ_ONLY) } } if (cm.timers.isEmpty() && cm.profiles.isEmpty()) Note(stringResource(R.string.no_charging_data)) SectionTitle(R.string.departure_timers, Topic.TIMERS) cm.timers.forEach { utc -> val t = utc.shifted(offset) Card { Row(Modifier.padding(16.dp)) { Column(Modifier.weight(1f)) { Text(t.departureTime.format(TIME), style = MaterialTheme.typography.displaySmall) val name = cm.profiles.find { it.id == t.profileId }?.name ?: "–" Text("${stringResource(R.string.timer_n, t.index)} · $name", color = MaterialTheme.colorScheme.onSurfaceVariant) if (t.cyclic) Days(t.weekdays) else Text(t.departureDate?.format(DATE) ?: "–") if (vm.writesEnabled) Box(Modifier.padding(top = 12.dp)) { EditButton { timer = t } } } Switch( checked = t.active, onCheckedChange = if (vm.writesEnabled) { on -> vm.save { write(utc.copy(state = if (on) TIMER_ON else TIMER_OFF)) } } else null, enabled = vm.writesEnabled, ) } } } SectionTitle(R.string.charging_locations, Topic.PROFILES) cm.profiles.forEach { p -> Card { Column(Modifier.padding(16.dp)) { Row { Text(p.name, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) Text(operationsLabel(p.operations)) } KeyValue(R.string.charge_to, "${p.targetLevel} %") KeyValue(R.string.max_current, "${p.maxCurrent} A") offpeak(p.providerId)?.let { KeyValue(R.string.offpeak_short, it) } if (vm.writesEnabled) EditButton { profile = p } } } } if (vm.writesEnabled && cm.profiles.size < MAX_LOCATIONS) OutlinedButton(onClick = { profile = newProfile(vin) }) { Text(stringResource(R.string.add_location)) } SectionTitle(R.string.options, Topic.OPTIONS) cm.options?.let { o -> Card { Column(Modifier.padding(16.dp)) { KeyValue(R.string.climate_ext_supply, stringResource(if (o.climateExtSupply) R.string.yes else R.string.no), Topic.EXT_SUPPLY) KeyValue(R.string.max_current, "${o.maxCurrent} A", Topic.MAX_CURRENT) KeyValue(R.string.min_level, "${o.minLevel} %", Topic.MIN_LEVEL) KeyValue(R.string.temperature, temperatureLabel(o.temperature), Topic.TEMPERATURE) if (vm.writesEnabled) EditButton { options = o } } } } SectionTitle(R.string.offpeak, Topic.PROVIDERS) Card { Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { // The car answers 403 to writes on the entry of the options profile. cm.providers.filter { it.id != cm.options?.providerId }.forEachIndexed { i, utc -> val p = utc.shifted(offset) if (i > 0) HorizontalDivider() Row(Modifier.padding(vertical = 8.dp)) { Column(Modifier.weight(1f)) { Text(owner(p), style = MaterialTheme.typography.titleMedium) Text(window(p)) if (p.active) Days(p.weekdays) if (vm.writesEnabled && p.active) Box(Modifier.padding(top = 12.dp)) { EditButton { provider = p } } } Switch( checked = p.active, onCheckedChange = if (vm.writesEnabled) { on -> // Switching on opens the editor with the maps+more default window 22:00 to 06:00. if (on) provider = p.copy(start = LocalTime.of(22, 0), end = LocalTime.of(6, 0), weekdays = p.weekdays.ifEmpty { DayOfWeek.entries.toSet() }) else vm.save { write(utc.off()) } } else null, enabled = vm.writesEnabled, ) } } } } } timer?.let { t -> TimerEditor(t, cm.profiles, offset, onDismiss = { timer = null }) { edited -> timer = null vm.save { write(edited.shifted(-offset)) } } } provider?.let { p -> ProviderEditor(p, owner(p), onDismiss = { provider = null }) { edited -> provider = null vm.save { write(edited.shifted(-offset)) } } } profile?.let { p -> val deletable = p.uri.isNotEmpty() && cm.timers.none { it.profileId == p.id } ProfileEditor(p, currents, onDismiss = { profile = null }, onDelete = if (deletable) { { profile = null; vm.save { delete(p) } } } else null) { edited -> profile = null vm.save { if (edited.uri.isEmpty()) create(edited) else write(edited) } } } options?.let { o -> OptionsEditor(o, currents, onDismiss = { options = null }) { edited -> options = null vm.save { write(edited) } } } } // Editors @Composable private fun Label(@StringRes text: Int) = Text(stringResource(text), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) @Composable private fun Actions(onCancel: () -> Unit, modifier: Modifier = Modifier, onSave: () -> Unit) = Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) } Button(onClick = onSave) { Text(stringResource(R.string.save)) } } @Composable private fun RepeatToggle(cyclic: Boolean, onChange: (Boolean) -> Unit) = SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { SegmentedButton(selected = cyclic, onClick = { onChange(true) }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text(stringResource(R.string.weekly)) } SegmentedButton(selected = !cyclic, onClick = { onChange(false) }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text(stringResource(R.string.once)) } } @Composable private fun DayPicker(days: Set, onChange: (Set) -> Unit) = FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { DayOfWeek.entries.forEach { d -> FilterChip(selected = d in days, onClick = { onChange(if (d in days) days - d else days + d) }, label = { Text(dayLetter(d)) }) } } @Composable private fun Dropdown(@StringRes label: Int, options: List, selected: T, text: (T) -> String, onSelect: (T) -> Unit) { var open by remember { mutableStateOf(false) } ExposedDropdownMenuBox(expanded = open, onExpandedChange = { open = it }) { OutlinedTextField( text(selected), {}, readOnly = true, singleLine = true, label = { Text(stringResource(label)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(open) }, modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) ExposedDropdownMenu(expanded = open, onDismissRequest = { open = false }) { options.forEach { DropdownMenuItem(text = { Text(text(it)) }, onClick = { onSelect(it); open = false }) } } } } /** A time as a button. Opens the clock dial, so no keyboard pops up. */ @Composable private fun TimeField(@StringRes label: Int, value: LocalTime, onChange: (LocalTime) -> Unit) { var open by remember { mutableStateOf(false) } Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(label), Modifier.weight(1f)) OutlinedButton(onClick = { open = true }) { Text(value.format(TIME), style = MaterialTheme.typography.titleLarge) } } if (open) { val state = rememberTimePickerState(value.hour, value.minute, DateFormat.is24HourFormat(LocalContext.current)) AlertDialog( onDismissRequest = { open = false }, confirmButton = { TextButton(onClick = { onChange(LocalTime.of(state.hour, state.minute)); open = false }) { Text(stringResource(R.string.ok)) } }, dismissButton = { TextButton(onClick = { open = false }) { Text(stringResource(R.string.cancel)) } }, text = { TimePicker(state) }, ) } } @Composable private fun SwitchRow(@StringRes text: Int, checked: Boolean, onChange: (Boolean) -> Unit) = Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(text), Modifier.weight(1f)) Switch(checked = checked, onCheckedChange = onChange) } @Composable private fun Error(@StringRes text: Int?) = text?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } /** Edits [local], a timer already moved to local time. */ @Composable private fun TimerEditor(local: Timer, profiles: List, offset: Int, onDismiss: () -> Unit, onSave: (Timer) -> Unit) { var draft by remember { mutableStateOf(local) } var pickDate by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } Sheet(onDismiss) { Text(stringResource(R.string.timer_n, draft.index), style = MaterialTheme.typography.headlineSmall) TimeField(R.string.departure_time_local, draft.departureTime) { draft = draft.copy(departureTime = it) } Text( stringResource(R.string.car_stores_utc, draft.shifted(-offset).departureTime.format(TIME)), style = MaterialTheme.typography.bodySmall, ) Label(R.string.repeat) RepeatToggle(draft.cyclic) { draft = draft.copy(cyclic = it) } if (draft.cyclic) { Label(R.string.days) DayPicker(draft.weekdays) { draft = draft.copy(weekdays = it) } } else { Label(R.string.date) OutlinedButton(onClick = { pickDate = true }) { Text(draft.departureDate?.format(DATE) ?: stringResource(R.string.pick_date)) } } Dropdown(R.string.charging_location, profiles, profiles.find { it.id == draft.profileId }, { it?.name ?: "–" }) { draft = draft.copy(profileId = it?.id) } SwitchRow(R.string.timer_active, draft.active) { draft = draft.copy(state = if (it) TIMER_ON else TIMER_OFF) } Error(error) Actions(onDismiss) { val result = draft error = when { result.cyclic && result.weekdays.isEmpty() -> R.string.err_pick_day !result.cyclic && result.departureDate == null -> R.string.err_pick_date else -> null } if (error == null) onSave(result) } } if (pickDate) { val state = rememberDatePickerState(draft.departureDate?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()) DatePickerDialog( onDismissRequest = { pickDate = false }, confirmButton = { TextButton(onClick = { state.selectedDateMillis?.let { draft = draft.copy(departureDate = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate()) } pickDate = false }) { Text(stringResource(R.string.ok)) } }, ) { DatePicker(state) } } } @Composable private fun ProviderEditor(p: Provider, title: String, onDismiss: () -> Unit, onSave: (Provider) -> Unit) { var draft by remember { mutableStateOf(p) } Sheet(onDismiss) { Text(title, style = MaterialTheme.typography.headlineSmall) TimeField(R.string.start, draft.start) { draft = draft.copy(start = it) } TimeField(R.string.end, draft.end) { draft = draft.copy(end = it) } when { draft.start == draft.end -> Text(stringResource(R.string.empty_window), style = MaterialTheme.typography.bodySmall) draft.end < draft.start -> Text(stringResource(R.string.overnight), style = MaterialTheme.typography.bodySmall) } Label(R.string.days) DayPicker(draft.weekdays) { draft = draft.copy(weekdays = it) } Actions(onDismiss) { onSave(draft) } } } @Composable private fun ProfileEditor(p: Profile, currents: List, onDismiss: () -> Unit, onDelete: (() -> Unit)?, onSave: (Profile) -> Unit) { var draft by remember { mutableStateOf(p) } var error by remember { mutableStateOf(null) } var confirmDelete by remember { mutableStateOf(false) } Sheet(onDismiss) { Text(if (p.uri.isEmpty()) stringResource(R.string.new_location) else p.name, style = MaterialTheme.typography.headlineSmall) OutlinedTextField( draft.name, { draft = draft.copy(name = it.take(NAME_MAX)) }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.name)) }, singleLine = true, ) Dropdown(R.string.charge_to, TARGET_LEVELS, draft.targetLevel, { "$it %" }) { draft = draft.copy(targetLevel = it) } Dropdown(R.string.max_current, currents, draft.maxCurrent, { "$it A" }) { draft = draft.copy(maxCurrent = it) } SwitchRow(R.string.op_charge, "charge" in draft.operations) { draft = draft.copy(operations = draft.operations.toggle("charge", it)) } SwitchRow(R.string.op_climate, "climate" in draft.operations) { draft = draft.copy(operations = draft.operations.toggle("climate", it)) } Error(error) if (p.uri.isNotEmpty() && onDelete == null) Text(stringResource(R.string.location_in_use), style = MaterialTheme.typography.bodySmall) Row(verticalAlignment = Alignment.CenterVertically) { if (onDelete != null) TextButton(onClick = { confirmDelete = true }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) } Actions(onDismiss, Modifier.weight(1f)) { error = if (draft.name.isBlank()) R.string.err_name else null if (error == null) onSave(draft.copy(name = draft.name.trim())) } } } if (confirmDelete && onDelete != null) AlertDialog( onDismissRequest = { confirmDelete = false }, title = { Text(stringResource(R.string.delete_location_title, p.name)) }, text = { Text(stringResource(R.string.delete_location_text)) }, confirmButton = { TextButton(onClick = onDelete) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.cancel)) } }, ) } @Composable private fun OptionsEditor(o: Options, currents: List, onDismiss: () -> Unit, onSave: (Options) -> Unit) { var draft by remember { mutableStateOf(o) } Sheet(onDismiss) { Text(stringResource(R.string.options), style = MaterialTheme.typography.headlineSmall) SwitchRow(R.string.climate_ext_supply, draft.climateExtSupply) { draft = draft.copy(operations = draft.operations.toggle(EXT_SUPPLY, it)) } Dropdown(R.string.max_current, currents, draft.maxCurrent, { "$it A" }) { draft = draft.copy(maxCurrent = it) } Dropdown(R.string.min_level, MIN_LEVELS, draft.minLevel, { "$it %" }) { draft = draft.copy(minLevel = it) } Dropdown(R.string.temperature, TEMPERATURES, draft.temperature, ::temperatureLabel) { draft = draft.copy(temperature = it) } Actions(onDismiss) { onSave(draft) } } } private fun List.toggle(value: String, on: Boolean) = if (on) (this - value) + value else this - value // Console @Composable fun Console(vm: MainViewModel) = Screen { var path by rememberSaveable { mutableStateOf("/car/batteries") } var body by rememberSaveable { mutableStateOf("{}") } OutlinedTextField( path, { path = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.path)) }, trailingIcon = { InfoButton(Topic.PATH) }, singleLine = true, textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), ) Button(onClick = { vm.console("GET", path, null) }, enabled = !vm.busy) { Text("GET") } OutlinedTextField( body, { body = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.json_body)) }, trailingIcon = { InfoButton(Topic.POST) }, enabled = vm.writesEnabled, minLines = 3, textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), ) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { FilledTonalButton(onClick = { vm.console("POST", path, body) }, enabled = vm.writesEnabled && !vm.busy) { Text("POST") } if (!vm.writesEnabled) Text(stringResource(R.string.post_disabled), style = MaterialTheme.typography.bodySmall) } Surface(color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) { Text( vm.consoleOut.ifEmpty { stringResource(R.string.response_hint) }, Modifier.horizontalScroll(rememberScrollState()).padding(12.dp), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, ) } }