diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt index a3bf7e75..c894a16a 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -458,6 +458,26 @@ class DriveApi @Inject constructor( */ suspend fun deadlineToCalendar(id: Long): DResult = post("deadlines/$id/calendar", JsonObject(), DriveJson::deadlineAnswer) + /** + * Deadline [id] into [target] (Drive 1.17, feature `deadlineMailCalendar`): a mail calendar, else the family + * calendar → the deadline with `calendar`; the mail calendar not reachable: `mail_unavailable`. + */ + suspend fun deadlineToCalendar(id: Long, target: DDeadlineTarget?): DResult = + post("deadlines/$id/calendar", DriveJson.deadlineCalendarBody(target), DriveJson::deadlineAnswer) + + /** The calendars a deadline can go to (Drive 1.17): the family calendar, and my mail calendars. */ + suspend fun deadlineCalendars(): DResult = get("deadlines/calendars", parse = DriveJson::deadlineCalendars) + + /** My target per area for new deadlines (Drive 1.17; the areas with AI for documents). */ + suspend fun deadlineTargets(): DResult> = get("deadlines/targets", parse = DriveJson::deadlineTargets) + + /** My target of area [id] for new deadlines (null: off) → my targets now. */ + suspend fun setDeadlineTarget(id: String, target: DDeadlineTarget?): DResult> { + val area = DriveJson.areaId(id) ?: return bad() + if (target?.mail == true && target.calendarId.isNullOrEmpty()) return bad() + return put("deadlines/targets", DriveJson.deadlineTargetsBody(area, target), DriveJson::deadlineTargets) + } + /** AI for documents in area [id] on or off (`PATCH areas/ { docAi }`, the write right) → the setting now. */ suspend fun setAreaDocAi(id: String, on: Boolean): DResult { val area = DriveJson.areaId(id) ?: return bad() diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveDeadlineTargets.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveDeadlineTargets.kt new file mode 100644 index 00000000..125e0e40 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveDeadlineTargets.kt @@ -0,0 +1,106 @@ +package com.gatecontrol.android.drive + +// ─── Drive 1.17: deadlines into a calendar of the mail plugin too (feature `deadlineMailCalendar`) ── + +/** + * Where a deadline goes into a calendar: [KIND_FAMILY] (the family calendar) or [KIND_MAIL] (a calendar [calendarId] of + * the mail plugin, e.g. Outlook). No target (off) is `null`. [label] is the server's text of it ("Outlook · Kalender"). + */ +data class DDeadlineTarget(val kind: String, val calendarId: String? = null, val label: String? = null) { + val family: Boolean get() = kind == KIND_FAMILY + val mail: Boolean get() = kind == KIND_MAIL + + /** The same calendar (the label does not count). */ + fun sameAs(other: DDeadlineTarget?): Boolean = other != null && kind == other.kind && (family || calendarId == other.calendarId) + + companion object { + const val KIND_FAMILY = "family" + const val KIND_MAIL = "mail" + val FAMILY = DDeadlineTarget(KIND_FAMILY) + } +} + +/** An account of the mail plugin ([kind] e.g. `microsoft`, `caldav`, `local`). */ +data class DMailAccount(val id: String, val label: String, val kind: String?) + +/** A calendar of the mail plugin the person may write to (`GET deadlines/calendars`); [color] `#rrggbb`. */ +data class DMailCalendar(val id: String, val name: String, val account: DMailAccount, val color: String?) + +/** `GET deadlines/calendars`: whether the family calendar takes entries, and the person's mail calendars. */ +data class DDeadlineCalendars(val family: Boolean, val mail: List) { + companion object { + val NONE = DDeadlineCalendars(family = false, mail = emptyList()) + } +} + +/** `GET deadlines/targets`: my target for the deadlines of area [areaId] ([label] its name); null: off. */ +data class DDeadlineTargetRow(val areaId: String, val label: String, val target: DDeadlineTarget?) + +/** What the settings and the deadlines offer: the choices, grouped like the dialog shows them, and the texts of a target. */ +object DriveDeadlineTargets { + + /** One line of the choice: off, the family calendar or a mail calendar. */ + data class Choice(val target: DDeadlineTarget?, val calendar: DMailCalendar? = null) + + /** A block of the choice: mail calendars under their [account] (null: off and the family calendar, no heading). */ + data class Group(val account: DMailAccount?, val choices: List) + + /** + * The choice of an area's target: "Nicht eintragen" ([off]), the family calendar (when Familie takes entries), then + * the mail calendars by account (only with [mailFeature]), in the server's order. + */ + fun groups(calendars: DDeadlineCalendars, mailFeature: Boolean, off: Boolean = true): List { + val first = buildList { + if (off) add(Choice(null)) + if (calendars.family) add(Choice(DDeadlineTarget.FAMILY)) + } + val mail = if (!mailFeature) emptyList() else calendars.mail.groupBy { it.account.id }.values.map { list -> + Group(list.first().account, list.map { c -> Choice(mailTarget(c), c) }) + } + return (if (first.isEmpty()) emptyList() else listOf(Group(null, first))) + mail + } + + /** The targets a single deadline can go to (no "off"). */ + fun targets(calendars: DDeadlineCalendars, mailFeature: Boolean): List = + groups(calendars, mailFeature, off = false).flatMap { g -> g.choices.mapNotNull { it.target } } + + /** "In Kalender eintragen" asks for the target only when more than one is possible. */ + fun ask(targets: List): Boolean = targets.size > 1 + + /** + * The target picked first for a deadline of area [areaId]: my target of that area when it is still possible, else + * the family calendar, else the first one. + */ + fun preselect(rows: List, areaId: String?, targets: List): DDeadlineTarget? { + val mine = rows.firstOrNull { it.areaId == areaId }?.target + return targets.firstOrNull { it.sameAs(mine) } ?: targets.firstOrNull { it.family } ?: targets.firstOrNull() + } + + /** A mail calendar as a target, labelled "Outlook · Kalender". */ + fun mailTarget(c: DMailCalendar) = DDeadlineTarget(DDeadlineTarget.KIND_MAIL, c.id, mailLabel(c)) + + fun mailLabel(c: DMailCalendar): String = listOf(c.account.label, c.name).filter { it.isNotBlank() }.distinct().joinToString(" · ") + + /** + * The text of a mail target ("Outlook · Kalender"): the calendar as it is now when it is still there, else what + * the server keeps; null for off and the family calendar (the app's own words). + */ + fun mailText(target: DDeadlineTarget?, calendars: DDeadlineCalendars): String? { + if (target?.mail != true) return null + return calendars.mail.firstOrNull { it.id == target.calendarId }?.let(::mailLabel) ?: target.label?.takeIf { it.isNotBlank() } + } + + /** + * "Im Kalender · Outlook": the account a deadline's entry went to; empty for a mail calendar without a label, null for + * the family calendar. + */ + fun entryText(entry: DCalendarEntry?): String? { + val t = entry?.target ?: return null + if (!t.mail) return null + return t.label?.substringBefore(" · ")?.trim()?.takeIf { it.isNotEmpty() } ?: "" + } + + /** My target of [areaId] now set to [target] (the rows of the settings). */ + fun put(rows: List, areaId: String, target: DDeadlineTarget?): List = + rows.map { if (it.areaId == areaId) it.copy(target = target) else it } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt index c22f0b91..41ef4249 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -2,6 +2,7 @@ package com.gatecontrol.android.drive import com.google.gson.JsonArray import com.google.gson.JsonElement +import com.google.gson.JsonNull import com.google.gson.JsonObject import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -731,7 +732,7 @@ internal object DriveJson { label = o.str("label")?.trim()?.take(200)?.ifEmpty { null }, reminder = r?.let { DReminder(it.str("at")?.take(40), it.int("daysBefore")?.coerceIn(0, 90) ?: 0, it.bool("sent") ?: false) }, node = node(o.obj("node")), - calendar = o.obj("calendar")?.let { c -> DCalendarEntry(c.idString("eventId")?.take(200), c.str("at")?.take(40)) }, + calendar = o.obj("calendar")?.let { c -> DCalendarEntry(c.idString("eventId")?.take(200), c.str("at")?.take(40), deadlineTarget(c.obj("target"))) }, nodeId = o.long("nodeId")?.takeIf { it > 0 }, ) } @@ -1275,6 +1276,79 @@ internal object DriveJson { /** `PATCH areas/` → `{ area { deadlinesToCalendar } }`. */ fun areaDeadlinesToCalendar(o: JsonObject): Boolean? = o.obj("area")?.bool("deadlinesToCalendar") + // ─── Drive 1.17: deadlines into a mail calendar (feature `deadlineMailCalendar`) ── + + /** `{ kind: 'family' }` | `{ kind: 'mail', calendarId, label }`; anything else (and `null`) is no target. */ + fun deadlineTarget(o: JsonObject?): DDeadlineTarget? { + if (o == null) return null + val label = o.str("label")?.trim()?.take(200)?.ifEmpty { null } + return when (o.str("kind")) { + DDeadlineTarget.KIND_FAMILY -> DDeadlineTarget(DDeadlineTarget.KIND_FAMILY, label = label) + DDeadlineTarget.KIND_MAIL -> o.idString("calendarId")?.take(200)?.ifEmpty { null }?.let { DDeadlineTarget(DDeadlineTarget.KIND_MAIL, it, label) } + // the entry of a deadline names only kind and label + ?: DDeadlineTarget(DDeadlineTarget.KIND_MAIL, label = label) + else -> null + } + } + + /** A target as the server takes it (`null`: off); a mail target needs its calendar. */ + fun deadlineTargetJson(t: DDeadlineTarget?): JsonElement = when { + t == null -> JsonNull.INSTANCE + t.mail -> JsonObject().apply { + addProperty("kind", DDeadlineTarget.KIND_MAIL) + addProperty("calendarId", t.calendarId) + t.label?.let { addProperty("label", it.take(200)) } + } + else -> JsonObject().apply { addProperty("kind", DDeadlineTarget.KIND_FAMILY) } + } + + /** `GET deadlines/calendars` → `{ family, mail: [{ id, name, account: { id, label, kind }, color }] }`. */ + fun deadlineCalendars(o: JsonObject): DDeadlineCalendars? { + val family = o.bool("family") ?: false + val mail = o.arr("mail")?.mapNotNull { e -> + val c = e as? JsonObject ?: return@mapNotNull null + val id = c.idString("id")?.trim()?.take(200)?.ifEmpty { null } ?: return@mapNotNull null + val a = c.obj("account") + val accountId = a?.idString("id")?.take(200) ?: "" + DMailCalendar( + id = id, + name = c.str("name")?.trim()?.take(200)?.ifEmpty { null } ?: id, + account = DMailAccount(accountId, a?.str("label")?.trim()?.take(200).orEmpty(), a?.str("kind")?.take(40)), + color = c.str("color")?.takeIf { COLOR.matches(it) }, + ) + }?.distinctBy { it.id }?.take(200).orEmpty() + return DDeadlineCalendars(family, mail) + } + + /** `GET|PUT deadlines/targets` → `{ targets: [{ areaId, label, target }] }`. */ + fun deadlineTargets(o: JsonObject): List? = o.arr("targets")?.mapNotNull { e -> + val r = e as? JsonObject ?: return@mapNotNull null + val area = areaId(r.str("areaId")) ?: return@mapNotNull null + DDeadlineTargetRow(area, r.str("label")?.take(200) ?: area, deadlineTarget(r.obj("target"))) + }?.distinctBy { it.areaId } + + /** `PUT deadlines/targets { targets: [{ areaId, target }] }`: my target of area [areaId]. */ + fun deadlineTargetsBody(areaId: String, target: DDeadlineTarget?) = JsonObject().apply { + add( + "targets", + JsonArray().apply { + add( + JsonObject().apply { + addProperty("areaId", areaId) + add("target", deadlineTargetJson(target)) + }, + ) + }, + ) + } + + /** `POST deadlines//calendar { target? }`: without a target (or the family's) as before into the family calendar. */ + fun deadlineCalendarBody(target: DDeadlineTarget?) = JsonObject().apply { + if (target?.mail == true) add("target", deadlineTargetJson(target)) + } + + private val COLOR = Regex("^#[0-9a-fA-F]{6}$") + /** `PUT nodes//labels { labels, source: 'device' }`: at most [LABELS_MAX] labels of at most [LABEL_MAX] characters. */ fun labelsBody(labels: List) = JsonObject().apply { add( diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt index 1d824f4e..2aac7411 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -116,6 +116,8 @@ data class DMe( const val FEATURE_PHOTO_PLACES = "photoPlaces" /** Drive 1.16: people in the photos (faces found on the phones, `people`, `person=`). */ const val FEATURE_PHOTO_PEOPLE = "photoPeople" + /** Drive 1.17: deadlines into a calendar of the mail plugin too (`deadlines/calendars`, `deadlines/targets`). */ + const val FEATURE_DEADLINE_MAIL_CALENDAR = "deadlineMailCalendar" } } @@ -463,8 +465,8 @@ data class DDeadline( val nodeId: Long? = null, ) -/** A deadline's entry in the family calendar (Drive 1.14). */ -data class DCalendarEntry(val eventId: String?, val at: String?) +/** A deadline's entry in the family calendar (Drive 1.14) or, Drive 1.17, in a mail calendar ([target]; null: the family's). */ +data class DCalendarEntry(val eventId: String?, val at: String?, val target: DDeadlineTarget? = null) data class DReminder(val at: String?, val daysBefore: Int, val sent: Boolean) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt index 03648180..a5e3e8fe 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt @@ -371,6 +371,8 @@ internal fun driveText(context: Context, code: String): String = when (code) { "done_scan_doc" -> context.getString(R.string.drive_ki_done_scan_doc) "done_calendar" -> context.getString(R.string.drive_ki_done_calendar) "familie_unavailable" -> context.getString(R.string.drive_ki_familie_unavailable) + "done_calendar_any" -> context.getString(R.string.drive_dl_done_calendar) + "mail_unavailable" -> context.getString(R.string.drive_dl_mail_unavailable) "rate_limited" -> context.getString(R.string.drive_ki_rate_limited) "ai_unavailable", "no_ai" -> context.getString(R.string.drive_ki_ai_unavailable) "no_text" -> context.getString(R.string.drive_insight_no_text) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveDeadlineTargetUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveDeadlineTargetUi.kt new file mode 100644 index 00000000..f0ce93b9 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveDeadlineTargetUi.kt @@ -0,0 +1,127 @@ +package com.gatecontrol.android.ui.drive + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DCalendarEntry +import com.gatecontrol.android.drive.DDeadlineCalendars +import com.gatecontrol.android.drive.DDeadlineTarget +import com.gatecontrol.android.drive.DriveDeadlineTargets +import com.gatecontrol.android.ui.theme.GateControlTheme + +// ─── Drive 1.17: where deadlines go — the family calendar or a calendar of the mail plugin ── + +/** The value of an area's target: "Nicht eintragen", "Familienkalender" or "Outlook · Kalender". */ +@Composable +internal fun deadlineTargetText(target: DDeadlineTarget?, calendars: DDeadlineCalendars): String = when { + target == null -> stringResource(R.string.drive_dl_target_off) + target.mail -> DriveDeadlineTargets.mailText(target, calendars) ?: stringResource(R.string.drive_dl_target_mail) + else -> stringResource(R.string.drive_dl_target_family) +} + +/** "Im Kalender · Outlook" of a deadline's entry; the family calendar as before ("Im Familienkalender"). */ +@Composable +internal fun calendarDoneText(entry: DCalendarEntry): String = when (val t = DriveDeadlineTargets.entryText(entry)) { + null -> stringResource(R.string.drive_ki_calendar_done) + "" -> stringResource(R.string.drive_dl_calendar_done) + else -> stringResource(R.string.drive_dl_calendar_done_in, t) +} + +/** + * The choice of a target (radio rows ≥ 48 dp): "Nicht eintragen" and the family calendar first, then the mail + * calendars under their account. Without [confirm] a tap picks at once (the settings); with it the tap marks and the + * button picks (a single deadline, [selected] the area's target). + */ +@Composable +internal fun DeadlineTargetDialog( + title: String, + groups: List, + selected: DDeadlineTarget?, + onSelect: (DDeadlineTarget?) -> Unit, + onDismiss: () -> Unit, + confirm: String? = null, +) { + val x = GateControlTheme.extraColors + var marked by remember(selected) { mutableStateOf(selected) } + fun isOn(t: DDeadlineTarget?) = if (t == null) marked == null else t.sameAs(marked) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.selectableGroup().verticalScroll(rememberScrollState())) { + groups.forEach { g -> + g.account?.let { a -> + Text( + a.label.ifBlank { stringResource(R.string.drive_dl_target_mail) }, + style = MaterialTheme.typography.labelMedium, + color = x.muted, + modifier = Modifier.padding(top = 12.dp, bottom = 2.dp).semantics { heading() }, + ) + } + g.choices.forEach { c -> + val on = isOn(c.target) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .selectable(selected = on, role = Role.RadioButton, onClick = { + if (confirm == null) onSelect(c.target) else marked = c.target + }), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = on, onClick = null) + calendarColor(c.calendar?.color)?.let { color -> + Box(Modifier.padding(start = 12.dp).size(10.dp).background(color, CircleShape)) + } + Text( + when { + c.calendar != null -> c.calendar.name + c.target == null -> stringResource(R.string.drive_dl_target_off) + else -> stringResource(R.string.drive_dl_target_family) + }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + } + }, + confirmButton = { + if (confirm != null) TextButton(onClick = { onSelect(marked) }, enabled = marked != null) { Text(confirm) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.drive_cancel)) } }, + ) +} + +/** `#rrggbb` of a mail calendar as a colour (none: no dot). */ +private fun calendarColor(hex: String?): Color? = hex?.removePrefix("#")?.toLongOrNull(16)?.let { Color(0xFF000000 or it) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt index 2f947e6c..df9ea684 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt @@ -46,12 +46,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.gatecontrol.android.R import com.gatecontrol.android.drive.DDeadline +import com.gatecontrol.android.drive.DDeadlineCalendars +import com.gatecontrol.android.drive.DDeadlineTarget +import com.gatecontrol.android.drive.DDeadlineTargetRow import com.gatecontrol.android.drive.DInsight import com.gatecontrol.android.drive.DMe import com.gatecontrol.android.drive.DNode import com.gatecontrol.android.drive.DOffice import com.gatecontrol.android.drive.DResult import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveDeadlineTargets import com.gatecontrol.android.drive.DriveJson import com.gatecontrol.android.drive.DriveKi import com.gatecontrol.android.drive.DriveLive @@ -517,6 +521,12 @@ class DriveDeadlinesViewModel @Inject constructor( private val _calendarBusy = MutableStateFlow(null) val calendarBusy: StateFlow = _calendarBusy.asStateFlow() + /** Drive 1.17 (`deadlineMailCalendar`): the calendars a deadline can go to, and my target per area (the preselection). */ + private val _calendars = MutableStateFlow(DDeadlineCalendars.NONE) + val calendars: StateFlow = _calendars.asStateFlow() + private val _targets = MutableStateFlow>(emptyList()) + val targets: StateFlow> = _targets.asStateFlow() + private var seen: Set? = DriveViewPref.deadlinesSeen(context) init { @@ -538,6 +548,12 @@ class DriveDeadlinesViewModel @Inject constructor( } _state.value = r } + viewModelScope.launch { + val me = live.me.value ?: live.current() + if (me?.has(DMe.FEATURE_DEADLINE_MAIL_CALENDAR) != true) return@launch + api.deadlineCalendars().valueOrNull()?.let { _calendars.value = it } + api.deadlineTargets().valueOrNull()?.let { _targets.value = it } + } } fun remind(d: DDeadline, days: Int?) { @@ -547,16 +563,19 @@ class DriveDeadlinesViewModel @Inject constructor( } } - /** "In den Familienkalender" (Drive 1.14, feature `deadlineCalendar`). */ - fun calendar(d: DDeadline) { + /** + * "In den Familienkalender" (Drive 1.14, feature `deadlineCalendar`); Drive 1.17 (`deadlineMailCalendar`): "In + * Kalender eintragen" into [target], a mail calendar or the family calendar. + */ + fun calendar(d: DDeadline, target: DDeadlineTarget? = null) { if (_calendarBusy.value != null) return viewModelScope.launch { _calendarBusy.value = d.id - val r = api.deadlineToCalendar(d.id) + val r = if (target == null) api.deadlineToCalendar(d.id) else api.deadlineToCalendar(d.id, target) _calendarBusy.value = null if (r is DResult.Ok) { took(d, r.value) - _message.value = "done_calendar" + _message.value = if (target?.mail == true) "done_calendar_any" else "done_calendar" } else { _message.value = r.errorCode() } @@ -576,6 +595,11 @@ fun DriveDeadlinesScreen(onBack: () -> Unit, onFile: (Long) -> Unit, viewModel: val busy by viewModel.calendarBusy.collectAsStateWithLifecycle() val me by viewModel.live.me.collectAsStateWithLifecycle() val calendar = me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true + val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val rows by viewModel.targets.collectAsStateWithLifecycle() + // Drive 1.17: the family calendar and / or the mail calendars; more than one: "In Kalender eintragen" asks + val targets = DriveDeadlineTargets.targets(calendars, mailFeature = me?.has(DMe.FEATURE_DEADLINE_MAIL_CALENDAR) == true) + var pick by remember { mutableStateOf(null) } val context = LocalContext.current var shown by remember { mutableStateOf(false) } LifecycleResumeEffect(viewModel) { @@ -611,9 +635,14 @@ fun DriveDeadlinesScreen(onBack: () -> Unit, onFile: (Long) -> Unit, viewModel: fresh = d.id in fresh, calendar = calendar, calendarBusy = busy == d.id, - onCalendar = { viewModel.calendar(d) }, + onCalendar = { + if (DriveDeadlineTargets.ask(targets)) pick = d else viewModel.calendar(d, targets.singleOrNull()) + }, onRemind = viewModel::remind, onFile = d.node?.let { n -> { onFile(n.id) } }, + calendarLabel = stringResource( + if (targets.any { it.mail }) R.string.drive_dl_calendar_add else R.string.drive_ki_calendar_add, + ), ) } } @@ -621,6 +650,16 @@ fun DriveDeadlinesScreen(onBack: () -> Unit, onFile: (Long) -> Unit, viewModel: } } } + pick?.let { d -> + DeadlineTargetDialog( + title = stringResource(R.string.drive_dl_calendar_pick), + groups = DriveDeadlineTargets.groups(calendars, mailFeature = true, off = false), + selected = DriveDeadlineTargets.preselect(rows, d.node?.areaId, targets), + onSelect = { t -> pick = null; viewModel.calendar(d, t) }, + onDismiss = { pick = null }, + confirm = stringResource(R.string.drive_dl_calendar_go), + ) + } } private fun monthTitle(month: String): String = runCatching { diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt index 4da19ab1..283e4b8c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt @@ -184,7 +184,8 @@ internal fun deadlineTone(kind: String): GcTone = when (kind) { /** * A deadline (mockup "Fristen"): its kind, the day and how far off, what it is about, the file; "In den - * Familienkalender" (with `deadlineCalendar`) and "Erinnern" (the days before to choose). + * Familienkalender" (with `deadlineCalendar`; "In Kalender eintragen" with `deadlineMailCalendar`) and "Erinnern" + * (the days before to choose). */ @Composable internal fun DeadlineCard( @@ -195,6 +196,8 @@ internal fun DeadlineCard( onCalendar: () -> Unit, onRemind: (DDeadline, Int?) -> Unit, onFile: (() -> Unit)?, + /** "In den Familienkalender", or with a mail calendar to choose (Drive 1.17) "In Kalender eintragen". */ + calendarLabel: String = stringResource(R.string.drive_ki_calendar_add), ) { val x = GateControlTheme.extraColors val today = remember { LocalDate.now() } @@ -219,9 +222,9 @@ internal fun DeadlineCard( KiFlow { if (calendar && (d.calendar != null || DriveKi.calendarOpen(d, today))) { if (d.calendar != null) { - KiButton(stringResource(R.string.drive_ki_calendar_done), GcIcons.Calendar, done = true, enabled = false) {} + KiButton(calendarDoneText(d.calendar), GcIcons.Calendar, done = true, enabled = false) {} } else { - KiButton(stringResource(R.string.drive_ki_calendar_add), GcIcons.Calendar, busy = calendarBusy, onClick = onCalendar) + KiButton(calendarLabel, GcIcons.Calendar, busy = calendarBusy, onClick = onCalendar) } } RemindButton(d, open, onRemind) @@ -675,7 +678,7 @@ internal fun AnswerCard( ) } if (calendar && deadline.calendar != null) { - KiButton(stringResource(R.string.drive_ki_calendar_done), GcIcons.Calendar, done = true, enabled = false) {} + KiButton(calendarDoneText(deadline.calendar), GcIcons.Calendar, done = true, enabled = false) {} } else if (calendar && DriveKi.calendarOpen(deadline, today)) { KiButton(stringResource(R.string.drive_ki_ask_calendar), GcIcons.Calendar, busy = calendarBusy) { onCalendar(deadline) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt index 4f79d53c..2f94ee45 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup @@ -34,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -42,6 +44,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.gatecontrol.android.R import com.gatecontrol.android.drive.DArea +import com.gatecontrol.android.drive.DDeadlineCalendars +import com.gatecontrol.android.drive.DDeadlineTarget +import com.gatecontrol.android.drive.DDeadlineTargetRow +import com.gatecontrol.android.drive.DriveDeadlineTargets import com.gatecontrol.android.drive.DMe import com.gatecontrol.android.drive.DResult import com.gatecontrol.android.drive.DriveApi @@ -106,6 +112,12 @@ class DriveSettingsViewModel @Inject constructor( private val _areas = MutableStateFlow>(emptyList()) val areas: StateFlow> = _areas.asStateFlow() + /** Drive 1.17 (`deadlineMailCalendar`): my target per area for new deadlines; null while not known (then the switch). */ + private val _targets = MutableStateFlow?>(null) + val targets: StateFlow?> = _targets.asStateFlow() + private val _calendars = MutableStateFlow(DDeadlineCalendars.NONE) + val calendars: StateFlow = _calendars.asStateFlow() + private val _cacheSize = MutableStateFlow(null) val cacheSize: StateFlow = _cacheSize.asStateFlow() @@ -135,6 +147,15 @@ class DriveSettingsViewModel @Inject constructor( fun load() { viewModelScope.launch { api.areas().valueOrNull()?.let { _areas.value = DriveLogic.sortAreas(it) } } viewModelScope.launch { _cacheSize.value = cache.size() } + viewModelScope.launch { + val me = live.me.value ?: live.current() + if (me?.has(DMe.FEATURE_DEADLINE_MAIL_CALENDAR) != true) { + _targets.value = null + return@launch + } + api.deadlineCalendars().valueOrNull()?.let { _calendars.value = it } + api.deadlineTargets().valueOrNull()?.let { _targets.value = it } + } viewModelScope.launch { val safety = withContext(Dispatchers.IO) { runCatching { session.safetyNumber() }.getOrNull() } _vaultInfo.value = VaultInfo(safety, _vaultInfo.value.approved) @@ -205,7 +226,11 @@ class DriveSettingsViewModel @Inject constructor( put(on) viewModelScope.launch { when (val r = api.setAreaDocAi(area.id, on)) { - is DResult.Ok -> put(r.value) + is DResult.Ok -> { + put(r.value) + // the targets follow the areas with AI for documents + if (_targets.value != null) api.deadlineTargets().valueOrNull()?.let { _targets.value = it } + } else -> { put(before) _message.value = r.errorCode() @@ -214,7 +239,22 @@ class DriveSettingsViewModel @Inject constructor( } } - /** "Neue Fristen in den Familienkalender" of [area] (Drive 1.14, `deadlineCalendar`, the write right). */ + /** "Fristen aus " (Drive 1.17, `deadlineMailCalendar`): my target of [areaId], shown at once. */ + fun setDeadlineTarget(areaId: String, target: DDeadlineTarget?) { + val before = _targets.value ?: return + _targets.value = DriveDeadlineTargets.put(before, areaId, target) + viewModelScope.launch { + when (val r = api.setDeadlineTarget(areaId, target)) { + is DResult.Ok -> _targets.value = r.value.ifEmpty { _targets.value } + else -> { + _targets.value = before + _message.value = r.errorCode() + } + } + } + } + + /** "Fristen aus " as a switch into the family calendar of [area] (Drive 1.14, `deadlineCalendar`, the write right). */ fun setDeadlinesToCalendar(area: DArea, on: Boolean) { val before = area.deadlinesToCalendar fun put(value: Boolean?) = _areas.update { list -> list.map { if (it.id == area.id) it.copy(deadlinesToCalendar = value) else it } } @@ -257,6 +297,9 @@ fun DriveSettingsScreen( val sort by viewModel.sort.collectAsStateWithLifecycle() val grid by viewModel.grid.collectAsStateWithLifecycle() val cameraOnly by viewModel.cameraOnly.collectAsStateWithLifecycle() + val targets by viewModel.targets.collectAsStateWithLifecycle() + val calendars by viewModel.calendars.collectAsStateWithLifecycle() + var pickTarget by remember { mutableStateOf(null) } val auth = rememberGcDeviceAuth() var secure by remember { mutableStateOf(viewModel.deviceSecure()) } var askOff by remember { mutableStateOf(false) } @@ -425,12 +468,32 @@ fun DriveSettingsScreen( onCheckedChange = { on -> viewModel.setDocAi(a, on) }, icon = GcIcons.Receipt, ) - // Drive 1.14 (`deadlineCalendar`): new deadlines of the area into the family calendar on their own - if (me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true && a.docAi == true && a.deadlinesToCalendar != null) { + val row = targets?.firstOrNull { it.areaId == a.id } + if (a.docAi == true && row != null) { + // Drive 1.17 (`deadlineMailCalendar`): my target of the area, the family calendar or a mail calendar + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_dl_from, a.label), + description = stringResource(R.string.drive_dl_from_desc), + onClick = { pickTarget = a.id }, + leading = { Icon(GcIcons.Calendar, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { + Text( + deadlineTargetText(row.target, calendars), + style = MaterialTheme.typography.labelMedium, + color = x.muted, + modifier = Modifier.widthIn(max = 160.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } else if (me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true && a.docAi == true && a.deadlinesToCalendar != null) { + // Drive 1.14 (`deadlineCalendar`): new deadlines of the area into the family calendar on their own GcRowDivider() GcSwitchRow( - label = stringResource(R.string.drive_ki_area_calendar), - description = stringResource(R.string.drive_ki_area_calendar_desc, a.label), + label = stringResource(R.string.drive_dl_from, a.label), + description = stringResource(R.string.drive_dl_from_switch_desc), checked = a.deadlinesToCalendar == true, onCheckedChange = { on -> viewModel.setDeadlinesToCalendar(a, on) }, icon = GcIcons.Calendar, @@ -535,6 +598,20 @@ fun DriveSettingsScreen( onDismiss = { pickLock = false }, ) } + pickTarget?.let { id -> + val row = targets?.firstOrNull { it.areaId == id } + if (row == null) { + pickTarget = null + } else { + DeadlineTargetDialog( + title = stringResource(R.string.drive_dl_from, areas.firstOrNull { it.id == id }?.label ?: row.label), + groups = DriveDeadlineTargets.groups(calendars, mailFeature = me?.has(DMe.FEATURE_DEADLINE_MAIL_CALENDAR) == true), + selected = row.target, + onSelect = { t -> pickTarget = null; viewModel.setDeadlineTarget(id, t) }, + onDismiss = { pickTarget = null }, + ) + } + } if (pickSort) { val keys = listOf("name", "mtime", "size", "type") val options = keys.flatMap { k -> listOf(DriveLogic.FolderSort(k, false), DriveLogic.FolderSort(k, true)) } diff --git a/app/src/main/res/values-de/strings_drive11.xml b/app/src/main/res/values-de/strings_drive11.xml new file mode 100644 index 00000000..de55479a --- /dev/null +++ b/app/src/main/res/values-de/strings_drive11.xml @@ -0,0 +1,17 @@ + + + + Fristen aus %1$s + Neu erkannte Fristen kommen von selbst in den Familienkalender + Wohin neu erkannte Fristen aus deinen Uploads kommen + Nicht eintragen + Familienkalender + Mail-Kalender + In Kalender eintragen + In welchen Kalender? + Eintragen + Im Kalender + Im Kalender · %1$s + In den Kalender eingetragen. + Der Mail-Kalender ist gerade nicht erreichbar (Konto getrennt oder Anmeldung abgelaufen). Bitte das Konto in Mail neu anmelden. + diff --git a/app/src/main/res/values-de/strings_drive9.xml b/app/src/main/res/values-de/strings_drive9.xml index 1f80bffb..2c5f807c 100644 --- a/app/src/main/res/values-de/strings_drive9.xml +++ b/app/src/main/res/values-de/strings_drive9.xml @@ -11,8 +11,6 @@ Garantieende Vertragsverlängerung kündigen bis %1$s - Neue Fristen in den Familienkalender - In „%1$s“ erkannte Fristen kommen von selbst in den Familienkalender neu In den Familienkalender Im Familienkalender diff --git a/app/src/main/res/values/strings_drive11.xml b/app/src/main/res/values/strings_drive11.xml new file mode 100644 index 00000000..4a95ec45 --- /dev/null +++ b/app/src/main/res/values/strings_drive11.xml @@ -0,0 +1,17 @@ + + + + Deadlines from %1$s + Newly recognised deadlines go into the family calendar on their own + Where newly recognised deadlines from your uploads go + Don’t add + Family calendar + Mail calendar + Add to calendar + Which calendar? + Add + In the calendar + In the calendar · %1$s + Added to the calendar. + The mail calendar cannot be reached right now (account disconnected or sign-in expired). Please sign in to the account in Mail again. + diff --git a/app/src/main/res/values/strings_drive9.xml b/app/src/main/res/values/strings_drive9.xml index 154a2708..06f23968 100644 --- a/app/src/main/res/values/strings_drive9.xml +++ b/app/src/main/res/values/strings_drive9.xml @@ -11,8 +11,6 @@ End of warranty Contract renewal cancel by %1$s - New deadlines to the family calendar - Deadlines recognised in “%1$s” go into the family calendar on their own new To the family calendar In the family calendar diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveDeadlineTargetsTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveDeadlineTargetsTest.kt new file mode 100644 index 00000000..61f725b5 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveDeadlineTargetsTest.kt @@ -0,0 +1,147 @@ +package com.gatecontrol.android.drive + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Drive 1.17 (`deadlineMailCalendar`): deadlines into the family calendar or a calendar of the mail plugin. */ +class DriveDeadlineTargetsTest { + + private fun json(s: String): JsonObject = JsonParser.parseString(s).asJsonObject + + private val outlook = DMailAccount("acc_1", "Outlook", "microsoft") + private val posteo = DMailAccount("acc_2", "Posteo", "caldav") + private val work = DMailCalendar("cal_a", "Kalender", outlook, "#3366ff") + private val birthdays = DMailCalendar("cal_b", "Geburtstage", outlook, null) + private val home = DMailCalendar("cal_c", "Privat", posteo, null) + + @Test + fun `me reports the mail calendar feature`() { + val me = DriveJson.me(json("""{"me":{"id":7},"licence":{"valid":true},"features":["deadlineCalendar","deadlineMailCalendar"]}"""))!! + assertTrue(me.has(DMe.FEATURE_DEADLINE_MAIL_CALENDAR)) + } + + @Test + fun `calendars and targets are read`() { + val c = DriveJson.deadlineCalendars( + json( + """{"family":true,"mail":[ + {"id":"cal_a","name":"Kalender","account":{"id":"acc_1","label":"Outlook","kind":"microsoft"},"color":"#3366ff"}, + {"id":"cal_a","name":"doppelt","account":{"id":"acc_1","label":"Outlook"}}, + {"id":7,"name":"Privat","account":{"id":2,"label":"Posteo"},"color":"blue"}, + {"name":"ohne Id"}]}""", + ), + )!! + assertTrue(c.family) + assertEquals(listOf("cal_a", "7"), c.mail.map { it.id }) + assertEquals(work, c.mail[0]) + assertNull(c.mail[1].color) + assertEquals("2", c.mail[1].account.id) + assertEquals(DDeadlineCalendars(false, emptyList()), DriveJson.deadlineCalendars(json("{}"))) + + val rows = DriveJson.deadlineTargets( + json( + """{"targets":[ + {"areaId":"mine:7","label":"Meine Dateien","target":{"kind":"mail","calendarId":"cal_a","label":"Outlook · Kalender"}}, + {"areaId":"family:1","label":"Familie","target":{"kind":"family"}}, + {"areaId":"family:2","label":"Andere","target":null}, + {"areaId":"bad id","target":{"kind":"family"}}, + {"areaId":"team:3","label":"Team","target":{"kind":"weird"}}]}""", + ), + )!! + assertEquals(listOf("mine:7", "family:1", "family:2", "team:3"), rows.map { it.areaId }) + assertEquals(DDeadlineTarget("mail", "cal_a", "Outlook · Kalender"), rows[0].target) + assertTrue(rows[1].target!!.family) + assertNull(rows[2].target) + assertNull(rows[3].target) + } + + @Test + fun `bodies of the target and of a single deadline`() { + val t = DriveDeadlineTargets.mailTarget(work) + assertEquals( + """{"targets":[{"areaId":"mine:7","target":{"kind":"mail","calendarId":"cal_a","label":"Outlook · Kalender"}}]}""", + DriveJson.deadlineTargetsBody("mine:7", t).toString(), + ) + assertEquals("""{"targets":[{"areaId":"family:1","target":{"kind":"family"}}]}""", DriveJson.deadlineTargetsBody("family:1", DDeadlineTarget.FAMILY).toString()) + assertEquals("""{"targets":[{"areaId":"family:1","target":null}]}""", DriveJson.deadlineTargetsBody("family:1", null).toString()) + // the family calendar as before without a target + assertEquals("{}", DriveJson.deadlineCalendarBody(null).toString()) + assertEquals("{}", DriveJson.deadlineCalendarBody(DDeadlineTarget.FAMILY).toString()) + assertEquals("""{"target":{"kind":"mail","calendarId":"cal_a","label":"Outlook · Kalender"}}""", DriveJson.deadlineCalendarBody(t).toString()) + } + + @Test + fun `a deadline's entry names its target`() { + val d = DriveJson.deadlineAnswer(json("""{"deadline":{"id":4,"kind":"cancellation","date":"2026-11-30","calendar":{"eventId":"e1","at":"2026-11-01T10:00:00Z","target":{"kind":"mail","label":"Outlook · Kalender"}}}}"""))!! + assertEquals(DDeadlineTarget("mail", null, "Outlook · Kalender"), d.calendar?.target) + assertEquals("Outlook", DriveDeadlineTargets.entryText(d.calendar)) + // the family calendar and older servers: the app's own words + assertNull(DriveDeadlineTargets.entryText(DCalendarEntry("e1", null, DDeadlineTarget.FAMILY))) + assertNull(DriveDeadlineTargets.entryText(DCalendarEntry("e1", null))) + assertEquals("", DriveDeadlineTargets.entryText(DCalendarEntry("e1", null, DDeadlineTarget("mail")))) + } + + @Test + fun `the choice - off, the family calendar, then the mail calendars by account`() { + val c = DDeadlineCalendars(family = true, mail = listOf(work, home, birthdays)) + val g = DriveDeadlineTargets.groups(c, mailFeature = true) + assertEquals(listOf(null, outlook, posteo), g.map { it.account }) + assertEquals(listOf(null, DDeadlineTarget.FAMILY), g[0].choices.map { it.target }) + assertEquals(listOf("cal_a", "cal_b"), g[1].choices.map { it.target?.calendarId }) + assertEquals("Outlook · Geburtstage", g[1].choices[1].target?.label) + assertEquals(listOf("cal_c"), g[2].choices.map { it.calendar?.id }) + + // without Familie: no family calendar; without the feature: no mail calendars + assertEquals(listOf(null), DriveDeadlineTargets.groups(c.copy(family = false), mailFeature = false).flatMap { it.choices }.map { it.target }) + // a single deadline: no "off" + assertEquals(4, DriveDeadlineTargets.targets(c, mailFeature = true).size) + assertTrue(DriveDeadlineTargets.groups(DDeadlineCalendars.NONE, mailFeature = true, off = false).isEmpty()) + } + + @Test + fun `a single deadline asks only with more than one target, the area's target first`() { + val all = DriveDeadlineTargets.targets(DDeadlineCalendars(true, listOf(work, home)), mailFeature = true) + assertTrue(DriveDeadlineTargets.ask(all)) + assertFalse(DriveDeadlineTargets.ask(DriveDeadlineTargets.targets(DDeadlineCalendars(true, emptyList()), mailFeature = true))) + assertFalse(DriveDeadlineTargets.ask(DriveDeadlineTargets.targets(DDeadlineCalendars(false, listOf(work)), mailFeature = true))) + assertFalse(DriveDeadlineTargets.ask(emptyList())) + + val rows = listOf( + DDeadlineTargetRow("mine:7", "Meine Dateien", DDeadlineTarget("mail", "cal_c", "alt")), + DDeadlineTargetRow("family:1", "Familie", null), + DDeadlineTargetRow("team:3", "Team", DDeadlineTarget("mail", "gone")), + ) + assertEquals("cal_c", DriveDeadlineTargets.preselect(rows, "mine:7", all)?.calendarId) + // off, a calendar no longer there or an unknown area: the family calendar, else the first + assertEquals(DDeadlineTarget.FAMILY, DriveDeadlineTargets.preselect(rows, "family:1", all)) + assertEquals(DDeadlineTarget.FAMILY, DriveDeadlineTargets.preselect(rows, "team:3", all)) + assertEquals(DDeadlineTarget.FAMILY, DriveDeadlineTargets.preselect(rows, null, all)) + assertEquals("cal_a", DriveDeadlineTargets.preselect(rows, "family:1", all.filter { it.mail })?.calendarId) + assertNull(DriveDeadlineTargets.preselect(rows, "mine:7", emptyList())) + } + + @Test + fun `the value of a target and setting it`() { + val c = DDeadlineCalendars(true, listOf(work)) + // the calendar as it is now, else the server's label + assertEquals("Outlook · Kalender", DriveDeadlineTargets.mailText(DDeadlineTarget("mail", "cal_a", "alt"), c)) + assertEquals("alt", DriveDeadlineTargets.mailText(DDeadlineTarget("mail", "gone", "alt"), c)) + assertNull(DriveDeadlineTargets.mailText(DDeadlineTarget("mail", "gone"), c)) + assertNull(DriveDeadlineTargets.mailText(DDeadlineTarget.FAMILY, c)) + assertNull(DriveDeadlineTargets.mailText(null, c)) + assertEquals("Kalender", DriveDeadlineTargets.mailLabel(DMailCalendar("x", "Kalender", DMailAccount("a", "", null), null))) + + val rows = listOf(DDeadlineTargetRow("mine:7", "Meine Dateien", null), DDeadlineTargetRow("family:1", "Familie", DDeadlineTarget.FAMILY)) + val now = DriveDeadlineTargets.put(rows, "mine:7", DriveDeadlineTargets.mailTarget(work)) + assertEquals("cal_a", now[0].target?.calendarId) + assertEquals(DDeadlineTarget.FAMILY, now[1].target) + assertNull(DriveDeadlineTargets.put(now, "mine:7", null)[0].target) + assertTrue(DDeadlineTarget.FAMILY.sameAs(DDeadlineTarget("family", label = "Familienkalender"))) + assertFalse(DriveDeadlineTargets.mailTarget(work).sameAs(DriveDeadlineTargets.mailTarget(home))) + } +} diff --git a/gradle.properties b/gradle.properties index 64df52c9..e7cce1f2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true