From 6d7b6483c0d54dc88d4bfd0105dafa1c04d7ab3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 09:47:23 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(mail):=20Seitenleiste=20mit=20neuem=20?= =?UTF-8?q?Fu=C3=9F=20(Einstellungen=20links,=20Kontakte/Kalender=20rechts?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fuß der Mail-Seitenleiste wie Mockup „Seitenmenü neu“: links das Zahnrad (Mail-Einstellungen), rechtsbündig Kontakte und Kalender (je 44×44, aktiver Bereich im Akzent). Beide erscheinen nur mit features.contacts bzw. features.calendar und sobald die App den Bereich öffnen kann (folgt mit Kalender und Kontakten). - Das „+“ entfällt: ein Konto kommt nur noch über die Mail-Einstellungen dazu. „Konto hinzufügen“ im Leer-Zustand öffnet die Mail-Einstellungen mit „Konto hinzufügen“ darüber (Zurück führt in die Einstellungen). - Texte DE/EN, Unit-Test für die Feature-Abfrage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../gatecontrol/android/mail/MailModels.kt | 3 + .../android/navigation/MailNavigation.kt | 6 +- .../android/ui/mail/MailHomeScreen.kt | 70 +++++++++++++++---- app/src/main/res/values-de/strings_mail.xml | 2 + app/src/main/res/values/strings_mail.xml | 2 + .../android/ui/mail/MailDrawerFootTest.kt | 32 +++++++++ 6 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/com/gatecontrol/android/ui/mail/MailDrawerFootTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt index 29980096..32c14285 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -44,6 +44,9 @@ data class MMe( const val FEATURE_DRIVE = "drive" const val FEATURE_FAMILY_CALENDAR = "familyCalendar" const val FEATURE_IMAGES = "images" + /** Since Mail 1.2.0: calendars and contacts (the administrator switched them on). */ + const val FEATURE_CALENDAR = "calendar" + const val FEATURE_CONTACTS = "contacts" } } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt index 9ef61f6b..e5a28046 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -30,7 +30,11 @@ private class MailNavigation(private val navController: NavHostController) { draft = { go(Screen.MailCompose.create(draft = it)) }, search = { go(Screen.MailSearch.route) }, filing = { go(Screen.MailFiling.route) }, - addAccount = { go(Screen.MailAddAccount.create()) }, + addAccount = { + // an account is added in the settings: back from adding leads there + go(Screen.MailSettings.route) + go(Screen.MailAddAccount.create()) + }, settings = { go(Screen.MailSettings.route) }, account = { go(Screen.MailAccount.create(it)) }, outbox = { go(Screen.MailOutbox.route) }, diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt index 0740eb47..5b87d81d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -90,6 +90,7 @@ data class MailNav( val draft: (Long) -> Unit, val search: () -> Unit, val filing: () -> Unit, + /** "Konto hinzufügen" of the empty tab: the settings with "Konto hinzufügen" on top (the menu has no "+"). */ val addAccount: () -> Unit, val settings: () -> Unit, val account: (Long) -> Unit, @@ -172,7 +173,6 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) onBox = { close(); viewModel.selectBox(it) }, onFiling = { close(); nav.filing() }, onOutbox = { close(); nav.outbox() }, - onAdd = { close(); nav.addAccount() }, onSettings = { close(); nav.settings() }, ) } @@ -576,10 +576,11 @@ internal object MailFolders { } /** - * The menu (mockup "Seitenmenü"): "Mail", "Alle Posteingänge", "Zum Ablegen" (with Drive), "Postausgang" (when - * something waits), the accounts with their folders and below "Konto hinzufügen" and the settings. A tap on an - * account opens or closes its folders (the menu stays, [folds] keeps it for the session); picking its "Posteingang" - * or a folder closes the menu. Calendar and resources come later (phase 2/3). + * The menu (mockups "Seitenmenü" and "Seitenmenü neu"): "Mail", "Alle Posteingänge", "Zum Ablegen" (with Drive), + * "Postausgang" (when something waits), the accounts with their folders, the storage bar and the foot: the settings + * on the left, contacts and calendar on the right ([MailDrawerFoot]). A tap on an account opens or closes its folders + * (the menu stays, [folds] keeps it for the session); picking its "Posteingang" or a folder closes the menu. An + * account is added in the settings only (no "+" here since phase 2). */ @Composable private fun MailDrawer( @@ -593,8 +594,10 @@ private fun MailDrawer( onBox: (MailLogic.Box) -> Unit, onFiling: () -> Unit, onOutbox: () -> Unit, - onAdd: () -> Unit, onSettings: () -> Unit, + onContacts: (() -> Unit)? = null, + onCalendar: (() -> Unit)? = null, + area: String = MailDrawerFoot.MAIL, ) { val extra = GateControlTheme.extraColors Column(Modifier.fillMaxHeight().padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 16.dp)) { @@ -653,13 +656,50 @@ private fun MailDrawer( } } } - Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DrawerIcon(GcIcons.Plus, stringResource(R.string.mail_add_account), onAdd) - DrawerIcon(GcIcons.Settings, stringResource(R.string.mail_settings), onSettings) + MailDrawerFoot(me = me, area = area, onSettings = onSettings, onContacts = onContacts, onCalendar = onCalendar) + } +} + +/** + * The menu's foot (mockup "Seitenmenü neu"): the settings on the left; contacts and calendar on the right, each only + * when the server offers it (`features.contacts` / `features.calendar`) and the app can open it, the active area in the + * accent. A tap on the active one goes back to the mail. + */ +@Composable +internal fun MailDrawerFoot( + me: MMe?, + area: String, + onSettings: () -> Unit, + onContacts: (() -> Unit)?, + onCalendar: (() -> Unit)?, +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DrawerIcon(GcIcons.Settings, stringResource(R.string.mail_settings), on = false, onClick = onSettings) + Spacer(Modifier.weight(1f)) + if (onContacts != null && MailDrawerFoot.contacts(me)) { + DrawerIcon(GcIcons.User, stringResource(R.string.mail_contacts), on = area == MailDrawerFoot.CONTACTS, onClick = onContacts) + } + if (onCalendar != null && MailDrawerFoot.calendar(me)) { + DrawerIcon(GcIcons.Calendar, stringResource(R.string.mail_calendar), on = area == MailDrawerFoot.CALENDAR, onClick = onCalendar) } } } +/** What the menu's foot shows (pure, unit-tested). */ +internal object MailDrawerFoot { + const val MAIL = "mail" + const val CALENDAR = "calendar" + const val CONTACTS = "contacts" + + fun calendar(me: MMe?): Boolean = me?.has(MMe.FEATURE_CALENDAR) == true + + fun contacts(me: MMe?): Boolean = me?.has(MMe.FEATURE_CONTACTS) == true +} + @Composable private fun DrawerLabel(text: String) { Text( @@ -740,18 +780,20 @@ private fun DrawerAccount(a: MAccount, count: Int, on: Boolean, open: Boolean, o } } +/** A square button of the menu's foot (44×44); [on]: the area shown now, in the accent (`aria-pressed`). */ @Composable -private fun DrawerIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) { +private fun DrawerIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, on: Boolean, onClick: () -> Unit) { val extra = GateControlTheme.extraColors + val shape = RoundedCornerShape(12.dp) Box( Modifier .size(44.dp) - .clip(RoundedCornerShape(12.dp)) - .border(1.dp, extra.border, RoundedCornerShape(12.dp)) - .clickable(role = Role.Button, onClick = onClick) + .clip(shape) + .then(if (on) Modifier.background(extra.accentBg) else Modifier.border(1.dp, extra.border, shape)) + .selectable(selected = on, role = Role.Button, onClick = onClick) .semantics { contentDescription = label }, contentAlignment = Alignment.Center, ) { - Icon(icon, contentDescription = null, tint = extra.muted, modifier = Modifier.size(20.dp)) + Icon(icon, contentDescription = null, tint = if (on) extra.accentText else extra.muted, modifier = Modifier.size(20.dp)) } } diff --git a/app/src/main/res/values-de/strings_mail.xml b/app/src/main/res/values-de/strings_mail.xml index 95153741..083bace8 100644 --- a/app/src/main/res/values-de/strings_mail.xml +++ b/app/src/main/res/values-de/strings_mail.xml @@ -53,6 +53,8 @@ Konten Konto hinzufügen Mail-Einstellungen + Kalender + Kontakte %1$s von %2$s belegt Offline – du siehst den zuletzt geladenen Stand. %1$s: Anmeldung abgelehnt. Tippen, um dich neu anzumelden. diff --git a/app/src/main/res/values/strings_mail.xml b/app/src/main/res/values/strings_mail.xml index 06a6d7d2..639a9dc3 100644 --- a/app/src/main/res/values/strings_mail.xml +++ b/app/src/main/res/values/strings_mail.xml @@ -53,6 +53,8 @@ Accounts Add account Mail settings + Calendar + Contacts %1$s of %2$s used Offline – showing what was loaded last. %1$s: sign-in refused. Tap to sign in again. diff --git a/app/src/test/java/com/gatecontrol/android/ui/mail/MailDrawerFootTest.kt b/app/src/test/java/com/gatecontrol/android/ui/mail/MailDrawerFootTest.kt new file mode 100644 index 00000000..54949d8a --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/mail/MailDrawerFootTest.kt @@ -0,0 +1,32 @@ +package com.gatecontrol.android.ui.mail + +import com.gatecontrol.android.mail.MLimits +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MMicrosoft +import com.gatecontrol.android.mail.MSettings +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MailDrawerFootTest { + + private fun me(vararg features: String) = MMe( + features = features.toSet(), + limits = MLimits(5, 0, 0, 12), + microsoft = MMicrosoft(null, "https://login.microsoftonline.com/common", emptyList()), + accounts = emptyList(), + unreadAll = 0, + unreadByAccount = emptyMap(), + settings = MSettings.DEFAULT, + ) + + @Test + fun `contacts and calendar only with the server's features`() { + assertFalse(MailDrawerFoot.calendar(null)) + assertFalse(MailDrawerFoot.contacts(null)) + assertFalse(MailDrawerFoot.calendar(me("mail"))) + assertFalse(MailDrawerFoot.contacts(me("mail", "calendar"))) + assertTrue(MailDrawerFoot.calendar(me("mail", "calendar"))) + assertTrue(MailDrawerFoot.contacts(me("mail", "contacts"))) + } +} From 4e1eb2b699880d87f7b503b87884e994490a0f95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 10:35:22 +0000 Subject: [PATCH 2/2] feat(mail): Kalender und Kontakte im Mail-Tab (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Mail-Tab bekommt die Bereiche Kalender und Kontakte (Vertrag D2–D8, API C2/C3 von Mail 1.2.0, `series` aus Mail 1.3.0): - Seitenleiste je Bereich: Kalender mit Häkchen/Farbe, Gruppen GateControl / Konto / Eingeblendet, Optionen per langem Druck, „Kalender hinzufügen“, „iCal-Abo“; Kontakte mit Adressbüchern, Gruppen und Dubletten. Kalender und Kontakte lassen sich als Seite an Start anheften (ohne eigenen Tab). - Kalender: Tag, 3 Tage, Woche, Monat, Liste; Raster mit Überlappung, Ganztägigen, offenen Einladungen, Abgesagten, Einblendungen und Jetzt-Linie; Termin ansehen, anlegen, bearbeiten (Wiederholung, Zeitzone, Erinnerungen, Teilnehmer mit Vorschlägen, Ort, Teams/Link), „Nur dieser / ganze Serie“ (Serie über series.start), Antworten, „Meine Änderung erneut anwenden“ bei Konflikten, Einladungskarte in der Lese-Ansicht. - Kontakte: Liste mit Suche, Buchstaben, A–Z, Gruppen-Reiter; Kontakt mit Mail, Anrufen, Bearbeiten, letzten Mails und gemeinsamen Terminen; Bearbeiten mit Foto; Dubletten zusammenführen; vCard-Import und -Export. - Absender-Ansicht mit echtem Kontakt und „Als Kontakt speichern“; Empfänger-Vorschläge mit Kontakten und Gruppen. - Microsoft: Band „Kalender und Kontakte freigeben“ mit „Neu anmelden“ bei missingScopes, danach Hinweis mit adminConsentUrl. - Push (Erinnerung → Termin, Einladung), Live-Ereignisse calendar und contacts, Offline-Kopie wie bei Mail (nur lesen). - Parser MailPimJson über den Gson-Baum (keine Reflection), Texte DE/EN, Unit-Tests für Raster, Ganztägiges, RRULE-Text, Zeitzonen, Serienverschiebung, Merge, Parser und Push. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../gatecontrol/android/layout/StartPages.kt | 12 +- .../com/gatecontrol/android/mail/MailApi.kt | 138 ++++ .../com/gatecontrol/android/mail/MailCache.kt | 92 +++ .../android/mail/MailCalendarModels.kt | 375 +++++++++ .../android/mail/MailContactModels.kt | 182 ++++ .../com/gatecontrol/android/mail/MailJson.kt | 18 +- .../gatecontrol/android/mail/MailMicrosoft.kt | 3 + .../gatecontrol/android/mail/MailModels.kt | 37 +- .../gatecontrol/android/mail/MailPimJson.kt | 779 ++++++++++++++++++ .../gatecontrol/android/mail/MailPimLogic.kt | 600 ++++++++++++++ .../android/mail/MailPimRepository.kt | 294 +++++++ .../com/gatecontrol/android/mail/MailPush.kt | 32 +- .../android/mail/MailRepository.kt | 7 + .../android/navigation/AppNavigation.kt | 25 +- .../android/navigation/MailNavigation.kt | 112 ++- .../android/navigation/MainTabs.kt | 21 +- .../gatecontrol/android/navigation/Screen.kt | 73 ++ .../gatecontrol/android/push/PushNotifier.kt | 3 + .../android/ui/mail/MailComponents.kt | 4 +- .../android/ui/mail/MailComposeScreen.kt | 13 +- .../android/ui/mail/MailComposeViewModel.kt | 5 +- .../android/ui/mail/MailHomeScreen.kt | 407 ++++++--- .../android/ui/mail/MailMoreScreens.kt | 7 + .../android/ui/mail/MailReadScreen.kt | 27 + .../android/ui/mail/MailSenderScreen.kt | 38 +- .../android/ui/mail/MailSenderViewModel.kt | 15 +- .../ui/mail/calendar/CalendarDrawer.kt | 273 ++++++ .../ui/mail/calendar/CalendarFormat.kt | 110 +++ .../ui/mail/calendar/InvitationCard.kt | 309 +++++++ .../ui/mail/calendar/MailCalendarScreen.kt | 630 ++++++++++++++ .../ui/mail/calendar/MailCalendarViewModel.kt | 252 ++++++ .../ui/mail/calendar/MailEventEditScreen.kt | 450 ++++++++++ .../mail/calendar/MailEventEditViewModel.kt | 444 ++++++++++ .../ui/mail/calendar/MailEventScreen.kt | 342 ++++++++ .../ui/mail/calendar/MailEventViewModel.kt | 102 +++ .../ui/mail/calendar/MailInviteViewModel.kt | 126 +++ .../ui/mail/contacts/ContactComponents.kt | 145 ++++ .../ui/mail/contacts/ContactsDrawer.kt | 57 ++ .../ui/mail/contacts/MailContactEditScreen.kt | 388 +++++++++ .../mail/contacts/MailContactEditViewModel.kt | 245 ++++++ .../ui/mail/contacts/MailContactScreen.kt | 334 ++++++++ .../ui/mail/contacts/MailContactViewModel.kt | 87 ++ .../ui/mail/contacts/MailContactsScreen.kt | 440 ++++++++++ .../ui/mail/contacts/MailContactsViewModel.kt | 186 +++++ .../ui/mail/contacts/MailDuplicatesScreen.kt | 277 +++++++ .../ui/mail/contacts/MailGroupScreen.kt | 246 ++++++ .../android/ui/start/StartPageMenu.kt | 11 +- .../android/ui/start/StartPager.kt | 3 + .../android/ui/start/StartPagesScreens.kt | 7 +- .../android/ui/start/StartPagesViewModel.kt | 4 + app/src/main/res/values-de/strings_pim.xml | 271 ++++++ app/src/main/res/values/strings_pim.xml | 271 ++++++ .../android/mail/MailPimJsonTest.kt | 329 ++++++++ .../android/mail/MailPimLogicTest.kt | 327 ++++++++ .../android/navigation/MainTabsTest.kt | 25 + 55 files changed, 9827 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt create mode 100644 app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt create mode 100644 app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt create mode 100644 app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt create mode 100644 app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarFormat.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/InvitationCard.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailInviteViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactComponents.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactsDrawer.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsViewModel.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailDuplicatesScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailGroupScreen.kt create mode 100644 app/src/main/res/values-de/strings_pim.xml create mode 100644 app/src/main/res/values/strings_pim.xml create mode 100644 app/src/test/java/com/gatecontrol/android/mail/MailPimJsonTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/mail/MailPimLogicTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt index 7b21de5a..78350ad0 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt @@ -27,9 +27,17 @@ object StartPages { const val DRIVE = "drive" const val NETWORK = "network" const val FAMILIE = "familie" + /** Mail's calendar and contacts (since Mail 1.2.0): pages beside Start, never a tab of their own. */ + const val CALENDAR = "calendar" + const val CONTACTS = "contacts" - /** Functions that can be pages, in the order of the tab bar. */ - val FEATURES = listOf(CHATS, MAIL, VAULT, DRIVE, NETWORK, FAMILIE) + /** Functions that can be pages, in the order of the tab bar (calendar and contacts after Mail). */ + val FEATURES = listOf(CHATS, MAIL, CALENDAR, CONTACTS, VAULT, DRIVE, NETWORK, FAMILIE) + + /** Functions that are a page or nothing (no tab): taking them off leaves them in their tab (Mail). */ + val PAGE_ONLY = setOf(CALENDAR, CONTACTS) + + fun isPageOnly(id: String): Boolean = id in PAGE_ONLY private val WIDGETS = Regex("^w([0-9]{1,3})$") diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt index d5954a44..5a0cbcdb 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt @@ -4,6 +4,7 @@ import com.gatecontrol.android.data.SettingsRepository import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClient import com.gatecontrol.android.network.ApiClientProvider +import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser @@ -14,8 +15,10 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.ResponseBody import retrofit2.Response import timber.log.Timber @@ -49,6 +52,10 @@ class MailApi @Inject constructor( private const val IMAGE_TIMEOUT_MS = 20_000L private const val IMAGE_MAX_BYTES = 6L * 1024 * 1024 private const val AVATAR_MAX_BYTES = 2L * 1024 * 1024 + /** Keeps `null` members (a PATCH that clears a field); Retrofit's Gson leaves them out. */ + private val NULLS_GSON = GsonBuilder().serializeNulls().create() + private val JSON_TYPE = "application/json; charset=utf-8".toMediaType() + private val OVERLAY_ID = Regex("^(familie|birthdays|alltag:[0-9]{1,15})$") } private val _unauthorized = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -332,4 +339,135 @@ class MailApi @Inject constructor( post("filing/$id/confirm", MailJson.driveSaveBody(folderId, name, null), timeoutMs = 60_000L, parse = MailJson::savedAnswer) suspend fun dismissFiling(id: Long): MResult = post("filing/$id/dismiss", JsonObject(), parse = unit) + + // ─── calendars (since Mail 1.2.0) ─────────────────────────────── + + /** A PATCH whose body keeps its `null` members (clearing a field: a rule, the online link, a birthday). */ + private suspend fun patchNulls(path: String, body: JsonObject, parse: (JsonObject) -> T?) = + call(request = { + pluginPatchRaw(MAIL_PLUGIN, path, NULLS_GSON.toJson(body).toRequestBody(JSON_TYPE), langQuery()) + }, parse = parse) + + /** `GET calendars` with the raw answer (kept for offline). */ + suspend fun calendars(): MResult> = get("calendars") { o -> MailPimJson.calendars(o)?.let { it to o } } + + suspend fun createCalendar(name: String, color: String?): MResult = + post("calendars", MailPimJson.calendarBody(name, color), parse = MailPimJson::calendarAnswer) + + suspend fun patchCalendar(id: Long, body: JsonObject): MResult = patch("calendars/$id", body, parse = MailPimJson::calendarAnswer) + + suspend fun deleteCalendar(id: Long): MResult = del("calendars/$id", unit) + + suspend fun patchOverlay(id: String, body: JsonObject): MResult = patch("overlays/" + id.takeIf { OVERLAY_ID.matches(it) }.orEmpty(), body, parse = MailPimJson::overlayAnswer) + + suspend fun subscribeCalendar(url: String, name: String?): MResult = + post("calendars/subscribe", MailPimJson.subscribeBody(url, name), timeoutMs = 45_000L, parse = MailPimJson::calendarAnswer) + + suspend fun calendarSettings(body: JsonObject): MResult = put("calendar/settings", body, MailPimJson::prefsAnswer) + + /** `GET events` in `[from, to)` (≤ 62 days) of [calendars] (ids and overlay ids; null: all visible). */ + suspend fun events(from: Long, to: Long, calendars: List? = null): MResult> = + get("events", buildMap { + put("from", from.toString()) + put("to", to.toString()) + calendars?.let { put("calendars", it.joinToString(",")) } + }) { o -> MailPimJson.events(o)?.let { it to o } } + + suspend fun event(id: Long, occurrence: String?): MResult> = + get("events/$id", occurrence?.let { mapOf("occurrence" to it) }.orEmpty()) { o -> MailPimJson.event(o)?.let { it to o } } + + suspend fun createEvent(body: JsonObject): MResult = post("events", body, timeoutMs = 60_000L, parse = MailPimJson::eventSaved) + + /** `PATCH events/`: `409 conflict { current }` when [body]'s etag is old ([MailPimJson.conflictEvent]). */ + suspend fun updateEvent(id: Long, body: JsonObject): MResult = patchNulls("events/$id", body, MailPimJson::eventSaved) + + suspend fun deleteEvent(id: Long, scope: String, occurrence: String?, notify: Boolean, comment: String?): MResult = + call(request = { + pluginDelete(MAIL_PLUGIN, "events/$id", buildMap { + put("scope", scope) + occurrence?.let { put("occurrence", it) } + if (notify) put("notify", "1") + comment?.trim()?.takeIf { it.isNotEmpty() }?.let { put("comment", it.take(1000)) } + } + langQuery()) + }, parse = unit) + + suspend fun respondEvent(id: Long, body: JsonObject): MResult = post("events/$id/respond", body, timeoutMs = 60_000L, parse = MailPimJson::eventAnswer) + + suspend fun invitations(): MResult> = get("invitations", mapOf("state" to "open"), MailPimJson::invitations) + + suspend fun invitation(id: Long): MResult = get("invitations/$id", parse = MailPimJson::invitation) + + suspend fun respondInvitation(id: Long, body: JsonObject): MResult = + post("invitations/$id/respond", body, timeoutMs = 60_000L, parse = MailPimJson::inviteAnswer) + + suspend fun applyInvitation(id: Long): MResult = post("invitations/$id/apply", JsonObject(), timeoutMs = 60_000L, parse = MailPimJson::inviteAnswer) + + suspend fun dismissInvitation(id: Long, decline: Boolean): MResult = post("invitations/$id/dismiss", MailPimJson.dismissBody(decline), timeoutMs = 60_000L, parse = unit) + + // ─── contacts (since Mail 1.2.0, path `cards`) ────────────────── + + suspend fun addressbooks(): MResult> = get("addressbooks") { o -> MailPimJson.books(o)?.let { it to o } } + + suspend fun patchAddressbook(id: Long, visible: Boolean): MResult = patch("addressbooks/$id", MailPimJson.bookPatch(visible), parse = MailPimJson::bookAnswer) + + /** `GET cards`: a page of the list ([since]: only what changed, for the offline copy). */ + suspend fun cards(q: String? = null, book: Long? = null, group: Long? = null, cursor: String? = null, since: Long? = null, limit: Int = 200): MResult> = + get("cards", buildMap { + q?.trim()?.takeIf { it.isNotEmpty() }?.let { put("q", it.take(100)) } + put("book", book?.toString() ?: "all") + group?.let { put("group", it.toString()) } + cursor?.let { put("cursor", it) } + since?.let { put("since", it.toString()) } + put("limit", limit.coerceIn(1, 1000).toString()) + }) { o -> MailPimJson.cardPage(o)?.let { it to o } } + + suspend fun card(id: Long): MResult> = get("cards/$id") { o -> MailPimJson.fullCard(o)?.let { it to o } } + + suspend fun createCard(body: JsonObject): MResult = post("cards", body, parse = MailPimJson::cardAnswer) + + /** `PATCH cards/`: `409 conflict { current }` ([MailPimJson.conflictCard]), `409 read_only`. */ + suspend fun patchCard(id: Long, body: JsonObject): MResult = patchNulls("cards/$id", body, MailPimJson::cardAnswer) + + suspend fun deleteCard(id: Long): MResult = del("cards/$id", unit) + + suspend fun cardPhoto(id: Long): MResult = get("cards/$id/photo", parse = MailPimJson::photo) + + suspend fun putCardPhoto(id: Long, mime: String, bytes: ByteArray): MResult = put("cards/$id/photo", MailPimJson.photoBody(mime, bytes), MailPimJson::cardAnswer) + + suspend fun deleteCardPhoto(id: Long): MResult = del("cards/$id/photo", MailPimJson::cardAnswer) + + suspend fun lookup(email: String): MResult = get("cards/lookup", mapOf("email" to email.trim().take(320)), MailPimJson::lookup) + + suspend fun activity(id: Long): MResult = get("cards/$id/activity", parse = MailPimJson::activity) + + suspend fun cardDraft(messageId: Long?, email: String): MResult = + get("cards/draft", buildMap { + messageId?.let { put("messageId", it.toString()) } + put("email", email.trim().take(320)) + }, MailPimJson::cardDraft) + + suspend fun duplicates(): MResult> = get("cards/duplicates", parse = MailPimJson::duplicates) + + suspend fun merge(body: JsonObject): MResult = post("cards/merge", body, timeoutMs = 60_000L, parse = MailPimJson::cardAnswer) + + suspend fun ignoreDuplicates(ids: List): MResult = post("cards/duplicates/ignore", MailJson.idsBody(ids), parse = unit) + + suspend fun importVcf(vcf: String, addressbookId: Long?): MResult = + post("cards/import", MailPimJson.importBody(vcf, addressbookId), timeoutMs = 120_000L, parse = MailPimJson::importAnswer) + + suspend fun exportVcf(book: Long? = null, group: Long? = null): MResult = + get("cards/export", buildMap { + put("book", book?.toString() ?: "all") + group?.let { put("group", it.toString()) } + }, MailPimJson::export) + + suspend fun createGroup(name: String): MResult = post("groups", MailPimJson.groupBody(name), parse = MailPimJson::groupAnswer) + + suspend fun renameGroup(id: Long, name: String): MResult = patch("groups/$id", MailPimJson.groupBody(name), parse = MailPimJson::groupAnswer) + + suspend fun deleteGroup(id: Long): MResult = del("groups/$id", unit) + + suspend fun group(id: Long): MResult = get("groups/$id", parse = MailPimJson::groupDetail) + + suspend fun groupMembers(id: Long, body: JsonObject): MResult = post("groups/$id/members", body, parse = MailPimJson::groupDetail) } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt index 6b818307..6a576c73 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt @@ -151,6 +151,98 @@ class MailCache @Inject constructor( } } + // ─── calendars and contacts (since Mail 1.2.0) ────────────────── + + suspend fun putCalendars(raw: JsonObject) { io { write(File(it, "pim/calendars.json"), raw) } } + + suspend fun calendars(): MCalendars? = io { dir -> read(File(dir, "pim/calendars.json"))?.let(MailPimJson::calendars) } + + /** The occurrences of `[from, to)` as the server gave them (only ranges near today are kept, D7: −30 … +90 days). */ + suspend fun putEvents(from: Long, to: Long, raw: JsonObject, now: Long = System.currentTimeMillis()) { + io { dir -> + val events = File(dir, "pim/events").apply { mkdirs() } + write(File(events, "$from-$to.json"), raw) + events.listFiles()?.forEach { f -> + val range = MailPimCacheLogic.rangeOf(f.name) + if (range == null || !MailPimCacheLogic.keep(range.first, range.second, now)) f.delete() + } + } + } + + /** The copy of `[from, to)`: every kept range that overlaps it, the newest copy of an occurrence winning. */ + suspend fun events(from: Long, to: Long): MEvents? = io { dir -> + val files = File(dir, "pim/events").listFiles()?.mapNotNull { f -> MailPimCacheLogic.rangeOf(f.name)?.let { f to it } } + ?.filter { (_, r) -> r.first < to && r.second > from }?.sortedBy { it.first.lastModified() }.orEmpty() + if (files.isEmpty()) return@io null + val items = LinkedHashMap() + val overlays = LinkedHashMap() + files.forEach { (f, _) -> + read(f)?.let(MailPimJson::events)?.let { e -> + e.items.filter { it.start < to && it.end >= from }.forEach { items[it.key] = it } + e.overlays.filter { it.start < to && it.end >= from }.forEach { overlays[it.key] = it } + } + } + MEvents(items.values.sortedBy { it.start }, overlays.values.toList(), emptyList()) + } + + suspend fun putEvent(id: Long, occurrence: String?, raw: JsonObject, now: Long = System.currentTimeMillis()) { + io { dir -> + write(File(dir, "pim/event/${name("$id:${occurrence.orEmpty()}")}.json"), raw) + MailCacheLogic.overflow(File(dir, "pim/event").listFiles()?.map { it.name to it.lastModified() }.orEmpty(), now) + .forEach { File(dir, "pim/event/$it").delete() } + } + } + + suspend fun event(id: Long, occurrence: String?): MEvent? = io { dir -> + read(File(dir, "pim/event/${name("$id:${occurrence.orEmpty()}")}.json"))?.let(MailPimJson::event) + } + + suspend fun putBooks(raw: JsonObject) { io { write(File(it, "pim/books.json"), raw) } } + + suspend fun books(): MBooks? = io { dir -> read(File(dir, "pim/books.json"))?.let(MailPimJson::books) } + + /** Every contact's summary and the version they are of (`since`). */ + suspend fun putCards(items: List, version: Long) { + io { dir -> write(File(dir, "pim/cards.json"), MailPimCacheLogic.cardsJson(items, version)) } + } + + suspend fun cards(): Pair, Long>? = io { dir -> + read(File(dir, "pim/cards.json"))?.let { o -> MailPimJson.cardPage(o)?.let { it.items to it.version } } + } + + suspend fun putCard(id: Long, raw: JsonObject, now: Long = System.currentTimeMillis()) { + io { dir -> + write(File(dir, "pim/card/$id.json"), raw) + MailCacheLogic.overflow(File(dir, "pim/card").listFiles()?.map { it.name to it.lastModified() }.orEmpty(), now) + .forEach { File(dir, "pim/card/$it").delete() } + } + } + + suspend fun card(id: Long): MCard? = io { dir -> read(File(dir, "pim/card/$id.json"))?.let(MailPimJson::fullCard) } + + suspend fun forgetCard(id: Long) { + io { dir -> File(dir, "pim/card/$id.json").delete() } + } + + /** A contact's photo (the etag changes with it); the 500 used last stay. */ + suspend fun putPhoto(id: Long, etag: String, p: MPhoto) { + io { dir -> + val photos = File(dir, "pim/photos").apply { mkdirs() } + File(photos, name("$id:$etag") + if (p.mime == "image/png") ".png" else ".jpg").writeBytes(p.bytes) + photos.listFiles()?.sortedByDescending { it.lastModified() }?.drop(MailPimCacheLogic.MAX_PHOTOS)?.forEach { it.delete() } + } + } + + suspend fun photo(id: Long, etag: String): MPhoto? = io { dir -> + val base = name("$id:$etag") + listOf(".jpg" to "image/jpeg", ".png" to "image/png").firstNotNullOfOrNull { (ext, mime) -> + File(dir, "pim/photos/$base$ext").takeIf { it.isFile }?.let { f -> + f.setLastModified(System.currentTimeMillis()) + MPhoto(mime, f.readBytes()) + } + } + } + /** Everything of the Mail tab on this device: the copies of every login and the files opened from mails. */ suspend fun wipe() = withContext(Dispatchers.IO) { mutex.withLock { diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt new file mode 100644 index 00000000..d618dfee --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt @@ -0,0 +1,375 @@ +package com.gatecontrol.android.mail + +/** + * Calendars of the Mail plugin (since Mail 1.2.0, contract C3 "Kalender"): the person's calendars, the overlays of + * Familie, Alltag and the birthdays, occurrences in a range, one event, invitations. Read by [MailPimJson] from the + * Gson tree, never by reflection. + */ + +/** `Account.calendar` / `Account.contacts`: the account's calendar or address book side. */ +data class MPimSide( + val sync: Boolean, + /** ok | syncing | auth_failed | unreachable | consent_required | none | off */ + val state: String, + val readOnly: Boolean, + val server: String?, +) { + companion object { + const val STATE_OK = "ok" + const val STATE_SYNCING = "syncing" + const val STATE_AUTH_FAILED = "auth_failed" + const val STATE_UNREACHABLE = "unreachable" + const val STATE_CONSENT = "consent_required" + const val STATE_NONE = "none" + const val STATE_OFF = "off" + } +} + +data class MCalendar( + val id: Long, + /** local | graph | caldav | ics */ + val source: String, + val accountId: Long?, + val name: String, + /** `#RRGGBB` */ + val color: String, + /** private | business | other */ + val kind: String, + val writable: Boolean, + val visible: Boolean, + /** Reminders of this calendar. */ + val notify: Boolean, + val isDefault: Boolean, + val canInvite: Boolean, + /** Teams meetings (Microsoft 365 calendars). */ + val canOnline: Boolean, + val maxReminders: Int, + /** ok | syncing | auth_failed | unreachable | consent_required */ + val state: String, + val errorCode: String?, + val syncedAt: Long?, + val version: Long, +) { + val local: Boolean get() = source == SOURCE_LOCAL + + companion object { + const val SOURCE_LOCAL = "local" + const val SOURCE_GRAPH = "graph" + const val SOURCE_CALDAV = "caldav" + const val SOURCE_ICS = "ics" + } +} + +/** An overlay: `familie`, `alltag:` or `birthdays` (read only). */ +data class MOverlay(val id: String, val source: String, val name: String, val color: String, val visible: Boolean) + +/** The person's calendar settings (`CalPrefs`). */ +data class MCalPrefs( + val defaultCalendarId: Long?, + /** day | 3day | week | month | list */ + val view: String, + /** 1 Monday, 0 Sunday */ + val weekStart: Int, + val workWeek: Boolean, + val timeZone: String?, + val reminders: Boolean, + /** Minutes before, null: none. */ + val defaultReminder: Int?, + /** Minutes before midnight of an all-day event (1080 → 18:00 the day before), null: none. */ + val allDayReminder: Int?, +) { + companion object { + const val VIEW_DAY = "day" + const val VIEW_3DAY = "3day" + const val VIEW_WEEK = "week" + const val VIEW_MONTH = "month" + const val VIEW_LIST = "list" + val VIEWS = listOf(VIEW_DAY, VIEW_3DAY, VIEW_WEEK, VIEW_MONTH, VIEW_LIST) + val DEFAULT = MCalPrefs(null, VIEW_3DAY, 1, false, null, true, 15, 1080) + } +} + +/** A Microsoft account whose calendar needs the person to sign in again. */ +data class MConsent(val accountId: Long, val missing: List) + +/** `GET calendars`. */ +data class MCalendars(val calendars: List, val overlays: List, val prefs: MCalPrefs, val consent: List) { + fun calendar(id: Long?): MCalendar? = id?.let { c -> calendars.firstOrNull { it.id == c } } +} + +data class MOnline(val url: String, /** teams | zoom | meet | webex | other */ val provider: String) + +/** One occurrence of an event in a range (`Occurrence`). */ +data class MOccurrence( + /** `:` or `` */ + val key: String, + val eventId: Long, + val calendarId: Long, + /** The original start (ms as text) or date (`YYYY-MM-DD`) of a recurring one; null for a single event. */ + val occurrence: String?, + val title: String, + val start: Long, + val end: Long, + val allDay: Boolean, + /** All-day: the first and the last day (inclusive). */ + val startDate: String?, + val endDate: String?, + val timeZone: String?, + val location: String?, + val online: MOnline?, + /** confirmed | tentative | cancelled */ + val status: String, + /** busy | free | tentative | oof */ + val busy: String, + /** organizer | accepted | tentative | declined | needs_action | null */ + val myResponse: String?, + val recurring: Boolean, + val attendees: Int, + val private: Boolean, + val reminder: Int?, +) { + val cancelled: Boolean get() = status == STATUS_CANCELLED + /** An invitation not answered yet (mockup: dashed). */ + val open: Boolean get() = myResponse == RESPONSE_NEEDS_ACTION + + companion object { + const val STATUS_CONFIRMED = "confirmed" + const val STATUS_TENTATIVE = "tentative" + const val STATUS_CANCELLED = "cancelled" + const val RESPONSE_ORGANIZER = "organizer" + const val RESPONSE_ACCEPTED = "accepted" + const val RESPONSE_TENTATIVE = "tentative" + const val RESPONSE_DECLINED = "declined" + const val RESPONSE_NEEDS_ACTION = "needs_action" + } +} + +/** An overlay's entry (Familie, Alltag, a birthday): read only. */ +data class MOverlayItem( + val key: String, + val overlayId: String, + val title: String, + val start: Long, + val end: Long, + val allDay: Boolean, + val startDate: String?, + val endDate: String?, + val location: String?, +) + +/** `GET events`. */ +data class MEvents(val items: List, val overlays: List, val pending: List) + +/** The organizer of an event or an invitation. */ +data class MOrganizer(val name: String, val email: String, val self: Boolean, val contactId: Long?) { + val label: String get() = name.ifBlank { email } +} + +data class MAttendee( + val name: String, + val email: String, + /** required | optional | resource */ + val role: String, + /** accepted | tentative | declined | needs_action */ + val response: String, + val self: Boolean, + val contactId: Long?, +) { + val label: String get() = name.ifBlank { email } + + companion object { + const val ROLE_REQUIRED = "required" + const val ROLE_OPTIONAL = "optional" + const val ROLE_RESOURCE = "resource" + } +} + +/** A rule the form can show and change (also what `POST events` takes as `recurrence`). */ +data class MRule( + /** daily | weekly | monthly | yearly */ + val freq: String, + val interval: Int = 1, + /** `MO` … `SU` */ + val byDay: List? = null, + val byMonthDay: List? = null, + /** With one [byDay]: the n-th weekday of the month (-1 the last). */ + val bySetPos: Int? = null, + /** `YYYY-MM-DD` */ + val until: String? = null, + val count: Int? = null, +) { + companion object { + const val DAILY = "daily" + const val WEEKLY = "weekly" + const val MONTHLY = "monthly" + const val YEARLY = "yearly" + val FREQS = listOf(DAILY, WEEKLY, MONTHLY, YEARLY) + val DAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU") + } +} + +/** `Event.recurrence`. */ +data class MRecurrence( + val rrule: String, + /** False: the rule can do more than the form (only "this one" or replacing the whole rule). */ + val editable: Boolean, + val rule: MRule, + val exceptions: List, + val deleted: List, +) + +/** `GET events/`: an occurrence with everything about its event. */ +data class MEvent( + val occ: MOccurrence, + val description: String, + /** Cleaned by the server (Graph), shown like a mail's HTML. */ + val descriptionHtml: String?, + val organizer: MOrganizer?, + val attendees: List, + val reminders: List, + val recurrence: MRecurrence?, + /** The series of an occurrence ([occ] of a recurring event). */ + val seriesId: Long?, + val readOnly: Boolean, + val canRespond: Boolean, + val canEdit: Boolean, + val canDelete: Boolean, + val categories: List, + val invitationId: Long?, + val url: String?, + val createdAt: Long?, + val updatedAt: Long?, + val etag: String?, + /** + * Since Mail 1.3.0: the series' own start and end (DTSTART/DTEND of the master) — the reference when the whole + * series moves. Null from older servers: a change of time then only goes to this occurrence. + */ + val series: MSeries? = null, +) { + val id: Long get() = occ.eventId +} + +/** + * `Event.series` (Mail 1.3.0): DTSTART/DTEND of the series' master in the form of `start` / `end`; an all-day series + * also with its dates (`endDate` inclusive). + */ +data class MSeries(val start: Long, val end: Long, val allDay: Boolean, val startDate: String? = null, val endDate: String? = null) + +/** The new times of a series moved from one of its occurrences ([MailPimLogic.shiftSeries]). */ +data class MShift(val start: Long? = null, val end: Long? = null, val startDate: String? = null, val endDate: String? = null) + +/** `POST/PATCH events` → `{ event, sent }`. */ +data class MEventSaved(val event: MEvent, val sentMail: Int, val sentInternal: Int) + +data class MInviteCounts(val accepted: Int, val tentative: Int, val declined: Int, val needsAction: Int) { + val total: Int get() = accepted + tentative + declined + needsAction +} + +data class MConflict(val title: String, val start: Long, val end: Long, val calendarId: Long?) + +/** An invitation (from a mail or from a GateControl person). */ +data class MInvitation( + val id: Long, + /** mail | internal */ + val kind: String, + val messageId: Long?, + val accountId: Long?, + /** request | cancel | reply | counter */ + val method: String, + /** open | answered | applied | cancelled | outdated */ + val state: String, + val uid: String, + val title: String, + val start: Long, + val end: Long, + val allDay: Boolean, + val startDate: String?, + val endDate: String?, + val timeZone: String?, + val location: String?, + val online: MOnline?, + /** The rule in words (`{ de, en }`). */ + val recurrenceDe: String?, + val recurrenceEn: String?, + val organizer: MOrganizer, + /** Who answers (reply, counter); the organizer otherwise. */ + val from: MAddress, + val attendees: List, + val counts: MInviteCounts, + val myResponse: String?, + val comment: String?, + /** counter: the time proposed. */ + val proposedStart: Long?, + val proposedEnd: Long?, + val eventId: Long?, + val calendarId: Long?, + val calendarChoices: List, + val day: List, + val conflicts: List, +) { + companion object { + const val METHOD_REQUEST = "request" + const val METHOD_CANCEL = "cancel" + const val METHOD_REPLY = "reply" + const val METHOD_COUNTER = "counter" + const val STATE_OPEN = "open" + const val STATE_ANSWERED = "answered" + const val STATE_APPLIED = "applied" + const val STATE_CANCELLED = "cancelled" + const val STATE_OUTDATED = "outdated" + } +} + +/** `POST invitations//respond|apply` → `{ invitation, event }`. */ +data class MInviteAnswer(val invitation: MInvitation, val event: MEvent?) + +/** What the form sends (`POST events` / `PATCH events/`); null fields stay out of a PATCH. */ +data class MEventInput( + val calendarId: Long? = null, + val title: String? = null, + val allDay: Boolean? = null, + val start: Long? = null, + val end: Long? = null, + val startDate: String? = null, + val endDate: String? = null, + val timeZone: String? = null, + /** "" clears it. */ + val location: String? = null, + val description: String? = null, + /** [online] given: a Teams meeting ([teams]) or a link ([onlineUrl], "" clears it). */ + val teams: Boolean = false, + val onlineUrl: String? = null, + val onlineSet: Boolean = false, + val reminders: List? = null, + /** [recurrenceSet]: the rule ([recurrence] null: none). */ + val recurrence: MRule? = null, + val recurrenceSet: Boolean = false, + val attendees: List? = null, + val busy: String? = null, + val private: Boolean? = null, + val sendInvites: Boolean? = null, +) + +data class MAttendeeInput(val email: String, val name: String? = null, val role: String = MAttendee.ROLE_REQUIRED) + +/** + * Realtime event `calendar`: `changed` (a calendar's events, [from]/[to] when known), `calendars` (the list and its + * states), `invitation`, `conflict`. + */ +data class MCalChanged( + val kind: String, + val calendarId: Long? = null, + val accountId: Long? = null, + val from: Long? = null, + val to: Long? = null, + val invitationId: Long? = null, + val messageId: Long? = null, + val eventId: Long? = null, +) { + companion object { + const val CHANGED = "changed" + const val CALENDARS = "calendars" + const val INVITATION = "invitation" + const val CONFLICT = "conflict" + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt new file mode 100644 index 00000000..c5ecd93d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt @@ -0,0 +1,182 @@ +package com.gatecontrol.android.mail + +/** + * Contacts of the Mail plugin (since Mail 1.2.0, contract C3 "Kontakte", path `cards`): address books, groups, the + * contacts as a list and one by one, the sender lookup, duplicates, vCard import and export. Read by [MailPimJson]. + */ + +data class MAddressbook( + val id: Long, + /** local | graph | carddav */ + val source: String, + val accountId: Long?, + val name: String, + val writable: Boolean, + val visible: Boolean, + val isDefault: Boolean, + val count: Int, + val state: String, + val errorCode: String?, + val syncedAt: Long?, +) { + val local: Boolean get() = source == SOURCE_LOCAL + + companion object { + const val SOURCE_LOCAL = "local" + const val SOURCE_GRAPH = "graph" + const val SOURCE_CARDDAV = "carddav" + } +} + +/** A distribution list of the person (GateControl only, across sources). */ +data class MGroup(val id: Long, val name: String, val color: String?, val count: Int) + +data class MCardPrefs( + /** family | given */ + val sort: String, + /** given_family | family_given */ + val show: String, + val defaultAddressbookId: Long?, + val birthdays: Boolean, +) { + companion object { + val DEFAULT = MCardPrefs("family", "given_family", null, true) + } +} + +/** `GET addressbooks`. */ +data class MBooks(val items: List, val groups: List, val prefs: MCardPrefs) { + fun book(id: Long?): MAddressbook? = id?.let { b -> items.firstOrNull { it.id == b } } +} + +/** A contact in a list (`CardSummary`). */ +data class MCardSummary( + val id: Long, + val addressbookId: Long, + val name: String, + val sortKey: String, + /** `A` … `Z` or `#` */ + val letter: String, + val company: String?, + val title: String?, + val emails: List, + val phone: String?, + val photo: Boolean, + val photoEtag: String?, + val groups: List, + val linked: List, + val gcUserId: Long?, + val updatedAt: Long?, +) + +/** `GET cards`. */ +data class MCardPage(val items: List, val next: String?, val version: Long, val deleted: List) + +data class MCardName( + val display: String = "", + val given: String = "", + val family: String = "", + val middle: String? = null, + val prefix: String? = null, + val suffix: String? = null, + val nickname: String? = null, +) + +data class MCardEmail(val value: String, /** home | work | other */ val type: String = "other", val primary: Boolean = false) + +data class MCardPhone(val value: String, /** mobile | home | work | fax | other */ val type: String = "mobile") + +data class MCardAddress( + /** home | work | other */ + val type: String = "home", + val street: String = "", + val postalCode: String = "", + val city: String = "", + val region: String = "", + val country: String = "", +) { + val empty: Boolean get() = listOf(street, postalCode, city, region, country).all { it.isBlank() } +} + +data class MCardLink(val id: Long, val addressbookId: Long, val source: String) + +/** One contact (`Card`); also the fields of a new one and of `cards/draft` (then [id] is 0). */ +data class MCard( + val id: Long = 0, + val addressbookId: Long = 0, + val source: String = MAddressbook.SOURCE_LOCAL, + val readOnly: Boolean = false, + val etag: String? = null, + val name: MCardName = MCardName(), + val emails: List = emptyList(), + val phones: List = emptyList(), + val company: String? = null, + val department: String? = null, + val title: String? = null, + val addresses: List = emptyList(), + /** `YYYY-MM-DD` or `--MM-DD` (year unknown) */ + val birthday: String? = null, + val note: String = "", + val urls: List = emptyList(), + val photoEtag: String? = null, + val photoMime: String? = null, + val groups: List = emptyList(), + val linked: List = emptyList(), + val gcUserId: Long? = null, + val updatedAt: Long? = null, +) { + val label: String get() = name.display.ifBlank { listOf(name.given, name.family).filter { it.isNotBlank() }.joinToString(" ") } + .ifBlank { company ?: emails.firstOrNull()?.value ?: "" } + + val photo: Boolean get() = photoEtag != null + + companion object { + val EMAIL_TYPES = listOf("work", "home", "other") + val PHONE_TYPES = listOf("mobile", "work", "home", "fax", "other") + val ADDRESS_TYPES = listOf("home", "work", "other") + } +} + +/** `GET cards/lookup?email=` (the sender's page). */ +data class MLookup(val card: MCard?, val personUserId: Long?, val personName: String?, val recentName: String?, val sent: Int, val received: Int, val lastAt: Long?) + +/** `GET cards//activity`. */ +data class MActivity(val mails: List, val events: List) + +/** `GET cards/draft`: the fields from a mail and the contact that has the address already. */ +data class MCardDraft(val draft: MCard, val existing: MCard?) + +/** `GET cards/duplicates`: contacts that may be one person. */ +data class MDuplicate(val key: String, /** email | phone | name */ val reason: String, val cards: List) + +data class MGroupMember(val id: Long, val contactId: Long?, val name: String, val email: String?) + +/** `GET groups/`. */ +data class MGroupDetail(val group: MGroup, val members: List) + +/** `POST cards/import`. */ +data class MImport(val imported: Int, val updated: Int, val skipped: Int) + +/** `GET cards/export`: the vCard file. */ +class MExport(val name: String, val mime: String, val bytes: ByteArray) + +/** A contact's photo (`GET cards//photo`). */ +class MPhoto(val mime: String, val bytes: ByteArray) + +/** Realtime event `contacts`: `changed` (an address book, [contactIds] when known), `addressbooks`, `groups`. */ +data class MContactsChanged(val kind: String, val addressbookId: Long? = null, val contactIds: List = emptyList()) { + companion object { + const val CHANGED = "changed" + const val ADDRESSBOOKS = "addressbooks" + const val GROUPS = "groups" + } +} + +/** How merging picks the single fields (`pick`): the contact whose value stays. */ +data class MMergePick( + val name: Long? = null, + val company: Long? = null, + val title: Long? = null, + val birthday: Long? = null, + val photo: Long? = null, +) diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt index a0475f18..38dcab97 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt @@ -118,6 +118,7 @@ object MailJson { clientId = ms?.str("clientId")?.trim()?.takeIf { it.isNotEmpty() && it.length <= 200 }, authority = ms?.str("authority")?.trimEnd('/')?.takeIf { it.startsWith("https://") } ?: MailMicrosoft.DEFAULT_AUTHORITY, scopes = ms?.arr("scopes")?.strings()?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() } ?: MailMicrosoft.DEFAULT_SCOPES, + adminConsentUrl = ms?.str("adminConsentUrl")?.trim()?.takeIf { it.startsWith("https://") && it.length <= 2000 }, ), accounts = o.arr("accounts")?.objects()?.mapNotNull(::account).orEmpty(), unreadAll = unread?.int("all")?.coerceAtLeast(0) ?: 0, @@ -158,6 +159,9 @@ object MailJson { val mime = av.str("mime")?.lowercase()?.substringBefore(';')?.trim()?.takeIf { PICTURE_MIME.matches(it) } ?: return@let null MAvatar(media, mime) }, + calendar = MailPimJson.pimSide(o.obj("calendar")), + contacts = MailPimJson.pimSide(o.obj("contacts")), + missingScopes = o.arr("missingScopes")?.strings()?.filter { it.isNotBlank() }.orEmpty(), ) } @@ -267,9 +271,12 @@ object MailJson { tab = o.str("tab")?.takeIf { it == MSummary.TAB_OTHER } ?: MSummary.TAB_RELEVANT, savedToDrive = o.bool("savedToDrive") ?: false, aiSummary = o.obj("ai")?.str("summary")?.takeIf { it.isNotBlank() }, + invite = o.str("invite")?.takeIf { it in INVITES }, ) } + private val INVITES = setOf("request", "cancel", "reply", "counter") + fun summaries(arr: JsonArray?): List = arr?.objects()?.mapNotNull(::summary).orEmpty() fun page(o: JsonObject): MPage? { @@ -342,6 +349,7 @@ object MailJson { attachments = o.arr("attachments")?.objects()?.mapNotNull(::attachment).orEmpty(), unsubscribe = unsub, bodyMissing = o.bool("bodyMissing") ?: false, + invite = o.obj("invite")?.let(MailPimJson::invitation), ) } @@ -452,9 +460,17 @@ object MailJson { ) } + /** Suggestions: entries with an address, and groups (since Mail 1.2.0: no address, their members). */ fun contacts(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { c -> + val source = c.str("source") ?: MContact.SOURCE_RECENT + val name = c.str("name")?.trim()?.takeIf { it.isNotEmpty() } + if (source == MContact.SOURCE_GROUP) { + val members = addresses(c.arr("members")).filter { it.email.contains('@') }.take(50) + if (members.isEmpty()) return@mapNotNull null + return@mapNotNull MContact(name ?: members.size.toString(), "", source, groupId = c.id("groupId"), members = members) + } val email = c.str("email")?.trim()?.takeIf { it.contains('@') } ?: return@mapNotNull null - MContact(c.str("name")?.trim()?.takeIf { it.isNotEmpty() }, email, c.str("source") ?: "recent") + MContact(name, email, source, contactId = c.id("contactId"), photo = c.bool("photo") ?: false) } // ─── Drive ────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt b/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt index 1f3a28eb..e54d13c7 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt @@ -22,6 +22,9 @@ object MailMicrosoft { const val HOST = "com.gatecontrol.client" const val DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common" val DEFAULT_SCOPES = listOf("openid", "profile", "email", "offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send") + /** Since Mail 1.2.0: the rights of calendars and contacts (`Account.missingScopes`). */ + const val CALENDAR_SCOPE = "Calendars.ReadWrite" + const val CONTACTS_SCOPE = "Contacts.ReadWrite" private val random = SecureRandom() private val VERIFIER = Regex("^[A-Za-z0-9._~-]{43,128}$") diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt index 32c14285..c3c5b4af 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -53,7 +53,13 @@ data class MMe( data class MLimits(val accountsMax: Int, val quotaBytes: Long, val usedBytes: Long, val retentionMonths: Int) /** Microsoft sign-in (public client with PKCE): no client id → not set up yet. */ -data class MMicrosoft(val clientId: String?, val authority: String, val scopes: List) +data class MMicrosoft( + val clientId: String?, + val authority: String, + val scopes: List, + /** Since Mail 1.2.0: the link a company's administrator opens to allow the new rights (null: none). */ + val adminConsentUrl: String? = null, +) /** The person's list settings (`PUT settings`). */ data class MSettings(val threads: Boolean, val swipeLeft: String, val swipeRight: String) { @@ -89,6 +95,11 @@ data class MAccount( val signature: String?, /** The account's picture at the provider (Microsoft only, since Mail 1.1.2), loaded as the plugin's media. */ val avatar: MAvatar? = null, + /** Since Mail 1.2.0: the account's calendar and address book side (null from older servers). */ + val calendar: MPimSide? = null, + val contacts: MPimSide? = null, + /** Microsoft scopes the account still lacks for calendar and contacts (`Calendars.ReadWrite` …). */ + val missingScopes: List = emptyList(), ) { val label: String get() = name?.takeIf { it.isNotBlank() } ?: email @@ -186,6 +197,8 @@ data class MSummary( val tab: String, val savedToDrive: Boolean, val aiSummary: String?, + /** Since Mail 1.2.0: request | cancel | reply | counter (the label "Einladung"), null for other mails. */ + val invite: String? = null, ) { companion object { const val TAB_RELEVANT = "relevant" @@ -247,6 +260,8 @@ data class MMessage( * live event `mail` with this mail's id (or its thread's) tells when it is there. */ val bodyMissing: Boolean = false, + /** Since Mail 1.2.0: the invitation the mail carries (the card above the text). */ + val invite: MInvitation? = null, ) { val id: Long get() = summary.id } @@ -323,9 +338,25 @@ data class MOutboxItem( val error: String?, ) -data class MContact(val name: String?, val email: String, val source: String) { +/** + * A suggestion of `GET contacts?q=`: an own contact (`contact`, since Mail 1.2.0, with [contactId]), a group + * (`group`: [email] empty, its [members] go in), a GateControl person (`person`) or someone written with lately. + */ +data class MContact( + val name: String?, + val email: String, + val source: String, + val contactId: Long? = null, + val photo: Boolean = false, + val groupId: Long? = null, + val members: List = emptyList(), +) { + val group: Boolean get() = source == SOURCE_GROUP + companion object { - /** A contact of the person (the coming contacts plugin); `recent`: someone written with lately. */ + const val SOURCE_CONTACT = "contact" + const val SOURCE_GROUP = "group" + /** Someone of this GateControl with a Mail account; `recent`: someone written with lately. */ const val SOURCE_PERSON = "person" const val SOURCE_RECENT = "recent" } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt new file mode 100644 index 00000000..d0d7cf5b --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt @@ -0,0 +1,779 @@ +package com.gatecontrol.android.mail + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject + +/** + * The answers of Mail's calendars and contacts (contract C3, since Mail 1.2.0), read from the Gson tree like + * [MailJson] (pure, unit-tested, never by reflection, so R8 cannot change what is read): unknown fields are ignored, + * an entry missing what it needs is left out, a list keeps what it can read. Also the request bodies. + */ +object MailPimJson { + + private val COLOR = Regex("^#[0-9A-Fa-f]{6}$") + private val DATE = Regex("^\\d{4}-\\d{2}-\\d{2}$") + private val BIRTHDAY = Regex("^(\\d{4}|-)-\\d{2}-\\d{2}$") + private val ZONE = Regex("^[A-Za-z0-9_+\\-]+(/[A-Za-z0-9_+\\-]+){0,3}$") + private const val FALLBACK_COLOR = "#46E0A0" + + // ─── helpers ──────────────────────────────────────────────────── + + private fun JsonObject.el(key: String): JsonElement? = get(key)?.takeIf { it !is JsonNull } + + private fun JsonObject.str(key: String): String? = + el(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() } + + private fun JsonObject.text(key: String): String? = str(key)?.trim()?.takeIf { it.isNotEmpty() } + + private fun JsonObject.long(key: String): Long? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p -> + val prim = p.asJsonPrimitive + when { + prim.isNumber -> runCatching { prim.asDouble }.getOrNull()?.takeIf { !it.isNaN() && !it.isInfinite() }?.toLong() + prim.isString -> prim.asString.trim().toLongOrNull() + else -> null + } + } + + private fun JsonObject.int(key: String): Int? = long(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt() + + private fun JsonObject.bool(key: String): Boolean? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p -> + val prim = p.asJsonPrimitive + when { + prim.isBoolean -> prim.asBoolean + prim.isNumber -> prim.asInt != 0 + prim.isString -> when (prim.asString) { "true", "1" -> true; "false", "0" -> false; else -> null } + else -> null + } + } + + private fun JsonObject.obj(key: String): JsonObject? = el(key)?.takeIf { it.isJsonObject }?.asJsonObject + + private fun JsonObject.arr(key: String): JsonArray? = el(key)?.takeIf { it.isJsonArray }?.asJsonArray + + private fun JsonArray.objects(): List = mapNotNull { e -> e.takeIf { it.isJsonObject }?.asJsonObject } + + private fun JsonArray.strings(): List = mapNotNull { e -> e.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() } } + + private fun JsonArray.longs(): List = mapNotNull { e -> + e.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString.trim().toDouble().toLong() }.getOrNull() }?.takeIf { it > 0 } + } + + private fun JsonArray.ints(): List = mapNotNull { e -> + e.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString.trim().toDouble().toInt() }.getOrNull() } + } + + private fun JsonObject.id(key: String): Long? = long(key)?.takeIf { it > 0 } + + private fun JsonObject.date(key: String): String? = str(key)?.takeIf { DATE.matches(it) } + + private fun JsonObject.color(key: String): String? = str(key)?.takeIf { COLOR.matches(it) }?.uppercase() + + private fun JsonObject.zone(key: String): String? = str(key)?.takeIf { it.length <= 64 && ZONE.matches(it) } + + private fun JsonObject.cursor(key: String): String? = + el(key)?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotEmpty() && it.length <= 512 } + + /** A link the app may open: `https:` (or `http:`) only. */ + private fun JsonObject.link(key: String): String? = str(key)?.trim()?.takeIf { it.startsWith("https://", true) || it.startsWith("http://", true) } + + // ─── account side (C2) ────────────────────────────────────────── + + fun pimSide(o: JsonObject?): MPimSide? { + o ?: return null + return MPimSide( + sync = o.bool("sync") ?: true, + state = o.str("state") ?: MPimSide.STATE_OK, + readOnly = o.bool("readOnly") ?: false, + server = o.text("server"), + ) + } + + // ─── calendars ────────────────────────────────────────────────── + + fun calendar(o: JsonObject): MCalendar? { + val id = o.id("id") ?: return null + return MCalendar( + id = id, + source = o.str("source") ?: MCalendar.SOURCE_LOCAL, + accountId = o.id("accountId"), + name = o.text("name") ?: return null, + color = o.color("color") ?: FALLBACK_COLOR, + kind = o.str("kind") ?: "other", + writable = o.bool("writable") ?: false, + visible = o.bool("visible") ?: true, + notify = o.bool("notify") ?: true, + isDefault = o.bool("isDefault") ?: false, + canInvite = o.bool("canInvite") ?: false, + canOnline = o.bool("canOnline") ?: false, + maxReminders = (o.int("maxReminders") ?: 3).coerceIn(0, 3), + state = o.str("state") ?: MPimSide.STATE_OK, + errorCode = o.obj("error")?.str("code"), + syncedAt = o.long("syncedAt"), + version = o.long("version") ?: 0L, + ) + } + + fun overlay(o: JsonObject): MOverlay? { + val id = o.text("id")?.takeIf { it.length <= 80 } ?: return null + return MOverlay(id, o.str("source") ?: id.substringBefore(':'), o.text("name") ?: id, o.color("color") ?: "#8C96A5", o.bool("visible") ?: true) + } + + fun calPrefs(o: JsonObject?): MCalPrefs { + o ?: return MCalPrefs.DEFAULT + val d = MCalPrefs.DEFAULT + return MCalPrefs( + defaultCalendarId = o.id("defaultCalendarId"), + view = o.str("view")?.takeIf { it in MCalPrefs.VIEWS } ?: d.view, + weekStart = o.int("weekStart")?.takeIf { it == 0 || it == 1 } ?: d.weekStart, + workWeek = o.bool("workWeek") ?: d.workWeek, + timeZone = o.zone("timeZone"), + reminders = o.bool("reminders") ?: d.reminders, + defaultReminder = if (o.has("defaultReminder")) o.int("defaultReminder")?.coerceAtLeast(0) else d.defaultReminder, + allDayReminder = if (o.has("allDayReminder")) o.int("allDayReminder")?.coerceAtLeast(0) else d.allDayReminder, + ) + } + + /** `GET calendars`. */ + fun calendars(o: JsonObject): MCalendars? { + val list = o.arr("calendars") ?: return null + return MCalendars( + calendars = list.objects().mapNotNull(::calendar), + overlays = o.arr("overlays")?.objects()?.mapNotNull(::overlay).orEmpty(), + prefs = calPrefs(o.obj("prefs")), + consent = o.arr("consent")?.objects()?.mapNotNull { c -> + MConsent(c.id("accountId") ?: return@mapNotNull null, c.arr("missing")?.strings().orEmpty()) + }.orEmpty(), + ) + } + + fun calendarAnswer(o: JsonObject): MCalendar? = o.obj("calendar")?.let(::calendar) + + fun overlayAnswer(o: JsonObject): MOverlay? = o.obj("overlay")?.let(::overlay) + + fun prefsAnswer(o: JsonObject): MCalPrefs? = o.obj("prefs")?.let(::calPrefs) + + // ─── events ───────────────────────────────────────────────────── + + fun online(o: JsonObject?): MOnline? { + o ?: return null + val url = o.link("url") ?: return null + return MOnline(url, o.str("provider") ?: "other") + } + + fun occurrence(o: JsonObject): MOccurrence? { + val eventId = o.id("eventId") ?: return null + val start = o.long("start") ?: return null + val end = (o.long("end") ?: start).coerceAtLeast(start) + val occ = o.str("occurrence")?.takeIf { it.isNotEmpty() && it.length <= 40 } + val allDay = o.bool("allDay") ?: false + return MOccurrence( + key = o.str("key")?.takeIf { it.isNotEmpty() } ?: (if (occ != null) "$eventId:$occ" else "$eventId"), + eventId = eventId, + calendarId = o.id("calendarId") ?: 0L, + occurrence = occ, + title = o.str("title") ?: "", + start = start, + end = end, + allDay = allDay, + startDate = o.date("startDate"), + endDate = o.date("endDate") ?: o.date("startDate"), + timeZone = o.zone("timeZone"), + location = o.text("location"), + online = online(o.obj("online")), + status = o.str("status") ?: MOccurrence.STATUS_CONFIRMED, + busy = o.str("busy") ?: "busy", + myResponse = o.str("myResponse"), + recurring = o.bool("recurring") ?: false, + // a full Event has the list here (it replaces the count) + attendees = o.int("attendees")?.coerceAtLeast(0) ?: o.arr("attendees")?.size() ?: 0, + private = o.bool("private") ?: false, + reminder = o.int("reminder")?.takeIf { it >= 0 }, + ) + } + + fun overlayItem(o: JsonObject): MOverlayItem? { + val key = o.text("key") ?: return null + val start = o.long("start") ?: return null + return MOverlayItem( + key = key, + overlayId = o.text("overlayId") ?: key.substringBefore(':'), + title = o.str("title") ?: "", + start = start, + end = (o.long("end") ?: start).coerceAtLeast(start), + allDay = o.bool("allDay") ?: false, + startDate = o.date("startDate"), + endDate = o.date("endDate") ?: o.date("startDate"), + location = o.text("location"), + ) + } + + /** `GET events`. */ + fun events(o: JsonObject): MEvents? { + val items = o.arr("items") ?: return null + return MEvents( + items = items.objects().mapNotNull(::occurrence), + overlays = o.arr("overlays")?.objects()?.mapNotNull(::overlayItem).orEmpty(), + pending = o.arr("pending")?.longs().orEmpty(), + ) + } + + private fun organizer(o: JsonObject?): MOrganizer? { + o ?: return null + val email = o.text("email") ?: return null + return MOrganizer(o.str("name")?.trim().orEmpty(), email, o.bool("self") ?: false, o.id("contactId")) + } + + fun attendee(o: JsonObject): MAttendee? { + val email = o.text("email") ?: return null + return MAttendee( + name = o.str("name")?.trim().orEmpty(), + email = email, + role = o.str("role")?.takeIf { it in setOf(MAttendee.ROLE_REQUIRED, MAttendee.ROLE_OPTIONAL, MAttendee.ROLE_RESOURCE) } ?: MAttendee.ROLE_REQUIRED, + response = o.str("response") ?: MOccurrence.RESPONSE_NEEDS_ACTION, + self = o.bool("self") ?: false, + contactId = o.id("contactId"), + ) + } + + fun rule(o: JsonObject): MRule? { + val freq = o.str("freq")?.lowercase()?.takeIf { it in MRule.FREQS } ?: return null + return MRule( + freq = freq, + interval = (o.int("interval") ?: 1).coerceIn(1, 999), + byDay = o.arr("byDay")?.strings()?.map { it.uppercase() }?.filter { it in MRule.DAYS }?.takeIf { it.isNotEmpty() }, + byMonthDay = o.arr("byMonthDay")?.ints()?.filter { it != 0 && it in -31..31 }?.takeIf { it.isNotEmpty() }, + bySetPos = o.int("bySetPos")?.takeIf { it != 0 && it in -5..5 }, + until = o.date("until"), + count = o.int("count")?.takeIf { it > 0 }, + ) + } + + private fun recurrence(o: JsonObject?): MRecurrence? { + o ?: return null + val rrule = o.str("rrule")?.takeIf { it.isNotBlank() } ?: return null + val rule = rule(o) ?: MailPimLogic.parseRule(rrule) ?: return null + return MRecurrence( + rrule = rrule, + editable = o.bool("editable") ?: false, + rule = rule, + exceptions = o.arr("exceptions")?.strings().orEmpty(), + deleted = o.arr("deleted")?.strings().orEmpty(), + ) + } + + /** `GET events/` (the Event at the root) or the `current` of a `conflict`. */ + fun event(o: JsonObject): MEvent? { + val occ = occurrence(o) ?: return null + return MEvent( + occ = occ, + description = o.str("description") ?: "", + descriptionHtml = o.str("descriptionHtml")?.takeIf { it.isNotBlank() }, + organizer = organizer(o.obj("organizer")), + attendees = o.arr("attendees")?.objects()?.mapNotNull(::attendee).orEmpty(), + reminders = o.arr("reminders")?.ints()?.filter { it >= 0 }?.take(3).orEmpty(), + recurrence = recurrence(o.obj("recurrence")), + seriesId = o.id("seriesId"), + readOnly = o.bool("readOnly") ?: true, + canRespond = o.bool("canRespond") ?: false, + canEdit = o.bool("canEdit") ?: false, + canDelete = o.bool("canDelete") ?: false, + categories = o.arr("categories")?.strings().orEmpty(), + invitationId = o.id("invitationId"), + url = o.link("url"), + createdAt = o.long("createdAt"), + updatedAt = o.long("updatedAt"), + etag = o.str("etag")?.takeIf { it.length <= 200 }, + series = o.obj("series")?.let { x -> + val a = x.long("start") ?: return@let null + MSeries(a, (x.long("end") ?: a).coerceAtLeast(a), x.bool("allDay") ?: occ.allDay, x.date("startDate"), x.date("endDate") ?: x.date("startDate")) + }, + ) + } + + /** `{ event }` (`POST events//respond`). */ + fun eventAnswer(o: JsonObject): MEvent? = o.obj("event")?.let(::event) + + /** `{ event, sent: { mail, internal } }`. */ + fun eventSaved(o: JsonObject): MEventSaved? { + val ev = o.obj("event")?.let(::event) ?: return null + val sent = o.obj("sent") + return MEventSaved(ev, sent?.int("mail")?.coerceAtLeast(0) ?: 0, sent?.int("internal")?.coerceAtLeast(0) ?: 0) + } + + /** The newer state a `409 conflict` carries (`error.current`): an Event. */ + fun conflictEvent(error: JsonObject?): MEvent? = (error?.obj("error") ?: error)?.obj("current")?.let(::event) + + /** The newer state a `409 conflict` of a contact carries (`error.current`): a Card. */ + fun conflictCard(error: JsonObject?): MCard? = (error?.obj("error") ?: error)?.obj("current")?.let(::card) + + // ─── invitations ──────────────────────────────────────────────── + + fun invitation(o: JsonObject): MInvitation? { + val id = o.id("id") ?: return null + val start = o.long("start") ?: return null + val organizer = organizer(o.obj("organizer")) ?: MOrganizer("", o.obj("from")?.text("email") ?: "", false, null) + val from = o.obj("from")?.let { f -> MAddress(f.text("name"), f.text("email") ?: organizer.email) } ?: MAddress(organizer.name.ifBlank { null }, organizer.email) + val counts = o.obj("counts") + val proposed = o.obj("proposed") + val summary = o.obj("recurrence")?.obj("summary") + return MInvitation( + id = id, + kind = o.str("kind") ?: "mail", + messageId = o.id("messageId"), + accountId = o.id("accountId"), + method = o.str("method") ?: MInvitation.METHOD_REQUEST, + state = o.str("state") ?: MInvitation.STATE_OPEN, + uid = o.str("uid") ?: "", + title = o.str("title") ?: "", + start = start, + end = (o.long("end") ?: start).coerceAtLeast(start), + allDay = o.bool("allDay") ?: false, + startDate = o.date("startDate"), + endDate = o.date("endDate") ?: o.date("startDate"), + timeZone = o.zone("timeZone"), + location = o.text("location"), + online = online(o.obj("online")), + recurrenceDe = summary?.text("de"), + recurrenceEn = summary?.text("en"), + organizer = organizer, + from = from, + attendees = o.arr("attendees")?.objects()?.mapNotNull(::attendee).orEmpty(), + counts = MInviteCounts( + counts?.int("accepted")?.coerceAtLeast(0) ?: 0, + counts?.int("tentative")?.coerceAtLeast(0) ?: 0, + counts?.int("declined")?.coerceAtLeast(0) ?: 0, + counts?.int("needs_action")?.coerceAtLeast(0) ?: 0, + ), + myResponse = o.str("myResponse"), + comment = o.text("comment"), + proposedStart = proposed?.long("start"), + proposedEnd = proposed?.long("end"), + eventId = o.id("eventId"), + calendarId = o.id("calendarId"), + calendarChoices = o.arr("calendarChoices")?.longs().orEmpty(), + day = o.arr("day")?.objects()?.mapNotNull(::occurrence).orEmpty(), + conflicts = o.arr("conflicts")?.objects()?.mapNotNull { c -> + val s = c.long("start") ?: return@mapNotNull null + MConflict(c.str("title") ?: "", s, c.long("end") ?: s, c.id("calendarId")) + }.orEmpty(), + ) + } + + fun invitations(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull(::invitation) + + /** `{ invitation, event }`. */ + fun inviteAnswer(o: JsonObject): MInviteAnswer? { + val inv = o.obj("invitation")?.let(::invitation) ?: return null + return MInviteAnswer(inv, o.obj("event")?.let(::event)) + } + + // ─── address books, groups ────────────────────────────────────── + + fun addressbook(o: JsonObject): MAddressbook? { + val id = o.id("id") ?: return null + return MAddressbook( + id = id, + source = o.str("source") ?: MAddressbook.SOURCE_LOCAL, + accountId = o.id("accountId"), + name = o.text("name") ?: return null, + writable = o.bool("writable") ?: false, + visible = o.bool("visible") ?: true, + isDefault = o.bool("isDefault") ?: false, + count = o.int("count")?.coerceAtLeast(0) ?: 0, + state = o.str("state") ?: MPimSide.STATE_OK, + errorCode = o.obj("error")?.str("code"), + syncedAt = o.long("syncedAt"), + ) + } + + fun group(o: JsonObject): MGroup? { + val id = o.id("id") ?: return null + return MGroup(id, o.text("name") ?: return null, o.color("color"), o.int("count")?.coerceAtLeast(0) ?: 0) + } + + fun cardPrefs(o: JsonObject?): MCardPrefs { + o ?: return MCardPrefs.DEFAULT + return MCardPrefs( + sort = o.str("sort")?.takeIf { it == "family" || it == "given" } ?: "family", + show = o.str("show")?.takeIf { it == "given_family" || it == "family_given" } ?: "given_family", + defaultAddressbookId = o.id("defaultAddressbookId"), + birthdays = o.bool("birthdays") ?: true, + ) + } + + /** `GET addressbooks`. */ + fun books(o: JsonObject): MBooks? { + val items = o.arr("items") ?: return null + return MBooks(items.objects().mapNotNull(::addressbook), o.arr("groups")?.objects()?.mapNotNull(::group).orEmpty(), cardPrefs(o.obj("prefs"))) + } + + fun bookAnswer(o: JsonObject): MAddressbook? = o.obj("addressbook")?.let(::addressbook) + + fun cardPrefsAnswer(o: JsonObject): MCardPrefs? = o.obj("prefs")?.let(::cardPrefs) + + fun groupAnswer(o: JsonObject): MGroup? = o.obj("group")?.let(::group) + + /** `GET groups/` / `POST groups//members`. */ + fun groupDetail(o: JsonObject): MGroupDetail? { + val g = o.obj("group")?.let(::group) ?: return null + return MGroupDetail(g, o.arr("members")?.objects()?.mapNotNull { m -> + val id = m.id("id") ?: return@mapNotNull null + MGroupMember(id, m.id("contactId"), m.str("name")?.trim().orEmpty(), m.text("email")) + }.orEmpty()) + } + + // ─── contacts ─────────────────────────────────────────────────── + + fun cardSummary(o: JsonObject): MCardSummary? { + val id = o.id("id") ?: return null + val name = o.str("name")?.trim().orEmpty() + val sortKey = o.str("sortKey") ?: name.uppercase() + return MCardSummary( + id = id, + addressbookId = o.id("addressbookId") ?: 0L, + name = name, + sortKey = sortKey, + letter = o.str("letter")?.takeIf { it.length == 1 } ?: MailPimLogic.letterOf(sortKey), + company = o.text("company"), + title = o.text("title"), + emails = o.arr("emails")?.strings()?.filter { it.contains('@') }?.take(3).orEmpty(), + phone = o.text("phone"), + photo = o.bool("photo") ?: false, + photoEtag = o.str("photoEtag")?.takeIf { it.isNotEmpty() && it.length <= 200 }, + groups = o.arr("groups")?.longs().orEmpty(), + linked = o.arr("linked")?.longs().orEmpty(), + gcUserId = o.id("gcUserId"), + updatedAt = o.long("updatedAt"), + ) + } + + /** `GET cards`. */ + fun cardPage(o: JsonObject): MCardPage? { + val items = o.arr("items") ?: return null + return MCardPage(items.objects().mapNotNull(::cardSummary), o.cursor("next"), o.long("version") ?: 0L, o.arr("deleted")?.longs().orEmpty()) + } + + fun card(o: JsonObject): MCard? { + val n = o.obj("name") + return MCard( + id = o.id("id") ?: 0L, + addressbookId = o.id("addressbookId") ?: 0L, + source = o.str("source") ?: MAddressbook.SOURCE_LOCAL, + readOnly = o.bool("readOnly") ?: false, + etag = o.str("etag")?.takeIf { it.length <= 200 }, + name = MCardName( + display = n?.str("display")?.trim().orEmpty(), + given = n?.str("given")?.trim().orEmpty(), + family = n?.str("family")?.trim().orEmpty(), + middle = n?.text("middle"), + prefix = n?.text("prefix"), + suffix = n?.text("suffix"), + nickname = n?.text("nickname"), + ), + emails = o.arr("emails")?.objects()?.mapNotNull { e -> + val v = e.text("value")?.takeIf { it.contains('@') } ?: return@mapNotNull null + MCardEmail(v, e.str("type")?.takeIf { it in MCard.EMAIL_TYPES } ?: "other", e.bool("primary") ?: false) + }.orEmpty(), + phones = o.arr("phones")?.objects()?.mapNotNull { p -> + MCardPhone(p.text("value") ?: return@mapNotNull null, p.str("type")?.takeIf { it in MCard.PHONE_TYPES } ?: "other") + }.orEmpty(), + company = o.text("company"), + department = o.text("department"), + title = o.text("title"), + addresses = o.arr("addresses")?.objects()?.map { a -> + MCardAddress( + type = a.str("type")?.takeIf { it in MCard.ADDRESS_TYPES } ?: "other", + street = a.str("street")?.trim().orEmpty(), + postalCode = a.str("postalCode")?.trim().orEmpty(), + city = a.str("city")?.trim().orEmpty(), + region = a.str("region")?.trim().orEmpty(), + country = a.str("country")?.trim().orEmpty(), + ) + }?.filter { !it.empty }.orEmpty(), + birthday = o.str("birthday")?.takeIf { BIRTHDAY.matches(it) }, + note = o.str("note") ?: "", + urls = o.arr("urls")?.strings()?.filter { it.startsWith("https://", true) || it.startsWith("http://", true) }.orEmpty(), + photoEtag = o.obj("photo")?.str("etag")?.takeIf { it.isNotEmpty() }, + photoMime = o.obj("photo")?.str("mime"), + groups = o.arr("groups")?.longs().orEmpty(), + linked = o.arr("linked")?.objects()?.mapNotNull { l -> + MCardLink(l.id("id") ?: return@mapNotNull null, l.id("addressbookId") ?: 0L, l.str("source") ?: MAddressbook.SOURCE_LOCAL) + }.orEmpty(), + gcUserId = o.id("gcUserId"), + updatedAt = o.long("updatedAt"), + ) + } + + /** `GET cards/` (a Card with an id). */ + fun fullCard(o: JsonObject): MCard? = card(o).takeIf { it?.id ?: 0L > 0 } + + /** `{ card }`. */ + fun cardAnswer(o: JsonObject): MCard? = o.obj("card")?.let(::card)?.takeIf { it.id > 0 } + + /** `GET cards/lookup?email=`. */ + fun lookup(o: JsonObject): MLookup { + val person = o.obj("person") + val recent = o.obj("recent") + return MLookup( + card = o.obj("card")?.let(::card)?.takeIf { it.id > 0 }, + personUserId = person?.id("userId"), + personName = person?.text("name"), + recentName = recent?.text("name"), + sent = recent?.int("sent")?.coerceAtLeast(0) ?: 0, + received = recent?.int("received")?.coerceAtLeast(0) ?: 0, + lastAt = recent?.long("lastAt"), + ) + } + + /** `GET cards//activity`. */ + fun activity(o: JsonObject): MActivity = MActivity( + mails = MailJson.summaries(o.arr("mails")), + events = o.arr("events")?.objects()?.mapNotNull(::occurrence).orEmpty(), + ) + + /** `GET cards/draft`. */ + fun cardDraft(o: JsonObject): MCardDraft? { + val d = o.obj("draft")?.let(::card) ?: return null + return MCardDraft(d.copy(id = 0, etag = null), o.obj("existing")?.let(::card)?.takeIf { it.id > 0 }) + } + + /** `GET cards/duplicates`. */ + fun duplicates(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { d -> + val cards = d.arr("cards")?.objects()?.mapNotNull(::cardSummary).orEmpty() + if (cards.size < 2) return@mapNotNull null + MDuplicate(d.str("key") ?: cards.joinToString("-") { it.id.toString() }, d.str("reason") ?: "name", cards) + } + + fun importAnswer(o: JsonObject): MImport? { + if (!o.has("imported")) return null + return MImport(o.int("imported")?.coerceAtLeast(0) ?: 0, o.int("updated")?.coerceAtLeast(0) ?: 0, o.int("skipped")?.coerceAtLeast(0) ?: 0) + } + + /** `GET cards/export` → the file (Base64 decoded); an empty address book gives an empty file. */ + fun export(o: JsonObject): MExport? { + val data = o.str("data") ?: return null + val bytes = if (data.isEmpty()) ByteArray(0) else runCatching { java.util.Base64.getMimeDecoder().decode(data) }.getOrNull() ?: return null + val name = o.str("name")?.takeIf { it.matches(Regex("^[A-Za-z0-9 ._-]{1,80}\\.vcf$")) } ?: "kontakte.vcf" + return MExport(name, "text/vcard", bytes) + } + + /** `GET cards//photo` → JPEG or PNG only. */ + fun photo(o: JsonObject): MPhoto? { + val mime = o.str("mime")?.lowercase()?.takeIf { it == "image/jpeg" || it == "image/png" } ?: return null + val bytes = o.str("data")?.let { runCatching { java.util.Base64.getMimeDecoder().decode(it) }.getOrNull() } ?: return null + if (bytes.isEmpty() || bytes.size > 512 * 1024) return null + return MPhoto(mime, bytes) + } + + // ─── live ─────────────────────────────────────────────────────── + + /** Realtime event `calendar` (or the plugin's data under `calendar`). */ + fun calChanged(data: JsonElement?): MCalChanged? { + val o = data?.takeIf { it.isJsonObject }?.asJsonObject ?: return null + val root = o.obj("calendar") ?: o + val kind = root.str("kind")?.takeIf { it in setOf(MCalChanged.CHANGED, MCalChanged.CALENDARS, MCalChanged.INVITATION, MCalChanged.CONFLICT) } ?: return null + return MCalChanged(kind, root.id("calendarId"), root.id("accountId"), root.long("from"), root.long("to"), root.id("invitationId"), root.id("messageId"), root.id("eventId")) + } + + /** Realtime event `contacts` (or the plugin's data under `contacts`). */ + fun contactsChanged(data: JsonElement?): MContactsChanged? { + val o = data?.takeIf { it.isJsonObject }?.asJsonObject ?: return null + val root = o.obj("contacts") ?: o + val kind = root.str("kind")?.takeIf { it in setOf(MContactsChanged.CHANGED, MContactsChanged.ADDRESSBOOKS, MContactsChanged.GROUPS) } ?: return null + return MContactsChanged(kind, root.id("addressbookId"), root.arr("contactIds")?.longs()?.take(50).orEmpty()) + } + + // ─── request bodies ───────────────────────────────────────────── + + fun calendarBody(name: String, color: String?): JsonObject = JsonObject().apply { + addProperty("name", name.trim()) + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + } + + fun calendarPatch(name: String? = null, color: String? = null, visible: Boolean? = null, notify: Boolean? = null): JsonObject = JsonObject().apply { + name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + visible?.let { addProperty("visible", it) } + notify?.let { addProperty("notify", it) } + } + + fun overlayPatch(visible: Boolean? = null, color: String? = null): JsonObject = JsonObject().apply { + visible?.let { addProperty("visible", it) } + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + } + + fun subscribeBody(url: String, name: String?): JsonObject = JsonObject().apply { + addProperty("url", url.trim()) + name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } + } + + fun calSettingsBody(view: String? = null, timeZone: String? = null, defaultCalendarId: Long? = null, workWeek: Boolean? = null): JsonObject = JsonObject().apply { + view?.takeIf { it in MCalPrefs.VIEWS }?.let { addProperty("view", it) } + timeZone?.let { addProperty("timeZone", it) } + defaultCalendarId?.let { addProperty("defaultCalendarId", it) } + workWeek?.let { addProperty("workWeek", it) } + } + + fun ruleBody(r: MRule): JsonObject = JsonObject().apply { + addProperty("freq", r.freq) + addProperty("interval", r.interval.coerceIn(1, 999)) + r.byDay?.takeIf { it.isNotEmpty() }?.let { d -> add("byDay", JsonArray().apply { d.forEach { add(it) } }) } + r.byMonthDay?.takeIf { it.isNotEmpty() }?.let { d -> add("byMonthDay", JsonArray().apply { d.forEach { add(it) } }) } + r.bySetPos?.let { addProperty("bySetPos", it) } + r.until?.let { addProperty("until", it) } + r.count?.let { addProperty("count", it) } + } + + /** `POST events` / `PATCH events/`: only what is given; [scope] and [occurrence] for a series, [etag] to guard. */ + fun eventBody(x: MEventInput, scope: String? = null, occurrence: String? = null, etag: String? = null): JsonObject = JsonObject().apply { + x.calendarId?.let { addProperty("calendarId", it) } + x.title?.let { addProperty("title", it.trim()) } + x.allDay?.let { addProperty("allDay", it) } + if (x.allDay == true) { + x.startDate?.let { addProperty("startDate", it) } + x.endDate?.let { addProperty("endDate", it) } + } else { + x.start?.let { addProperty("start", it) } + x.end?.let { addProperty("end", it) } + } + x.timeZone?.let { addProperty("timeZone", it) } + x.location?.let { if (it.isBlank()) add("location", JsonNull.INSTANCE) else addProperty("location", it.trim()) } + x.description?.let { addProperty("description", it) } + if (x.onlineSet) { + when { + x.teams -> add("online", JsonObject().apply { addProperty("create", "teams") }) + !x.onlineUrl.isNullOrBlank() -> add("online", JsonObject().apply { addProperty("url", x.onlineUrl.trim()) }) + else -> add("online", JsonNull.INSTANCE) + } + } + x.reminders?.let { r -> add("reminders", JsonArray().apply { r.distinct().sorted().take(3).forEach { add(it) } }) } + if (x.recurrenceSet) add("recurrence", x.recurrence?.let(::ruleBody) ?: JsonNull.INSTANCE) + x.attendees?.let { list -> + add("attendees", JsonArray().apply { + list.forEach { a -> + add(JsonObject().apply { + addProperty("email", a.email.trim()) + a.name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } + addProperty("role", a.role) + }) + } + }) + } + x.busy?.let { addProperty("busy", it) } + x.private?.let { addProperty("private", it) } + x.sendInvites?.let { addProperty("sendInvites", it) } + scope?.let { addProperty("scope", it) } + occurrence?.let { addProperty("occurrence", it) } + etag?.let { addProperty("etag", it) } + } + + fun respondBody(response: String, comment: String?, occurrence: String? = null, proposeStart: Long? = null, proposeEnd: Long? = null, calendarId: Long? = null): JsonObject = + JsonObject().apply { + addProperty("response", response) + comment?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("comment", it.take(2000)) } + occurrence?.let { addProperty("occurrence", it) } + calendarId?.let { addProperty("calendarId", it) } + if (proposeStart != null && proposeEnd != null && proposeEnd >= proposeStart) { + addProperty("proposeStart", proposeStart) + addProperty("proposeEnd", proposeEnd) + } + } + + fun dismissBody(decline: Boolean): JsonObject = JsonObject().apply { if (decline) addProperty("decline", true) } + + /** A contact's fields for `POST cards` / `PATCH cards/` (all of them: the form edits the whole contact). */ + fun cardBody(c: MCard, addressbookId: Long? = null, etag: String? = null, groups: List? = c.groups): JsonObject = JsonObject().apply { + addressbookId?.let { addProperty("addressbookId", it) } + add("name", JsonObject().apply { + addProperty("given", c.name.given.trim()) + addProperty("family", c.name.family.trim()) + c.name.middle?.let { addProperty("middle", it) } + c.name.prefix?.let { addProperty("prefix", it) } + c.name.suffix?.let { addProperty("suffix", it) } + c.name.nickname?.let { addProperty("nickname", it) } + }) + add("emails", JsonArray().apply { + c.emails.filter { it.value.isNotBlank() }.distinctBy { it.value.trim().lowercase() }.forEach { e -> + add(JsonObject().apply { + addProperty("value", e.value.trim()) + addProperty("type", e.type) + if (e.primary) addProperty("primary", true) + }) + } + }) + add("phones", JsonArray().apply { + c.phones.filter { it.value.isNotBlank() }.forEach { p -> + add(JsonObject().apply { + addProperty("value", p.value.trim()) + addProperty("type", p.type) + }) + } + }) + fun opt(key: String, v: String?) { if (v.isNullOrBlank()) add(key, JsonNull.INSTANCE) else addProperty(key, v.trim()) } + opt("company", c.company) + opt("department", c.department) + opt("title", c.title) + add("addresses", JsonArray().apply { + c.addresses.filter { !it.empty }.forEach { a -> + add(JsonObject().apply { + addProperty("type", a.type) + addProperty("street", a.street.trim()) + addProperty("postalCode", a.postalCode.trim()) + addProperty("city", a.city.trim()) + addProperty("region", a.region.trim()) + addProperty("country", a.country.trim()) + }) + } + }) + if (c.birthday != null && BIRTHDAY.matches(c.birthday)) addProperty("birthday", c.birthday) else add("birthday", JsonNull.INSTANCE) + addProperty("note", c.note) + groups?.let { g -> add("groups", JsonArray().apply { g.distinct().forEach { add(it) } }) } + etag?.let { addProperty("etag", it) } + } + + fun bookPatch(visible: Boolean): JsonObject = JsonObject().apply { addProperty("visible", visible) } + + fun mergeBody(ids: List, primaryId: Long, pick: MMergePick?, joinNotes: Boolean): JsonObject = JsonObject().apply { + add("ids", JsonArray().apply { ids.distinct().forEach { add(it) } }) + addProperty("primaryId", primaryId) + val p = JsonObject().apply { + pick?.name?.let { addProperty("name", it) } + pick?.company?.let { addProperty("company", it) } + pick?.title?.let { addProperty("title", it) } + pick?.birthday?.let { addProperty("birthday", it) } + pick?.photo?.let { addProperty("photo", it) } + if (joinNotes) addProperty("note", "join") + } + if (p.size() > 0) add("pick", p) + } + + fun importBody(vcf: String, addressbookId: Long?): JsonObject = JsonObject().apply { + addressbookId?.let { addProperty("addressbookId", it) } + addProperty("vcf", vcf) + } + + fun groupBody(name: String, color: String? = null): JsonObject = JsonObject().apply { + addProperty("name", name.trim()) + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + } + + fun membersBody(addContacts: List = emptyList(), addEmails: List = emptyList(), remove: List = emptyList()): JsonObject = JsonObject().apply { + add("add", JsonArray().apply { + addContacts.forEach { id -> add(JsonObject().apply { addProperty("contactId", id) }) } + addEmails.forEach { a -> + add(JsonObject().apply { + addProperty("email", a.email) + a.name?.let { addProperty("name", it) } + }) + } + }) + add("remove", JsonArray().apply { remove.forEach { add(it) } }) + } + + fun photoBody(mime: String, bytes: ByteArray): JsonObject = JsonObject().apply { + addProperty("mime", mime) + addProperty("data", java.util.Base64.getEncoder().encodeToString(bytes)) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt new file mode 100644 index 00000000..431f2736 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt @@ -0,0 +1,600 @@ +package com.gatecontrol.android.mail + +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalAdjusters +import java.util.Locale + +/** + * The rules of the calendar and the contacts (pure, unit-tested): which days a view shows, where an event sits in the + * grid (overlapping ones side by side), all-day events, the rule of a series in words, time zones, reminders, the + * letters of the contact list and what merging contacts gives. + */ +object MailPimLogic { + + const val DAY_MS = 24L * 60 * 60 * 1000 + + /** At most this many days per `GET events` (the server's limit). */ + const val RANGE_DAYS = 62 + + // ─── views and ranges ─────────────────────────────────────────── + + /** The days a view shows around [anchor]: one, three, a (work) week from [weekStart] or the month's full weeks. */ + fun days(view: String, anchor: LocalDate, weekStart: Int, workWeek: Boolean = false): List = when (view) { + MCalPrefs.VIEW_DAY -> listOf(anchor) + MCalPrefs.VIEW_3DAY -> (0L..2L).map { anchor.plusDays(it) } + MCalPrefs.VIEW_WEEK -> { + val first = weekStartOf(anchor, weekStart) + val all = (0L..6L).map { first.plusDays(it) } + if (workWeek) all.filter { it.dayOfWeek != DayOfWeek.SATURDAY && it.dayOfWeek != DayOfWeek.SUNDAY } else all + } + MCalPrefs.VIEW_MONTH -> monthGrid(anchor, weekStart).flatten() + else -> (0L until 30L).map { anchor.plusDays(it) } + } + + /** The first day of the week [date] lies in ([weekStart] 1: Monday, 0: Sunday). */ + fun weekStartOf(date: LocalDate, weekStart: Int): LocalDate = + date.with(TemporalAdjusters.previousOrSame(if (weekStart == 0) DayOfWeek.SUNDAY else DayOfWeek.MONDAY)) + + /** The weeks of [anchor]'s month, each seven days from [weekStart] (the days of the months around included). */ + fun monthGrid(anchor: LocalDate, weekStart: Int): List> { + val first = anchor.withDayOfMonth(1) + val last = anchor.with(TemporalAdjusters.lastDayOfMonth()) + var day = weekStartOf(first, weekStart) + val weeks = ArrayList>() + while (!day.isAfter(last)) { + weeks += (0L..6L).map { day.plusDays(it) } + day = day.plusWeeks(1) + } + return weeks + } + + /** Where ‹ / › go from [anchor] in a view ([delta] -1 or +1). */ + fun step(view: String, anchor: LocalDate, delta: Int): LocalDate = when (view) { + MCalPrefs.VIEW_DAY -> anchor.plusDays(delta.toLong()) + MCalPrefs.VIEW_3DAY -> anchor.plusDays(3L * delta) + MCalPrefs.VIEW_WEEK -> anchor.plusWeeks(delta.toLong()) + MCalPrefs.VIEW_MONTH -> anchor.plusMonths(delta.toLong()).withDayOfMonth(1) + else -> anchor.plusDays(30L * delta) + } + + /** `[from, to)` in ms of the days [first] … [last] in [zone] (midnight to midnight). */ + fun range(first: LocalDate, last: LocalDate, zone: ZoneId): Pair = + first.atStartOfDay(zone).toInstant().toEpochMilli() to last.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + + /** The ranges to load for [from] … [to] (each at most [RANGE_DAYS] days). */ + fun chunks(first: LocalDate, last: LocalDate): List> { + val out = ArrayList>() + var a = first + while (!a.isAfter(last)) { + val b = minOf(last, a.plusDays(RANGE_DAYS - 1L)) + out += a to b + a = b.plusDays(1) + } + return out + } + + // ─── what lies on a day ───────────────────────────────────────── + + /** An entry of the grid: an occurrence or an overlay item, reduced to what placing needs. */ + data class Slot( + val key: String, + val title: String, + val start: Long, + val end: Long, + val allDay: Boolean, + val startDate: String?, + val endDate: String?, + /** `#RRGGBB` of its calendar or overlay. */ + val color: String, + /** An overlay's entry (read only). */ + val overlay: Boolean = false, + val open: Boolean = false, + val cancelled: Boolean = false, + val location: String? = null, + val occurrence: MOccurrence? = null, + ) + + /** Whether an all-day [s] covers [day] (its dates inclusive; without dates: its start and end in [zone]). */ + fun coversAllDay(s: Slot, day: LocalDate, zone: ZoneId): Boolean { + val first = s.startDate?.let(::date) ?: Instant.ofEpochMilli(s.start).atZone(zone).toLocalDate() + val last = s.endDate?.let(::date) ?: Instant.ofEpochMilli((s.end - 1).coerceAtLeast(s.start)).atZone(zone).toLocalDate() + return !day.isBefore(first) && !day.isAfter(last) + } + + /** Whether a timed [s] lies (partly) on [day] in [zone]. */ + fun onDay(s: Slot, day: LocalDate, zone: ZoneId): Boolean { + val (from, to) = range(day, day, zone) + return s.start < to && (s.end > from || (s.end == s.start && s.start >= from)) + } + + /** The all-day entries of [day]. */ + fun allDayOn(slots: List, day: LocalDate, zone: ZoneId): List = slots.filter { it.allDay && coversAllDay(it, day, zone) } + + /** The timed entries of [day]. */ + fun timedOn(slots: List, day: LocalDate, zone: ZoneId): List = slots.filter { !it.allDay && onDay(it, day, zone) } + + /** An entry placed in a day's column: from [top] to [bottom] minutes after midnight, column [col] of [cols]. */ + data class Placed(val slot: Slot, val top: Int, val bottom: Int, val col: Int, val cols: Int) + + /** Shortest height of an entry in the grid, in minutes (a 5-minute event still shows its title). */ + const val MIN_MINUTES = 20 + + /** + * The timed entries of [day] in their places: clipped to the day (an event over midnight shows on both days), + * overlapping ones side by side — each group of entries that overlap one another shares its width, an entry takes + * the first column free at its start. + */ + fun layoutDay(slots: List, day: LocalDate, zone: ZoneId): List { + val (from, to) = range(day, day, zone) + val dayMinutes = ((to - from) / 60_000L).toInt() + val items = timedOn(slots, day, zone).map { s -> + val top = ((maxOf(s.start, from) - from) / 60_000L).toInt().coerceIn(0, dayMinutes) + val rawBottom = ((minOf(s.end, to) - from) / 60_000L).toInt().coerceIn(0, dayMinutes) + val bottom = maxOf(rawBottom, minOf(top + MIN_MINUTES, dayMinutes)) + Triple(s, top, bottom) + }.sortedWith(compareBy({ it.second }, { -it.third }, { it.first.key })) + val out = ArrayList() + var group = ArrayList>() + var groupEnd = -1 + val colEnds = ArrayList() + val cols = HashMap() + fun flush() { + val n = colEnds.size.coerceAtLeast(1) + group.forEach { (s, t, b) -> out += Placed(s, t, b, cols.getValue(s.key), n) } + group = ArrayList() + groupEnd = -1 + colEnds.clear() + cols.clear() + } + for (it in items) { + val (s, top, bottom) = it + if (group.isNotEmpty() && top >= groupEnd) flush() + val free = colEnds.indexOfFirst { end -> end <= top } + val col = if (free >= 0) free else colEnds.size + if (col == colEnds.size) colEnds += bottom else colEnds[col] = bottom + cols[s.key] = col + group += it + groupEnd = maxOf(groupEnd, bottom) + } + if (group.isNotEmpty()) flush() + return out + } + + /** The agenda: from [first] on, each day with entries (all-day first, then by start). */ + fun agenda(slots: List, first: LocalDate, days: Int, zone: ZoneId): List>> = + (0 until days).mapNotNull { i -> + val day = first.plusDays(i.toLong()) + val list = allDayOn(slots, day, zone).sortedBy { it.title.lowercase() } + timedOn(slots, day, zone).sortedWith(compareBy({ it.start }, { it.key })) + if (list.isEmpty()) null else day to list + } + + /** + * The series' new times when "Alle Termine der Serie" moves the shown occurrence from ([occStart], [occEnd]) to + * ([newStart], [newEnd]): the series' own times (Mail 1.3.0 `Event.series`) moved by as much. + */ + fun shiftSeries( + series: MSeries, + occ: MOccurrence, + allDay: Boolean, + newStart: Long, + newEnd: Long, + newStartDate: String?, + newEndDate: String?, + zone: ZoneId, + ): MShift { + // days and minutes of wall clock (the portal's L.shiftSeries): a series that started in winter keeps its + // hour when a summer occurrence moves + fun day(at: Long) = Instant.ofEpochMilli(at).atZone(zone).toLocalDate() + val occDay = (if (occ.allDay) date(occ.startDate) else null) ?: day(occ.start) + val seriesDay = (if (series.allDay) date(series.startDate) else null) ?: day(series.start) + if (allDay) { + val s = date(newStartDate) ?: occDay + val e = date(newEndDate) ?: s + val start = seriesDay.plusDays(ChronoUnit.DAYS.between(occDay, s)) + return MShift(startDate = start.toString(), endDate = start.plusDays(ChronoUnit.DAYS.between(s, e).coerceAtLeast(0)).toString()) + } + val days = ChronoUnit.DAYS.between(occDay, day(newStart)) + val base = if (series.allDay) seriesDay.atStartOfDay(zone).toInstant().toEpochMilli() else series.start + val minutes = minuteOfDay(newStart, zone) - (if (occ.allDay || series.allDay) 0 else minuteOfDay(occ.start, zone)) + val start = ms(local(base, zone).plusDays(days).plusMinutes(minutes.toLong()), zone) + return MShift(start = start, end = start + (newEnd - newStart).coerceAtLeast(0)) + } + + /** A tap on free time in the grid: the start rounded down to the half hour, one hour long. */ + fun newAt(day: LocalDate, minute: Int, zone: ZoneId): Pair { + val m = (minute.coerceIn(0, 24 * 60 - 30) / 30) * 30 + val start = day.atStartOfDay(zone).plusMinutes(m.toLong()).toInstant().toEpochMilli() + return start to start + 60 * 60_000L + } + + /** A new event without a tap: the next full hour from [now], one hour long. */ + fun nextHour(now: Long, zone: ZoneId): Pair { + val t = Instant.ofEpochMilli(now).atZone(zone).truncatedTo(ChronoUnit.HOURS).plusHours(1) + val start = t.toInstant().toEpochMilli() + return start to start + 60 * 60_000L + } + + /** The minutes after midnight of [now] in [zone] (the now line). */ + fun minuteOfDay(now: Long, zone: ZoneId): Int = Instant.ofEpochMilli(now).atZone(zone).let { it.hour * 60 + it.minute } + + // ─── all-day dates ────────────────────────────────────────────── + + fun date(s: String?): LocalDate? = s?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + + /** The dates of an all-day event from the person's start and end day (end inclusive, never before the start). */ + fun allDayDates(first: LocalDate, last: LocalDate): Pair = first.toString() to (if (last.isBefore(first)) first else last).toString() + + /** The dates an all-day occurrence covers (its own dates, else from its ms in [zone]). */ + fun datesOf(o: MOccurrence, zone: ZoneId): Pair { + val first = date(o.startDate) ?: Instant.ofEpochMilli(o.start).atZone(zone).toLocalDate() + val last = date(o.endDate) ?: Instant.ofEpochMilli((o.end - 1).coerceAtLeast(o.start)).atZone(zone).toLocalDate() + return first to (if (last.isBefore(first)) first else last) + } + + // ─── time zones ───────────────────────────────────────────────── + + /** A zone the device knows, else null. */ + fun zone(id: String?): ZoneId? = id?.let { runCatching { ZoneId.of(it) }.getOrNull() } + + /** + * Whether an event's zone is worth showing beside the time: it differs from the person's at the event's start + * (another offset; the same rules under another name count as the same). + */ + fun otherZone(eventZone: String?, personZone: ZoneId, at: Long): ZoneId? { + val z = zone(eventZone) ?: return null + val i = Instant.ofEpochMilli(at) + return z.takeIf { it.rules.getOffset(i) != personZone.rules.getOffset(i) } + } + + /** `GMT+1`, `GMT-5:30` of [zone] at [at]. */ + fun offsetLabel(zone: ZoneId, at: Long): String { + val o: ZoneOffset = zone.rules.getOffset(Instant.ofEpochMilli(at)) + val total = o.totalSeconds / 60 + if (total == 0) return "GMT" + val sign = if (total < 0) "-" else "+" + val h = kotlin.math.abs(total) / 60 + val m = kotlin.math.abs(total) % 60 + return "GMT$sign$h" + if (m != 0) ":%02d".format(m) else "" + } + + /** The wall time of [at] in [zone] (what the form shows and edits). */ + fun local(at: Long, zone: ZoneId): LocalDateTime = Instant.ofEpochMilli(at).atZone(zone).toLocalDateTime() + + /** The ms of a wall time in [zone] (a time in a summer-time gap moves forward, an overlap takes the earlier). */ + fun ms(t: LocalDateTime, zone: ZoneId): Long = t.atZone(zone).toInstant().toEpochMilli() + + fun ms(day: LocalDate, time: LocalTime, zone: ZoneId): Long = ms(LocalDateTime.of(day, time), zone) + + // ─── rules in words ───────────────────────────────────────────── + + private val RULE_PART = Regex("^[A-Z]+=[A-Z0-9,+\\-]+$") + + /** `FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20261231` → the rule the form knows (null for what it cannot read). */ + fun parseRule(rrule: String): MRule? { + val parts = rrule.trim().removePrefix("RRULE:").split(';').filter { it.isNotBlank() } + if (parts.isEmpty() || parts.any { !RULE_PART.matches(it.uppercase()) }) return null + val map = parts.associate { it.substringBefore('=').uppercase() to it.substringAfter('=').uppercase() } + val freq = map["FREQ"]?.lowercase()?.takeIf { it in MRule.FREQS } ?: return null + var bySetPos = map["BYSETPOS"]?.toIntOrNull() + val byDay = map["BYDAY"]?.split(',')?.mapNotNull { d -> + val m = Regex("^([+-]?\\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$").matchEntire(d) ?: return@mapNotNull null + m.groupValues[1].takeIf { it.isNotEmpty() }?.toIntOrNull()?.let { bySetPos = it } + m.groupValues[2] + }?.takeIf { it.isNotEmpty() } + val until = map["UNTIL"]?.takeIf { it.length >= 8 }?.let { "${it.substring(0, 4)}-${it.substring(4, 6)}-${it.substring(6, 8)}" }?.takeIf { date(it) != null } + return MRule( + freq = freq, + interval = map["INTERVAL"]?.toIntOrNull()?.coerceIn(1, 999) ?: 1, + byDay = byDay, + byMonthDay = map["BYMONTHDAY"]?.split(',')?.mapNotNull { it.toIntOrNull() }?.takeIf { it.isNotEmpty() }, + bySetPos = bySetPos, + until = until, + count = map["COUNT"]?.toIntOrNull()?.takeIf { it > 0 }, + ) + } + + private fun dayName(d: String, lang: String, short: Boolean = true): String { + val dow = DayOfWeek.of(MRule.DAYS.indexOf(d) + 1) + val locale = if (lang == "en") Locale.ENGLISH else Locale.GERMAN + val s = dow.getDisplayName(if (short) TextStyle.SHORT else TextStyle.FULL, locale) + return s.removeSuffix(".") + } + + private fun ordinal(n: Int, lang: String): String = if (lang == "en") { + when { + n == -1 -> "last" + n % 100 in 11..13 -> "${n}th" + n % 10 == 1 -> "${n}st" + n % 10 == 2 -> "${n}nd" + n % 10 == 3 -> "${n}rd" + else -> "${n}th" + } + } else if (n == -1) "letzten" else "$n." + + /** + * A rule in words: "Wöchentlich am Di, Do", "Alle 2 Wochen am Mo", "Monatlich am 4.", "Monatlich am 2. Dienstag", + * "Jährlich am 14. März", with ", bis 31.12.2026" or ", 10-mal". [start] fills what the rule leaves to the start + * (the weekday of a weekly rule, the day of a monthly or yearly one). + */ + fun ruleText(r: MRule, lang: String, start: LocalDate? = null): String { + val en = lang == "en" + val n = r.interval.coerceAtLeast(1) + val base = when (r.freq) { + MRule.DAILY -> if (en) (if (n > 1) "Every $n days" else "Daily") else (if (n > 1) "Alle $n Tage" else "Täglich") + MRule.WEEKLY -> if (en) (if (n > 1) "Every $n weeks" else "Weekly") else (if (n > 1) "Alle $n Wochen" else "Wöchentlich") + MRule.MONTHLY -> if (en) (if (n > 1) "Every $n months" else "Monthly") else (if (n > 1) "Alle $n Monate" else "Monatlich") + else -> if (en) (if (n > 1) "Every $n years" else "Yearly") else (if (n > 1) "Alle $n Jahre" else "Jährlich") + } + val on = if (en) " on " else " am " + val detail = when (r.freq) { + MRule.WEEKLY -> { + val days = r.byDay ?: start?.let { listOf(MRule.DAYS[it.dayOfWeek.value - 1]) } + days?.let { d -> on + MRule.DAYS.filter { it in d }.joinToString(", ") { dayName(it, lang) } }.orEmpty() + } + MRule.MONTHLY -> when { + r.byDay != null && r.byDay.size == 1 && r.bySetPos != null -> + on + (if (en) "the " else "") + ordinal(r.bySetPos, lang) + " " + dayName(r.byDay[0], lang, short = false) + r.byMonthDay != null -> on + (if (en) "day " else "") + r.byMonthDay.joinToString(", ") { if (en) "$it" else "$it." } + start != null -> on + (if (en) "day ${start.dayOfMonth}" else "${start.dayOfMonth}.") + else -> "" + } + MRule.YEARLY -> start?.let { + val month = it.month.getDisplayName(TextStyle.FULL, if (en) Locale.ENGLISH else Locale.GERMAN) + on + if (en) "$month ${it.dayOfMonth}" else "${it.dayOfMonth}. $month" + }.orEmpty() + else -> "" + } + val end = when { + r.until != null -> date(r.until)?.let { d -> if (en) ", until ${d.monthValue}/${d.dayOfMonth}/${d.year}" else ", bis ${d.dayOfMonth}.${d.monthValue}.${d.year}" }.orEmpty() + r.count != null -> if (en) ", ${r.count} times" else ", ${r.count}-mal" + else -> "" + } + return base + detail + end + } + + /** The n-th weekday of [date] in its month (1 … 4, -1 when it is the last one) and its day (`MO` …). */ + fun nthWeekday(date: LocalDate): Pair { + val day = MRule.DAYS[date.dayOfWeek.value - 1] + val last = date.plusWeeks(1).month != date.month + val n = (date.dayOfMonth - 1) / 7 + 1 + return (if (last && n >= 4) -1 else n) to day + } + + /** The form's choices of a rule ([kind]) for an event starting [start]. */ + fun ruleFor(kind: String, start: LocalDate, interval: Int = 1, byDay: List? = null, until: String? = null, count: Int? = null): MRule? = when (kind) { + RULE_NONE -> null + MRule.DAILY -> MRule(MRule.DAILY, interval, until = until, count = count) + MRule.WEEKLY -> MRule(MRule.WEEKLY, interval, byDay = byDay?.takeIf { it.isNotEmpty() } ?: listOf(MRule.DAYS[start.dayOfWeek.value - 1]), until = until, count = count) + RULE_MONTHLY_DAY -> MRule(MRule.MONTHLY, interval, byMonthDay = listOf(start.dayOfMonth), until = until, count = count) + RULE_MONTHLY_WEEKDAY -> nthWeekday(start).let { (pos, d) -> MRule(MRule.MONTHLY, interval, byDay = listOf(d), bySetPos = pos, until = until, count = count) } + MRule.YEARLY -> MRule(MRule.YEARLY, interval, until = until, count = count) + else -> null + } + + /** The form's choice for a rule (the reverse of [ruleFor]). */ + fun kindOf(r: MRule?): String = when { + r == null -> RULE_NONE + r.freq == MRule.MONTHLY && r.byDay != null && r.bySetPos != null -> RULE_MONTHLY_WEEKDAY + r.freq == MRule.MONTHLY -> RULE_MONTHLY_DAY + else -> r.freq + } + + const val RULE_NONE = "none" + const val RULE_MONTHLY_DAY = "monthly_day" + const val RULE_MONTHLY_WEEKDAY = "monthly_weekday" + val RULE_KINDS = listOf(RULE_NONE, MRule.DAILY, MRule.WEEKLY, RULE_MONTHLY_DAY, RULE_MONTHLY_WEEKDAY, MRule.YEARLY) + + // ─── reminders ────────────────────────────────────────────────── + + /** The reminder choices of the form, in minutes before. */ + val REMINDERS = listOf(0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080) + + /** + * The choices for an all-day event, in minutes before the midnight that starts its day (the server's rule): 18:00 + * and 9:00 the day before, 9:00 two days and a week before. + */ + val ALL_DAY_REMINDERS = listOf(360, 900, 2340, 9540) + + /** An all-day reminder as (days before, minute of that day): 360 → (1, 1080) = 18:00 the day before. */ + fun allDayReminder(minutes: Int): Pair { + val daysBefore = Math.floorDiv(minutes + 1439, 1440) + return daysBefore to Math.floorMod(daysBefore * 1440 - minutes, 1440) + } + + /** A reminder in minutes as value and unit: (0, null) at the start, (15, min), (2, h), (1, d), (1, w). */ + fun reminderParts(minutes: Int): Pair = when { + minutes <= 0 -> 0 to null + minutes % 10080 == 0 -> minutes / 10080 to "w" + minutes % 1440 == 0 -> minutes / 1440 to "d" + minutes % 60 == 0 -> minutes / 60 to "h" + else -> minutes to "min" + } + + // ─── responses ────────────────────────────────────────────────── + + /** "3 von 5 zugesagt": the accepted and everyone but the organizer's resources. */ + fun acceptedOf(attendees: List): Pair { + val people = attendees.filter { it.role != MAttendee.ROLE_RESOURCE } + return people.count { it.response == MOccurrence.RESPONSE_ACCEPTED } to people.size + } + + // ─── contacts ─────────────────────────────────────────────────── + + /** The letter of a sort key: `A` … `Z` (Ä → A, Ö → O, Ü → U, ß → S), `#` for anything else. */ + fun letterOf(sortKey: String): String { + val c = sortKey.trim().firstOrNull()?.uppercaseChar() ?: return "#" + val folded = when (c) { + 'Ä', 'À', 'Á', 'Â', 'Å' -> 'A' + 'Ö', 'Ò', 'Ó', 'Ô' -> 'O' + 'Ü', 'Ù', 'Ú', 'Û' -> 'U' + 'ß' -> 'S' + 'É', 'È', 'Ê' -> 'E' + else -> c + } + return if (folded in 'A'..'Z') folded.toString() else "#" + } + + /** The list's sections: by [MCardSummary.letter] in the order of the list, `#` last. */ + fun sections(items: List): List>> = + items.groupBy { it.letter.ifEmpty { "#" } }.entries + .sortedWith(compareBy({ it.key == "#" }, { it.key })) + .map { it.key to it.value } + + /** The list after a `since` sync: [changed] replace or join, [deleted] go, sorted by the server's key. */ + fun applyCards(old: List, changed: List, deleted: Set): List { + val ids = changed.mapTo(HashSet()) { it.id } + return (old.filter { it.id !in deleted && it.id !in ids } + changed.filter { it.id !in deleted }) + .sortedWith(compareBy({ it.sortKey }, { it.id })) + } + + /** The contacts that match [q] offline: name, company, addresses, number (digits only). */ + fun search(items: List, q: String): List { + val t = q.trim().lowercase() + if (t.isEmpty()) return items + val digits = t.filter { it.isDigit() } + return items.filter { c -> + c.name.lowercase().contains(t) || (c.company?.lowercase()?.contains(t) == true) || c.emails.any { it.lowercase().contains(t) } || + (digits.length >= 3 && c.phone?.filter { it.isDigit() }?.contains(digits) == true) + } + } + + /** The second line of a contact in the list: position · company, else the first address, else the number. */ + fun subline(c: MCardSummary): String = + listOfNotNull(c.title, c.company).joinToString(" · ").ifEmpty { c.emails.firstOrNull() ?: c.phone.orEmpty() } + + /** The digits of a number for comparing (`+49` and a leading `0` the same, the last 9). */ + fun phoneKey(v: String): String { + var d = v.filter { it.isDigit() || it == '+' } + if (d.startsWith("+49")) d = "0" + d.removePrefix("+49") + if (d.startsWith("0049")) d = "0" + d.removePrefix("0049") + return d.filter { it.isDigit() }.takeLast(9) + } + + /** A number for `tel:` (digits, a leading `+`). */ + fun telUri(v: String): String? { + val t = v.trim() + val plus = t.startsWith("+") + val digits = t.filter { it.isDigit() } + return if (digits.length < 3) null else "tel:" + (if (plus) "+" else "") + digits + } + + /** + * What merging [cards] gives, kept by [primary]: addresses, numbers and postal addresses of all without doubles, + * the single fields from the contact picked for them (else the primary, else the first that has one), notes joined. + */ + fun merged(cards: List, primary: Long, pick: MMergePick = MMergePick()): MCard { + val main = cards.firstOrNull { it.id == primary } ?: cards.first() + val others = listOf(main) + cards.filter { it.id != main.id } + fun from(id: Long?, get: (MCard) -> T?): T? = (id?.let { p -> cards.firstOrNull { it.id == p } }?.let(get)) ?: others.firstNotNullOfOrNull(get) + val emails = others.flatMap { it.emails }.distinctBy { it.value.trim().lowercase() } + val phones = others.flatMap { it.phones }.distinctBy { phoneKey(it.value).ifEmpty { it.value } } + val addresses = others.flatMap { it.addresses }.distinctBy { "${it.street.trim()}|${it.city.trim()}".lowercase() } + val name = (pick.name?.let { p -> cards.firstOrNull { it.id == p } } ?: main).name + return main.copy( + name = name, + emails = emails.mapIndexed { i, e -> e.copy(primary = i == 0) }, + phones = phones, + addresses = addresses, + company = from(pick.company) { it.company }, + title = from(pick.title) { it.title }, + department = from(null) { it.department }, + birthday = from(pick.birthday) { it.birthday }, + note = others.map { it.note.trim() }.filter { it.isNotEmpty() }.distinct().joinToString("\n\n"), + groups = others.flatMap { it.groups }.distinct(), + ) + } + + /** The single fields where [cards] differ (the merge preview asks for these): name, company, title, birthday. */ + fun conflicts(cards: List): List = buildList { + if (cards.map { it.label.trim().lowercase() }.filter { it.isNotEmpty() }.distinct().size > 1) add("name") + if (cards.mapNotNull { it.company?.trim()?.lowercase() }.distinct().size > 1) add("company") + if (cards.mapNotNull { it.title?.trim()?.lowercase() }.distinct().size > 1) add("title") + if (cards.mapNotNull { it.birthday }.distinct().size > 1) add("birthday") + } + + /** Merging contacts of different address books links them (nothing is deleted at the providers). */ + fun links(cards: List): Boolean = cards.map { it.addressbookId }.distinct().size > 1 + + /** A birthday as `YYYY-MM-DD` / `--MM-DD` → (month, day, year or null). */ + fun birthdayParts(b: String?): Triple? { + val m = Regex("^(\\d{4}|-)-(\\d{2})-(\\d{2})$").matchEntire(b ?: return null) ?: return null + val month = m.groupValues[2].toInt() + val day = m.groupValues[3].toInt() + if (month !in 1..12 || day !in 1..31) return null + return Triple(month, day, m.groupValues[1].toIntOrNull()) + } + + /** The birthday the form gives: day and month (`14.03.`), the year optional. Null when it is no date. */ + fun birthdayOf(dayMonth: String, year: String): String? { + val m = Regex("^\\s*(\\d{1,2})\\.(\\d{1,2})\\.?\\s*$").matchEntire(dayMonth) ?: return null + val d = m.groupValues[1].toInt() + val mo = m.groupValues[2].toInt() + val y = year.trim().takeIf { it.isNotEmpty() }?.toIntOrNull() + if (year.isNotBlank() && (y == null || y !in 1800..2100)) return null + val check = runCatching { LocalDate.of(y ?: 2000, mo, d) }.getOrNull() ?: return null + return if (y != null) check.toString() else "--%02d-%02d".format(mo, d) + } + + /** The age the person turns at their next birthday on or after [today] (null without a year). */ + fun turns(b: String?, today: LocalDate): Int? { + val (m, d, y) = birthdayParts(b) ?: return null + y ?: return null + val thisYear = runCatching { LocalDate.of(today.year, m, d) }.getOrNull() ?: LocalDate.of(today.year, m, 28) + return (if (thisYear.isBefore(today)) today.year + 1 else today.year) - y + } +} + +/** What the offline copy of calendars and contacts keeps (pure, unit-tested). */ +object MailPimCacheLogic { + /** Contact photos kept (the used last). */ + const val MAX_PHOTOS = 500 + + private const val BACK_MS = 30L * MailPimLogic.DAY_MS + private const val AHEAD_MS = 90L * MailPimLogic.DAY_MS + + private val RANGE = Regex("^(-?\\d{1,15})-(-?\\d{1,15})\\.json$") + + /** `-.json` → its range. */ + fun rangeOf(name: String): Pair? = RANGE.matchEntire(name)?.let { m -> + val a = m.groupValues[1].toLongOrNull() ?: return null + val b = m.groupValues[2].toLongOrNull() ?: return null + (a to b).takeIf { b > a } + } + + /** Whether a range of occurrences is kept: it touches −30 … +90 days around [now]. */ + fun keep(from: Long, to: Long, now: Long): Boolean = to > now - BACK_MS && from < now + AHEAD_MS + + /** Every contact's summary as the server writes it (`{ items, version }`). */ + fun cardsJson(items: List, version: Long): com.google.gson.JsonObject = com.google.gson.JsonObject().apply { + add("items", com.google.gson.JsonArray().apply { + items.forEach { c -> + add(com.google.gson.JsonObject().apply { + addProperty("id", c.id) + addProperty("addressbookId", c.addressbookId) + addProperty("name", c.name) + addProperty("sortKey", c.sortKey) + addProperty("letter", c.letter) + c.company?.let { addProperty("company", it) } + c.title?.let { addProperty("title", it) } + add("emails", com.google.gson.JsonArray().apply { c.emails.forEach { add(it) } }) + c.phone?.let { addProperty("phone", it) } + addProperty("photo", c.photo) + c.photoEtag?.let { addProperty("photoEtag", it) } + add("groups", com.google.gson.JsonArray().apply { c.groups.forEach { add(it) } }) + add("linked", com.google.gson.JsonArray().apply { c.linked.forEach { add(it) } }) + c.gcUserId?.let { addProperty("gcUserId", it) } + c.updatedAt?.let { addProperty("updatedAt", it) } + }) + } + }) + addProperty("version", version) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt new file mode 100644 index 00000000..2a3eb468 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt @@ -0,0 +1,294 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.support.CrashReports +import com.google.gson.JsonElement +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * What the calendar and contact screens share (contract D2–D7): the calendars with their overlays and settings, the + * address books and groups, every contact as a summary (synced with `since`, searched offline), the realtime events + * `calendar` and `contacts`, and the offline copy of it all ([MailCache]). Offline everything is read from the copy; + * changing needs the server. + */ +@Singleton +class MailPimRepository @Inject constructor( + val api: MailApi, + private val cache: MailCache, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) + + private val _calendars = MutableStateFlow(null) + val calendars: StateFlow = _calendars.asStateFlow() + + private val _books = MutableStateFlow(null) + val books: StateFlow = _books.asStateFlow() + + /** Every visible contact (summaries), sorted as the server sorts them. */ + private val _cards = MutableStateFlow>(emptyList()) + val cards: StateFlow> = _cards.asStateFlow() + + /** The last answer could not be had (the copy is shown): offline. */ + private val _offline = MutableStateFlow(false) + val offline: StateFlow = _offline.asStateFlow() + + private val _calChanged = MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val calChanged: SharedFlow = _calChanged.asSharedFlow() + + private val _contactsChanged = MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val contactsChanged: SharedFlow = _contactsChanged.asSharedFlow() + + /** Microsoft accounts the person was sent to sign in again (a hint names the company's consent if it stays). */ + private val _relogins = MutableStateFlow>(emptySet()) + val relogins: StateFlow> = _relogins.asStateFlow() + + private var cardsVersion = 0L + private val cardsMutex = Mutex() + private var calJob: Job? = null + private var booksJob: Job? = null + + init { + scope.launch { + if (_calendars.value == null) cache.calendars()?.let { if (_calendars.value == null) _calendars.value = it } + if (_books.value == null) cache.books()?.let { if (_books.value == null) _books.value = it } + if (_cards.value.isEmpty()) cache.cards()?.let { (list, version) -> + if (_cards.value.isEmpty()) { + _cards.value = list + cardsVersion = version + } + } + } + } + + // ─── calendars ────────────────────────────────────────────────── + + /** `GET calendars` now (the copy when offline). */ + suspend fun refreshCalendars(): MResult { + val r = api.calendars() + return when (r) { + is MResult.Ok -> { + _calendars.value = r.value.first + _offline.value = false + cache.putCalendars(r.value.second) + MResult.Ok(r.value.first) + } + MResult.Offline -> { + _offline.value = true + if (_calendars.value == null) cache.calendars()?.let { _calendars.value = it } + r.notOk() + } + else -> r.notOk() + } + } + + fun refreshCalendarsSoon() { + if (calJob?.isActive == true) return + calJob = scope.launch { refreshCalendars() } + } + + /** A calendar changed here (shown at once; the server's answer replaces it). */ + fun putCalendar(c: MCalendar) { + _calendars.update { cur -> cur?.copy(calendars = cur.calendars.map { if (it.id == c.id) c else it }.let { l -> if (l.any { it.id == c.id }) l else l + c }) } + } + + fun dropCalendar(id: Long) { + _calendars.update { cur -> cur?.copy(calendars = cur.calendars.filter { it.id != id }) } + } + + fun putOverlay(o: MOverlay) { + _calendars.update { cur -> cur?.copy(overlays = cur.overlays.map { if (it.id == o.id) o else it }) } + } + + fun putPrefs(p: MCalPrefs) { + _calendars.update { cur -> cur?.copy(prefs = p) } + } + + /** + * The occurrences of `[from, to)` (one call per 62 days): the server's when it answers (kept for offline when the + * range is near today), else the copy of these days. + */ + suspend fun events(from: Long, to: Long, keep: Boolean): MResult { + val items = ArrayList() + val overlays = ArrayList() + val pending = LinkedHashSet() + var t = from + while (t < to) { + val end = minOf(to, t + MailPimLogic.RANGE_DAYS * MailPimLogic.DAY_MS) + when (val r = api.events(t, end)) { + is MResult.Ok -> { + items += r.value.first.items + overlays += r.value.first.overlays + pending += r.value.first.pending + _offline.value = false + if (keep) cache.putEvents(t, end, r.value.second) + } + MResult.Offline -> { + _offline.value = true + val copy = cache.events(from, to) ?: return r.notOk() + return MResult.Ok(copy) + } + else -> return r.notOk() + } + t = end + } + return MResult.Ok(MEvents(items.distinctBy { it.key }, overlays.distinctBy { it.key }, pending.toList())) + } + + /** One event (the copy of an opened one when offline). */ + suspend fun event(id: Long, occurrence: String?): MResult = when (val r = api.event(id, occurrence)) { + is MResult.Ok -> { + cache.putEvent(id, occurrence, r.value.second) + MResult.Ok(r.value.first) + } + MResult.Offline -> cache.event(id, occurrence)?.let { MResult.Ok(it) } ?: r.notOk() + else -> r.notOk() + } + + // ─── contacts ─────────────────────────────────────────────────── + + suspend fun refreshBooks(): MResult { + val r = api.addressbooks() + return when (r) { + is MResult.Ok -> { + _books.value = r.value.first + _offline.value = false + cache.putBooks(r.value.second) + MResult.Ok(r.value.first) + } + MResult.Offline -> { + _offline.value = true + if (_books.value == null) cache.books()?.let { _books.value = it } + r.notOk() + } + else -> r.notOk() + } + } + + fun refreshBooksSoon() { + if (booksJob?.isActive == true) return + booksJob = scope.launch { refreshBooks() } + } + + fun putBooks(b: MBooks) { + _books.value = b + } + + /** + * Every contact: all pages the first time, afterwards only what changed since the last version (`since`), the + * deleted ones taken out. Kept for offline. + */ + suspend fun syncCards(full: Boolean = false): MResult> = cardsMutex.withLock { + val since = if (full || _cards.value.isEmpty()) null else cardsVersion + val got = ArrayList() + var deleted = emptyList() + var cursor: String? = null + var version = cardsVersion + var pages = 0 + do { + when (val r = api.cards(cursor = cursor, since = since, limit = 500)) { + is MResult.Ok -> { + got += r.value.first.items + if (cursor == null) deleted = r.value.first.deleted + version = r.value.first.version + cursor = r.value.first.next + _offline.value = false + } + MResult.Offline -> { + _offline.value = true + return@withLock r.notOk() + } + else -> return@withLock r.notOk() + } + pages++ + } while (cursor != null && pages < 40) + val list = if (since == null) got else MailPimLogic.applyCards(_cards.value, got, deleted.toSet()) + _cards.value = list + cardsVersion = version + cache.putCards(list, version) + MResult.Ok(list) + } + + fun syncCardsSoon(full: Boolean = false) { + scope.launch { syncCards(full) } + } + + /** One contact (the copy of an opened one when offline). */ + suspend fun card(id: Long): MResult = when (val r = api.card(id)) { + is MResult.Ok -> { + cache.putCard(id, r.value.second) + MResult.Ok(r.value.first) + } + MResult.Offline -> cache.card(id)?.let { MResult.Ok(it) } ?: r.notOk() + else -> r.notOk() + } + + /** A contact's photo: the copy (LRU, 500), else the server's. */ + suspend fun photo(id: Long, etag: String): MPhoto? { + cache.photo(id, etag)?.let { return it } + val p = api.cardPhoto(id).valueOrNull() ?: return null + cache.putPhoto(id, etag, p) + return p + } + + fun forgetCard(id: Long) { + _cards.update { l -> l.filter { it.id != id } } + scope.launch { cache.forgetCard(id) } + } + + // ─── Microsoft ────────────────────────────────────────────────── + + /** The person goes to sign in again for [accountId] (the hint about the company's consent follows if it stays). */ + fun markRelogin(accountId: Long) = _relogins.update { it + accountId } + + // ─── live ─────────────────────────────────────────────────────── + + /** `event: plugin` of Mail with type `calendar` / `contacts` (the `mail` ones go to [MailRepository]). */ + fun onPluginEvent(type: String, data: JsonElement?): Boolean { + when (type) { + "calendar" -> { + val c = MailPimJson.calChanged(data) ?: return true + _calChanged.tryEmit(c) + if (c.kind == MCalChanged.CALENDARS || c.kind == MCalChanged.CHANGED) refreshCalendarsSoon() + return true + } + "contacts" -> { + val c = MailPimJson.contactsChanged(data) ?: return true + _contactsChanged.tryEmit(c) + if (c.kind != MContactsChanged.CHANGED) refreshBooksSoon() + if (c.kind == MContactsChanged.CHANGED && _cards.value.isNotEmpty()) syncCardsSoon() + return true + } + } + return false + } + + /** (Re)connected: what changed meanwhile. */ + fun onStreamOpen() { + if (_calendars.value != null) _calChanged.tryEmit(MCalChanged(MCalChanged.CALENDARS)) + if (_books.value != null) _contactsChanged.tryEmit(MContactsChanged(MContactsChanged.ADDRESSBOOKS)) + } + + /** The copy is gone ([MailRepository.forget]). */ + fun forget() { + _calendars.value = null + _books.value = null + _cards.value = emptyList() + cardsVersion = 0L + _relogins.value = emptySet() + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt index ec754f5b..72527c63 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt @@ -13,24 +13,50 @@ object MailPush { private const val TOPIC_PREFIX = "plugin:$MAIL_PLUGIN:" const val TOPIC_MAIL = TOPIC_PREFIX + "mail" const val TOPIC_FAILED = TOPIC_PREFIX + "mail_failed" + /** Since Mail 1.2.0: a reminder (`data.calendar = { eventId, occurrence }`) and an invitation (`{ invitationId }`). */ + const val TOPIC_REMINDER = TOPIC_PREFIX + "calendar_reminder" + const val TOPIC_INVITE = TOPIC_PREFIX + "calendar_invite" fun isMailMessage(message: PushMessage): Boolean = message.topic.startsWith(TOPIC_PREFIX) /** Whether a notification channel (by its topic) is Mail's own: shown as "Mail". */ fun isMailTopic(topic: String): Boolean = topic == TOPIC_MAIL - /** `message:` | `outbox:` | `outbox`; null for other messages. */ + /** Whether a channel (by its topic) is the calendar's: shown as "Kalender". */ + fun isCalendarTopic(topic: String): Boolean = topic == TOPIC_REMINDER || topic == TOPIC_INVITE + + /** `message:` | `outbox:` | `outbox` | `event:[:]` | `invitation:`; null for other messages. */ fun destination(message: PushMessage): String? { if (!isMailMessage(message)) return null - val mail = message.dataJson?.let { raw -> runCatching { JsonParser.parseString(raw).asJsonObject.get("mail") }.getOrNull() } - ?.takeIf { it.isJsonObject }?.asJsonObject + val data = message.dataJson?.let { raw -> runCatching { JsonParser.parseString(raw).asJsonObject }.getOrNull() } + val mail = data?.get("mail")?.takeIf { it.isJsonObject }?.asJsonObject + val calendar = data?.get("calendar")?.takeIf { it.isJsonObject }?.asJsonObject return when (message.topic) { TOPIC_MAIL -> mail?.id("messageId")?.let { "message:$it" } TOPIC_FAILED -> mail?.id("outboxId")?.let { "outbox:$it" } ?: "outbox" + TOPIC_REMINDER -> calendar?.id("eventId")?.let { id -> + val occ = calendar.get("occurrence")?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { OCCURRENCE.matches(it) } + "event:$id" + (occ?.let { ":$it" } ?: "") + } + TOPIC_INVITE -> calendar?.id("invitationId")?.let { "invitation:$it" } else -> null } } + private val OCCURRENCE = Regex("^(\\d{1,15}|\\d{4}-\\d{2}-\\d{2})$") + + /** A destination of [destination] → the event and its occurrence, or null. */ + fun eventOf(dest: String): Pair? { + if (!dest.startsWith("event:")) return null + val rest = dest.removePrefix("event:") + val id = rest.substringBefore(':').toLongOrNull()?.takeIf { it > 0 } ?: return null + val occ = rest.substringAfter(':', "").takeIf { it.isNotEmpty() && OCCURRENCE.matches(it) } + return id to occ + } + + /** A destination of [destination] → the invitation's id, or null. */ + fun invitationOf(dest: String): Long? = dest.takeIf { it.startsWith("invitation:") }?.removePrefix("invitation:")?.toLongOrNull()?.takeIf { it > 0 } + private fun JsonObject.id(key: String): Long? = get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString.trim().toLong() }.getOrNull() }?.takeIf { it > 0 } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt index 3276ee81..21693f87 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt @@ -45,6 +45,8 @@ data class MUndo(val outboxId: Long, val draftId: Long?, val untilElapsed: Long, class MailRepository @Inject constructor( val api: MailApi, val cache: MailCache, + /** Calendars and contacts (since Mail 1.2.0): their live events and their copy go with Mail's. */ + val pim: MailPimRepository, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) @@ -120,6 +122,8 @@ class MailRepository @Inject constructor( /** `event: plugin` of Mail (`mail { kind: 'changed', accountId, folderId }`): the lists and the counts load again. */ fun onPluginEvent(type: String, data: JsonElement?) { + // `calendar` and `contacts` (since Mail 1.2.0) belong to the calendar and the contacts + if (pim.onPluginEvent(type, data)) return val changed = MailJson.changed(data) ?: if (type == "mail" || type == "changed") MChanged(null, null) else return _changed.tryEmit(changed) if (refreshJob?.isActive != true) { @@ -132,6 +136,7 @@ class MailRepository @Inject constructor( /** (Re)connected: what changed meanwhile. */ fun onStreamOpen() { + pim.onStreamOpen() if (_me.value == null) return _changed.tryEmit(MChanged(null, null)) } @@ -160,6 +165,7 @@ class MailRepository @Inject constructor( _me.value = null _folders.value = emptyMap() _undo.value = null + pim.forget() cache.wipe() } @@ -221,4 +227,5 @@ class MailStartup @Inject constructor( interface MailEntryPoint { fun mailStartup(): MailStartup fun mailAvatars(): MailAvatars + fun mailPim(): MailPimRepository } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index a280ff6d..a5085bcf 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -187,8 +187,12 @@ fun AppNavigation( network = MainTabs.hasNetwork(services = hasServicesPermission, pihole = hasPiholePermission, rdp = hasRdpPermission), plugins = plugins, ) + // Mail's calendar and contacts: pages without a tab, while the server offers them + val mailMe by pagesViewModel.mailMe.collectAsStateWithLifecycle() /** The functions this login has besides Start (tabs or pages). */ - val features = MainTabs.features(tabRoutes) + val features = MainTabs.features(tabRoutes, MainTabs.mailPages(mailMe, lenient = true)) + /** The same, only what is known to be there (to offer pinning). */ + val pinnable = MainTabs.features(tabRoutes, MainTabs.mailPages(mailMe)) /** The pages shown beside Start, in their order. */ val startPages = StartPages.visible(allPages, features) val pinnedFeatures = startPages.filter { StartPages.isFeature(it) } @@ -424,6 +428,8 @@ fun AppNavigation( StartPages.VAULT -> VaultHome(navController) StartPages.DRIVE -> DriveHome(navController, onShown = {}) StartPages.MAIL -> MailHome(navController) + StartPages.CALENDAR -> MailHome(navController, com.gatecontrol.android.ui.mail.MailDrawerFoot.CALENDAR) + StartPages.CONTACTS -> MailHome(navController, com.gatecontrol.android.ui.mail.MailDrawerFoot.CONTACTS) } } @@ -535,7 +541,7 @@ fun AppNavigation( val bell = bellState(bellInputs?.first, bellInputs?.second ?: true, systemAllowed) StartPager( pages = startPages, - candidates = features.filter { it !in allPages }, + candidates = pinnable.filter { it !in allPages }, badges = mapOf(StartPages.CHATS to chatBadge, StartPages.NETWORK to onlineRdpHostCount), request = pageRequest, requests = pageRequests, @@ -655,7 +661,7 @@ fun AppNavigation( onOpenRoutes = { navController.navigate(Screen.NotifyRoutes.route) { launchSingleTop = true } }, onOpenRules = { navController.navigate(RulesRoutes.LIST) { launchSingleTop = true } }, onSelectPane = { paneKey = it.key }, - features = features, + features = pinnable, onArrangePages = { navController.navigate(Screen.StartPagesArrange.route) { launchSingleTop = true } }, ) } @@ -1190,12 +1196,12 @@ fun AppNavigation( // Pages beside Start: "Seiten anordnen" and Einstellungen → "Leiste und Seiten". composable(Screen.StartPagesArrange.route) { - GcContentWidth { StartPagesArrangeScreen(available = features, onBack = { navController.popBackStack() }) } + GcContentWidth { StartPagesArrangeScreen(available = pinnable, onBack = { navController.popBackStack() }) } } composable(Screen.SettingsPages.route) { GcContentWidth { StartPagesSettingsScreen( - available = features, + available = pinnable, onBack = { navController.popBackStack() }, onArrangePages = { navController.navigate(Screen.StartPagesArrange.route) { launchSingleTop = true } }, ) @@ -1294,6 +1300,15 @@ private fun openPushTarget( navigateToTab(Screen.Mail.route) navController.navigate(Screen.MailOutbox.route) { launchSingleTop = true } } + mail && com.gatecontrol.android.mail.MailPush.eventOf(target.dest) != null -> { + val (event, occurrence) = com.gatecontrol.android.mail.MailPush.eventOf(target.dest)!! + navigateToTab(Screen.Mail.route) + navController.navigate(Screen.MailEvent.create(event, occurrence)) { launchSingleTop = true } + } + mail && com.gatecontrol.android.mail.MailPush.invitationOf(target.dest) != null -> { + navigateToTab(Screen.Mail.route) + navController.navigate(Screen.MailInvitation.create(com.gatecontrol.android.mail.MailPush.invitationOf(target.dest)!!)) { launchSingleTop = true } + } target.messageId != null -> navController.navigate(Screen.NotificationDetail.create(target.messageId)) else -> navigateToTab(Screen.Vpn.route) } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt index e5a28046..8d884334 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -9,6 +9,7 @@ import androidx.navigation.navArgument import com.gatecontrol.android.ui.mail.MailAccountScreen import com.gatecontrol.android.ui.mail.MailAddAccountScreen import com.gatecontrol.android.ui.mail.MailComposeScreen +import com.gatecontrol.android.ui.mail.MailDrawerFoot import com.gatecontrol.android.ui.mail.MailFilingScreen import com.gatecontrol.android.ui.mail.MailHomeScreen import com.gatecontrol.android.ui.mail.MailNav @@ -19,6 +20,16 @@ import com.gatecontrol.android.ui.mail.MailSearchScreen import com.gatecontrol.android.ui.mail.MailSenderNav import com.gatecontrol.android.ui.mail.MailSenderScreen import com.gatecontrol.android.ui.mail.MailSettingsScreen +import com.gatecontrol.android.ui.mail.calendar.EventNav +import com.gatecontrol.android.ui.mail.calendar.InviteNav +import com.gatecontrol.android.ui.mail.calendar.MailEventEditScreen +import com.gatecontrol.android.ui.mail.calendar.MailEventScreen +import com.gatecontrol.android.ui.mail.calendar.MailInvitationScreen +import com.gatecontrol.android.ui.mail.contacts.ContactNav +import com.gatecontrol.android.ui.mail.contacts.MailContactEditScreen +import com.gatecontrol.android.ui.mail.contacts.MailContactScreen +import com.gatecontrol.android.ui.mail.contacts.MailDuplicatesScreen +import com.gatecontrol.android.ui.mail.contacts.MailGroupScreen /** Mail's ways between its screens (the tab, a page beside Start and the routes on top share them). */ private class MailNavigation(private val navController: NavHostController) { @@ -38,14 +49,24 @@ private class MailNavigation(private val navController: NavHostController) { settings = { go(Screen.MailSettings.route) }, account = { go(Screen.MailAccount.create(it)) }, outbox = { go(Screen.MailOutbox.route) }, + event = { id, occ -> go(Screen.MailEvent.create(id, occ)) }, + newEvent = { start, end, allDay -> go(Screen.MailEventEdit.create(start = start, end = end, allDay = allDay)) }, + contact = { go(Screen.MailContact.create(it)) }, + newContact = { go(Screen.MailContactEdit.create()) }, + group = { go(Screen.MailGroup.create(it)) }, + duplicates = { go(Screen.MailContactDuplicates.route) }, + relogin = { go(Screen.MailAddAccount.create(it)) }, ) } -/** The Mail tab's screen (as the tab or as a page beside Start). */ +/** + * The Mail tab's screen (as the tab or as a page beside Start); [area] the one it starts with: the mail, or the + * calendar / the contacts as their own page beside Start (no tab of their own). + */ @Composable -internal fun MailHome(navController: NavHostController) { +internal fun MailHome(navController: NavHostController, area: String = MailDrawerFoot.MAIL) { val nav = androidx.compose.runtime.remember(navController) { MailNavigation(navController) } - GcContentWidth { MailHomeScreen(nav.nav) } + GcContentWidth { MailHomeScreen(nav.nav, initialArea = area) } } /** @@ -78,6 +99,10 @@ internal fun NavGraphBuilder.mailRoutes( mailTo = { to -> go(Screen.MailCompose.create(to = to)) }, open = { other -> navController.navigate(Screen.MailMessage.create(other)) }, sender = { email, name, account -> go(Screen.MailSender.create(email, name, account)) }, + saveContact = { email, name, message -> go(Screen.MailContactEdit.create(message = message, email = email, name = name)) }, + contact = nav.nav.contact, + event = { nav.nav.event(it, null) }, + relogin = nav.nav.relogin, ), ) } @@ -89,6 +114,8 @@ internal fun NavGraphBuilder.mailRoutes( back = back, write = { to, account -> go(Screen.MailCompose.create(account = account, to = to)) }, open = { id -> navController.navigate(Screen.MailMessage.create(id)) }, + contact = { id -> go(Screen.MailContact.create(id)) }, + saveContact = { email, name -> go(Screen.MailContactEdit.create(email = email, name = name)) }, ), ) } @@ -132,4 +159,83 @@ internal fun NavGraphBuilder.mailRoutes( composable(Screen.MailAccount.route, arguments = listOf(navArgument(Screen.MailAccount.ARG_ID) { type = NavType.StringType })) { GcContentWidth { MailAccountScreen(onBack = back, onRelogin = { go(Screen.MailAddAccount.create(it)) }) } } + // ─── calendar and contacts (since Mail 1.2.0): routes of their own, no tab bar ─── + val required: (String) -> androidx.navigation.NamedNavArgument = { name -> navArgument(name) { type = NavType.StringType } } + val saveContact: (String, String?) -> Unit = { email, name -> go(Screen.MailContactEdit.create(email = email, name = name)) } + val mailTo: (String) -> Unit = { to -> go(Screen.MailCompose.create(to = to)) } + composable(Screen.MailEvent.route, arguments = listOf(required(Screen.MailEvent.ARG_ID), optional(Screen.MailEvent.ARG_OCC))) { + GcContentWidth { + MailEventScreen( + EventNav( + back = back, + edit = { id, occ -> go(Screen.MailEventEdit.create(id = id, occurrence = occ)) }, + contact = nav.nav.contact, + saveContact = saveContact, + mailTo = mailTo, + ), + ) + } + } + composable( + Screen.MailEventEdit.route, + arguments = listOf( + optional(Screen.MailEventEdit.ARG_ID), + optional(Screen.MailEventEdit.ARG_OCC), + optional(Screen.MailEventEdit.ARG_SCOPE), + optional(Screen.MailEventEdit.ARG_CAL), + optional(Screen.MailEventEdit.ARG_START), + optional(Screen.MailEventEdit.ARG_END), + optional(Screen.MailEventEdit.ARG_ALL_DAY), + optional(Screen.MailEventEdit.ARG_TO), + ), + ) { + GcContentWidth { MailEventEditScreen(onClose = back) } + } + composable(Screen.MailInvitation.route, arguments = listOf(required(Screen.MailInvitation.ARG_ID))) { + GcContentWidth { + MailInvitationScreen(onBack = back, nav = InviteNav(event = { nav.nav.event(it, null) }, contact = nav.nav.contact, saveContact = saveContact)) + } + } + composable(Screen.MailContact.route, arguments = listOf(required(Screen.MailContact.ARG_ID))) { + GcContentWidth { + MailContactScreen( + ContactNav( + back = back, + edit = { go(Screen.MailContactEdit.create(id = it)) }, + mailTo = mailTo, + openMail = nav.nav.open, + sender = { email, name -> go(Screen.MailSender.create(email, name)) }, + event = nav.nav.event, + ), + ) + } + } + composable( + Screen.MailContactEdit.route, + arguments = listOf( + optional(Screen.MailContactEdit.ARG_ID), + optional(Screen.MailContactEdit.ARG_MESSAGE), + optional(Screen.MailContactEdit.ARG_EMAIL), + optional(Screen.MailContactEdit.ARG_NAME), + optional(Screen.MailContactEdit.ARG_GROUP), + ), + ) { + GcContentWidth { + MailContactEditScreen( + onClose = back, + // a new contact opens after saving; a change goes back to it + onSaved = { id -> + val editing = it.arguments?.getString(Screen.MailContactEdit.ARG_ID) != null + navController.popBackStack() + if (!editing) go(Screen.MailContact.create(id)) + }, + ) + } + } + composable(Screen.MailContactDuplicates.route) { + GcContentWidth { MailDuplicatesScreen(onBack = back, onContact = nav.nav.contact) } + } + composable(Screen.MailGroup.route, arguments = listOf(required(Screen.MailGroup.ARG_ID))) { + GcContentWidth { MailGroupScreen(onBack = back, onContact = nav.nav.contact, mailTo = mailTo) } + } } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt b/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt index b3efa187..a09f9c36 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt @@ -97,8 +97,25 @@ internal object MainTabs { } } - /** The functions of the tab [routes] that can be pinned to Start (all but Start). */ - fun features(routes: List): List = routes.mapNotNull(::pageOf) + /** + * The functions of the tab [routes] that can be pinned to Start (all but Start), and Mail's pages without a tab + * ([mailPages]: calendar, contacts — only with Mail) right after Mail. + */ + fun features(routes: List, mailPages: Collection = emptyList()): List = routes.mapNotNull(::pageOf).flatMap { page -> + if (page == StartPages.MAIL) listOf(page) + StartPages.PAGE_ONLY.filter { it in mailPages }.sortedBy { StartPages.FEATURES.indexOf(it) } else listOf(page) + } + + /** + * Mail's pages without a tab the server offers ([MMe] features `calendar`, `contacts`); [lenient]: while `me` is + * not known yet, both (a pinned page does not vanish for a moment at start). + */ + fun mailPages(me: com.gatecontrol.android.mail.MMe?, lenient: Boolean = false): List = when { + me == null -> if (lenient) listOf(StartPages.CALENDAR, StartPages.CONTACTS) else emptyList() + else -> listOfNotNull( + StartPages.CALENDAR.takeIf { me.has(com.gatecontrol.android.mail.MMe.FEATURE_CALENDAR) }, + StartPages.CONTACTS.takeIf { me.has(com.gatecontrol.android.mail.MMe.FEATURE_CONTACTS) }, + ) + } /** The tabs left in the bar: [routes] without the functions pinned to Start ([pinned] page ids); Start always stays. */ fun visible(routes: List, pinned: Collection): List = diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index ce817ac6..4f17ec70 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -186,6 +186,69 @@ sealed class Screen(val route: String) { } /** "Postausgang": sends planned or failed. */ data object MailOutbox : Screen("mail/outbox") + /** An event of the calendar (since Mail 1.2.0): the occurrence `occ` of a series, or the event itself. */ + data object MailEvent : Screen("mail/event/{id}?occ={occ}") { + const val ARG_ID = "id" + const val ARG_OCC = "occ" + fun create(id: Long, occurrence: String? = null) = "mail/event/$id" + (occurrence?.let { "?occ=" + MailRoutes.enc(it) } ?: "") + } + /** + * A new event (`cal`, `start`/`end` of the tapped time, `allDay`, `to`: attendees) or the event `id` to change + * (`occ` + `scope`: only this occurrence or the whole series). + */ + data object MailEventEdit : Screen("mail/event-edit?id={id}&occ={occ}&scope={scope}&cal={cal}&start={start}&end={end}&allDay={allDay}&to={to}") { + const val ARG_ID = "id" + const val ARG_OCC = "occ" + const val ARG_SCOPE = "scope" + const val ARG_CAL = "cal" + const val ARG_START = "start" + const val ARG_END = "end" + const val ARG_ALL_DAY = "allDay" + const val ARG_TO = "to" + fun create( + id: Long? = null, + occurrence: String? = null, + scope: String? = null, + calendar: Long? = null, + start: Long? = null, + end: Long? = null, + allDay: Boolean = false, + to: String? = null, + ): String = MailRoutes.build( + "mail/event-edit", + "id" to id?.toString(), "occ" to occurrence, "scope" to scope, "cal" to calendar?.toString(), + "start" to start?.toString(), "end" to end?.toString(), "allDay" to (if (allDay) "1" else null), "to" to to, + ) + } + /** An invitation (a push of a GateControl person's invitation). */ + data object MailInvitation : Screen("mail/invitation/{id}") { + const val ARG_ID = "id" + fun create(id: Long) = "mail/invitation/$id" + } + /** A contact. */ + data object MailContact : Screen("mail/contact/{id}") { + const val ARG_ID = "id" + fun create(id: Long) = "mail/contact/$id" + } + /** A new contact, the contact `id` to change, or "Als Kontakt speichern" of `email` (from the mail `message`). */ + data object MailContactEdit : Screen("mail/contact-edit?id={id}&message={message}&email={email}&name={name}&group={group}") { + const val ARG_ID = "id" + const val ARG_MESSAGE = "message" + const val ARG_EMAIL = "email" + const val ARG_NAME = "name" + const val ARG_GROUP = "group" + fun create(id: Long? = null, message: Long? = null, email: String? = null, name: String? = null, group: Long? = null): String = MailRoutes.build( + "mail/contact-edit", + "id" to id?.toString(), "message" to message?.toString(), "email" to email, "name" to name?.take(200), "group" to group?.toString(), + ) + } + /** "Dubletten": contacts that may be one person. */ + data object MailContactDuplicates : Screen("mail/contacts/duplicates") + /** A group of contacts: its members, "Mail an alle". */ + data object MailGroup : Screen("mail/group/{id}") { + const val ARG_ID = "id" + fun create(id: Long) = "mail/group/$id" + } /** * Tab "Drive" (plugin gatecontrol-drive, only when the server lists it): * the areas and the newest files. A folder, the trash and the search are @@ -340,3 +403,13 @@ sealed class Screen(val route: String) { if (link == null) "setup/enroll" else "setup/enroll?link=" + android.net.Uri.encode(link) } } + +/** Query arguments of Mail's routes (%20, not "+": the navigation decodes arguments like a URI path). */ +internal object MailRoutes { + fun enc(v: String): String = java.net.URLEncoder.encode(v, "UTF-8").replace("+", "%20") + + fun build(base: String, vararg args: Pair): String { + val q = args.mapNotNull { (k, v) -> v?.let { "$k=" + enc(it) } } + return base + if (q.isEmpty()) "" else "?" + q.joinToString("&") + } +} diff --git a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt index 9f287a59..a6f41570 100644 --- a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt +++ b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt @@ -98,6 +98,9 @@ class PushNotifier @Inject constructor( spec.key?.let { PushText.channelName(it) }?.let { ctx.getString(it) } // Mail's new mails: the channel "Mail" (contract D), whatever the server calls the topic ?: ctx.getString(R.string.mail_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_MAIL) } + // the calendar's reminders and invitations (Mail 1.2.0): "Kalender", whatever the server calls the topics + ?: ctx.getString(R.string.mail_calendar_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_REMINDER) } + ?: ctx.getString(R.string.mail_invite_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_INVITE) } ?: spec.label ?: PushText.fallbackLabel(spec.id.removePrefix(PushChannels.topicChannelId(""))) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt index 20f5e0b4..7a806cd6 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt @@ -380,8 +380,10 @@ internal fun MailRow( if (preview.isNotBlank()) { Text(preview, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) } - if (item.hasAttachments || item.savedToDrive) { + if (item.hasAttachments || item.savedToDrive || item.invite != null) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + // since Mail 1.2.0: an invitation (or its answer, a cancellation) in the mail + if (item.invite != null) MailTag(stringResource(R.string.mail_invite_label), extra.blueBg, extra.blue) if (item.hasAttachments) Icon(GcIcons.Paperclip, contentDescription = stringResource(R.string.mail_has_attachments), tint = extra.muted, modifier = Modifier.size(14.dp)) if (item.savedToDrive) MailTag(stringResource(R.string.mail_saved_to_drive), extra.accentBg, extra.accentText) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt index e535d06d..9d78b99e 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt @@ -353,18 +353,9 @@ private fun RecipientRow(f: RecipientField, label: String, state: ComposeState, } if (state.suggestFor == f && state.suggestions.isNotEmpty()) { Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)) { + // photo or initials and the source; a group puts in all its members (since Mail 1.2.0) state.suggestions.take(6).forEach { c -> - Row( - Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { viewModel.pick(f, c) }.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon(if (c.source == "person") GcIcons.User else GcIcons.History, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) - Column(Modifier.weight(1f)) { - Text(c.name ?: c.email, style = MaterialTheme.typography.bodyMedium, maxLines = 1) - if (c.name != null) Text(c.email, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1) - } - } + com.gatecontrol.android.ui.mail.contacts.SuggestionRow(c) { viewModel.pick(f, c) } } } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt index e9f5426d..059f622f 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt @@ -280,10 +280,11 @@ class MailComposeViewModel @Inject constructor( if (parsed.addresses.isNotEmpty()) changed() } + /** A suggestion picked: its address, or a group's members (since Mail 1.2.0). */ fun pick(f: RecipientField, c: MContact) { _state.update { s -> - val a = MAddress(c.name, c.email) - val list = (s.recipients(f) + a).distinctBy { it.email.lowercase() } + val add = if (c.group) c.members else listOf(MAddress(c.name, c.email)) + val list = (s.recipients(f) + add).distinctBy { it.email.lowercase() } val next = when (f) { RecipientField.TO -> s.copy(to = list) RecipientField.CC -> s.copy(cc = list) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt index 5b87d81d..f78678db 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -77,6 +78,16 @@ import com.gatecontrol.android.ui.components.GcIconButton import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcPrimaryButton import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.mail.calendar.CalendarMenuDialogs +import com.gatecontrol.android.ui.mail.calendar.CalendarMenuState +import com.gatecontrol.android.ui.mail.calendar.CalendarNav +import com.gatecontrol.android.ui.mail.calendar.MailCalendarContent +import com.gatecontrol.android.ui.mail.calendar.MailCalendarViewModel +import com.gatecontrol.android.ui.mail.calendar.calendarDrawerItems +import com.gatecontrol.android.ui.mail.contacts.ContactsNav +import com.gatecontrol.android.ui.mail.contacts.MailContactsContent +import com.gatecontrol.android.ui.mail.contacts.MailContactsViewModel +import com.gatecontrol.android.ui.mail.contacts.contactsDrawerItems import com.gatecontrol.android.ui.theme.GateControlTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -95,6 +106,15 @@ data class MailNav( val settings: () -> Unit, val account: (Long) -> Unit, val outbox: () -> Unit, + /** Calendar and contacts (since Mail 1.2.0): an occurrence, a new event, a contact, a group, the duplicates. */ + val event: (eventId: Long, occurrence: String?) -> Unit = { _, _ -> }, + val newEvent: (start: Long?, end: Long?, allDay: Boolean) -> Unit = { _, _, _ -> }, + val contact: (Long) -> Unit = {}, + val newContact: () -> Unit = {}, + val group: (Long) -> Unit = {}, + val duplicates: () -> Unit = {}, + /** "Neu anmelden" of a Microsoft account (calendar and contacts rights). */ + val relogin: (Long) -> Unit = {}, ) /** @@ -104,7 +124,12 @@ data class MailNav( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) { +fun MailHomeScreen( + nav: MailNav, + /** The area shown first: mail, or calendar / contacts when that page is pinned to Start. */ + initialArea: String = MailDrawerFoot.MAIL, + viewModel: MailHomeViewModel = hiltViewModel(key = "mail-home-$initialArea"), +) { val me by viewModel.me.collectAsStateWithLifecycle() val meError by viewModel.meError.collectAsStateWithLifecycle() val folders by viewModel.folders.collectAsStateWithLifecycle() @@ -123,6 +148,21 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) val extra = GateControlTheme.extraColors var actionsFor by remember { mutableStateOf(null) } var moveFor by remember { mutableStateOf(null) } + // the areas of the tab (contract D2): mail, calendar, contacts — switched by the menu's foot + var area by rememberSaveable(initialArea) { mutableStateOf(initialArea) } + var calendarUsed by rememberSaveable { mutableStateOf(initialArea == MailDrawerFoot.CALENDAR) } + var contactsUsed by rememberSaveable { mutableStateOf(initialArea == MailDrawerFoot.CONTACTS) } + if (area == MailDrawerFoot.CALENDAR) calendarUsed = true + if (area == MailDrawerFoot.CONTACTS) contactsUsed = true + // the view models of an area only once it was opened (their first calls need the feature) + val calendarVm: MailCalendarViewModel? = if (calendarUsed) hiltViewModel(key = "mail-cal-$initialArea") else null + val contactsVm: MailContactsViewModel? = if (contactsUsed) hiltViewModel(key = "mail-con-$initialArea") else null + val calendarMenu = remember { CalendarMenuState() } + // an area the server no longer offers: back to the mail + LaunchedEffect(me, area) { + val m = me ?: return@LaunchedEffect + if ((area == MailDrawerFoot.CALENDAR && !MailDrawerFoot.calendar(m)) || (area == MailDrawerFoot.CONTACTS && !MailDrawerFoot.contacts(m))) area = MailDrawerFoot.MAIL + } LifecycleResumeEffect(viewModel) { viewModel.onShown() @@ -148,7 +188,10 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) } BackHandler(enabled = drawer.isOpen) { scope.launch { drawer.close() } } // a folder of an account: back goes to the account's inbox first - BackHandler(enabled = !drawer.isOpen && box.folderId != null) { viewModel.selectBox(MailLogic.Box(box.accountId)) } + BackHandler(enabled = !drawer.isOpen && area == MailDrawerFoot.MAIL && box.folderId != null) { viewModel.selectBox(MailLogic.Box(box.accountId)) } + // calendar or contacts: back goes to the area the screen started with (the mail in the tab) + BackHandler(enabled = !drawer.isOpen && area != initialArea) { area = initialArea } + val toggle: (String) -> Unit = { a -> area = if (area == a) MailDrawerFoot.MAIL else a } val accounts = me?.accounts.orEmpty() val account = me?.account(box.accountId) @@ -174,132 +217,33 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) onFiling = { close(); nav.filing() }, onOutbox = { close(); nav.outbox() }, onSettings = { close(); nav.settings() }, + onContacts = { toggle(MailDrawerFoot.CONTACTS) }, + onCalendar = { toggle(MailDrawerFoot.CALENDAR) }, + area = area, + onMail = { close(); area = MailDrawerFoot.MAIL }, + calendarVm = calendarVm, + calendarMenu = calendarMenu, + contactsVm = contactsVm, + onContactsFilter = { close() }, + onDuplicates = { close(); nav.duplicates() }, ) } }, ) { - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - // the bar: menu, title with the unread count, search, "⋯" when pinned to Start - val unread = when { - folder != null -> folder.unread - box.accountId != null -> me?.unreadByAccount?.get(box.accountId) ?: 0 - else -> me?.unreadAll ?: 0 - } - val title = when { - folder != null && !inbox -> folder.name - box.all -> stringResource(R.string.mail_all_inboxes) - else -> stringResource(R.string.mail_inbox) - } - val where = if (box.all) pluralStringResource(R.plurals.mail_accounts_n, accounts.size, accounts.size) else account?.label.orEmpty() - Row( - Modifier.fillMaxWidth().padding(start = 4.dp, end = 8.dp, top = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - GcIconButton( - GcIcons.Menu, - contentDescription = stringResource(R.string.mail_menu), - onClick = { scope.launch { drawer.open() } }, - tint = MaterialTheme.colorScheme.onBackground, - ) - Column(Modifier.weight(1f).padding(start = 4.dp)) { - Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (me != null) { - Text( - listOf(where, pluralStringResource(R.plurals.mail_unread_n, unread, unread)).filter { it.isNotEmpty() }.joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = extra.muted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - GcIconButton(GcIcons.Search, contentDescription = stringResource(R.string.mail_search), onClick = nav.search, tint = MaterialTheme.colorScheme.onBackground) - com.gatecontrol.android.ui.start.StartPageMenuButton() - } - if (accounts.size > 1) { - BoxChips(accounts = accounts, box = box, onBox = viewModel::selectBox) - } - if (inbox) { - TabSwitch( - tab = tab, - other = list.counts?.other, - onTab = viewModel::selectTab, - ) - } - MailBanners(me = me, meError = meError, offline = list.offline, onAccount = nav.account) - when { - me != null && accounts.isEmpty() -> NoAccounts(onAdd = nav.addAccount) - me == null && meError != null && list.items.isEmpty() -> ErrorBox(mailText(context, meError), onRetry = { viewModel.load(refresh = true) }) - else -> { - val listState = rememberLazyListState() - LaunchedEffect(listState, list.next) { - snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }.collect { last -> - if (list.next != null && last >= list.items.size - 8) viewModel.loadMore() - } - } - PullToRefreshBox( - isRefreshing = list.refreshing, - onRefresh = { viewModel.load(refresh = true) }, - modifier = Modifier.weight(1f).fillMaxWidth(), - ) { - LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 96.dp)) { - if (list.items.isEmpty()) { - item(key = "empty") { - Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { - if (list.loading) { - CircularProgressIndicator(Modifier.size(28.dp)) - } else { - Text( - list.error?.let { mailText(context, it) } ?: stringResource(if (inbox && tab == MSummary.TAB_OTHER) R.string.mail_empty_other else R.string.mail_empty), - color = extra.muted, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - } - items(list.items, key = { it.id }) { item -> - val settings = me?.settings - SwipeRow( - left = swipeSpec(settings?.swipeLeft, item.unread), - right = swipeSpec(settings?.swipeRight, item.unread), - onSwipe = { viewModel.act(item, it.action) }, - ) { - Column { - MailRow( - item = item, - account = me?.account(item.accountId), - onClick = { nav.open(item.id) }, - onLongClick = { actionsFor = item }, - ) - MailDivider() - } - } - } - if (list.loadingMore) { - item(key = "more") { - Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator(Modifier.size(24.dp)) - } - } - } - } - } - } - } - } - if (accounts.isNotEmpty()) { - NewMailButton( - onClick = { nav.compose(box.accountId) }, - modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = if (undo != null) 88.dp else 16.dp), - ) - } - undo?.let { u -> - UndoBar(u, onUndo = { viewModel.undoSend(u) }, onExpired = { viewModel.undoExpired(u) }, modifier = Modifier.align(Alignment.BottomCenter)) - } + val openMenu: () -> Unit = { scope.launch { drawer.open() } } + when { + area == MailDrawerFoot.CALENDAR && calendarVm != null -> MailCalendarContent( + CalendarNav(menu = openMenu, event = nav.event, newEvent = nav.newEvent, relogin = nav.relogin), + calendarVm, + ) + area == MailDrawerFoot.CONTACTS && contactsVm != null -> MailContactsContent( + ContactsNav(menu = openMenu, contact = nav.contact, newContact = nav.newContact, group = nav.group, duplicates = nav.duplicates, relogin = nav.relogin), + contactsVm, + ) + else -> MailArea(nav, viewModel, me, meError, folders, box, tab, list, undo, accounts, account, inbox, folder, openMenu) { actionsFor = it } } } + calendarVm?.let { CalendarMenuDialogs(calendarMenu, it, it.calendars.collectAsStateWithLifecycle().value) } actionsFor?.let { item -> RowActions( @@ -325,6 +269,151 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) } } +/** The mail area: the bar, the boxes, Relevant / Sonstige, the list, "Neue Mail" and the bar to undo a send. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MailArea( + nav: MailNav, + viewModel: MailHomeViewModel, + me: MMe?, + meError: String?, + folders: Map>, + box: MailLogic.Box, + tab: String, + list: MailListState, + undo: MUndo?, + accounts: List, + account: MAccount?, + inbox: Boolean, + folder: MFolder?, + openMenu: () -> Unit, + onActions: (MSummary) -> Unit, +) { + val context = mailContext() + val extra = GateControlTheme.extraColors + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + // the bar: menu, title with the unread count, search, "⋯" when pinned to Start + val unread = when { + folder != null -> folder.unread + box.accountId != null -> me?.unreadByAccount?.get(box.accountId) ?: 0 + else -> me?.unreadAll ?: 0 + } + val title = when { + folder != null && !inbox -> folder.name + box.all -> stringResource(R.string.mail_all_inboxes) + else -> stringResource(R.string.mail_inbox) + } + val where = if (box.all) pluralStringResource(R.plurals.mail_accounts_n, accounts.size, accounts.size) else account?.label.orEmpty() + Row( + Modifier.fillMaxWidth().padding(start = 4.dp, end = 8.dp, top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GcIconButton( + GcIcons.Menu, + contentDescription = stringResource(R.string.mail_menu), + onClick = openMenu, + tint = MaterialTheme.colorScheme.onBackground, + ) + Column(Modifier.weight(1f).padding(start = 4.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (me != null) { + Text( + listOf(where, pluralStringResource(R.plurals.mail_unread_n, unread, unread)).filter { it.isNotEmpty() }.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + GcIconButton(GcIcons.Search, contentDescription = stringResource(R.string.mail_search), onClick = nav.search, tint = MaterialTheme.colorScheme.onBackground) + com.gatecontrol.android.ui.start.StartPageMenuButton() + } + if (accounts.size > 1) { + BoxChips(accounts = accounts, box = box, onBox = viewModel::selectBox) + } + if (inbox) { + TabSwitch( + tab = tab, + other = list.counts?.other, + onTab = viewModel::selectTab, + ) + } + MailBanners(me = me, meError = meError, offline = list.offline, onAccount = nav.account) + when { + me != null && accounts.isEmpty() -> NoAccounts(onAdd = nav.addAccount) + me == null && meError != null && list.items.isEmpty() -> ErrorBox(mailText(context, meError), onRetry = { viewModel.load(refresh = true) }) + else -> { + val listState = rememberLazyListState() + LaunchedEffect(listState, list.next) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }.collect { last -> + if (list.next != null && last >= list.items.size - 8) viewModel.loadMore() + } + } + PullToRefreshBox( + isRefreshing = list.refreshing, + onRefresh = { viewModel.load(refresh = true) }, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) { + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 96.dp)) { + if (list.items.isEmpty()) { + item(key = "empty") { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + if (list.loading) { + CircularProgressIndicator(Modifier.size(28.dp)) + } else { + Text( + list.error?.let { mailText(context, it) } ?: stringResource(if (inbox && tab == MSummary.TAB_OTHER) R.string.mail_empty_other else R.string.mail_empty), + color = extra.muted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + items(list.items, key = { it.id }) { item -> + val settings = me?.settings + SwipeRow( + left = swipeSpec(settings?.swipeLeft, item.unread), + right = swipeSpec(settings?.swipeRight, item.unread), + onSwipe = { viewModel.act(item, it.action) }, + ) { + Column { + MailRow( + item = item, + account = me?.account(item.accountId), + onClick = { nav.open(item.id) }, + onLongClick = { onActions(item) }, + ) + MailDivider() + } + } + } + if (list.loadingMore) { + item(key = "more") { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + } + } + } + } + } + } + if (accounts.isNotEmpty()) { + NewMailButton( + onClick = { nav.compose(box.accountId) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = if (undo != null) 88.dp else 16.dp), + ) + } + undo?.let { u -> + UndoBar(u, onUndo = { viewModel.undoSend(u) }, onExpired = { viewModel.undoExpired(u) }, modifier = Modifier.align(Alignment.BottomCenter)) + } + } +} + /** "Alle" and one chip per account (mockup: colored dot, the active one in the accent). */ @Composable private fun BoxChips(accounts: List, box: MailLogic.Box, onBox: (MailLogic.Box) -> Unit) { @@ -598,15 +687,74 @@ private fun MailDrawer( onContacts: (() -> Unit)? = null, onCalendar: (() -> Unit)? = null, area: String = MailDrawerFoot.MAIL, + onMail: () -> Unit = {}, + calendarVm: MailCalendarViewModel? = null, + calendarMenu: CalendarMenuState? = null, + contactsVm: MailContactsViewModel? = null, + onContactsFilter: () -> Unit = {}, + onDuplicates: () -> Unit = {}, ) { val extra = GateControlTheme.extraColors + val cals = calendarVm?.calendars?.collectAsStateWithLifecycle()?.value + val books = contactsVm?.books?.collectAsStateWithLifecycle()?.value + val contactsState = contactsVm?.state?.collectAsStateWithLifecycle()?.value + val contactsTotal = contactsVm?.total?.collectAsStateWithLifecycle()?.value?.size ?: 0 Column(Modifier.fillMaxHeight().padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 16.dp)) { Text( - stringResource(R.string.mail_tab), + stringResource( + when (area) { + MailDrawerFoot.CALENDAR -> R.string.mail_calendar + MailDrawerFoot.CONTACTS -> R.string.mail_contacts + else -> R.string.mail_tab + }, + ), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(start = 12.dp, top = 4.dp, bottom = 12.dp).semantics { heading() }, ) + val inboxRow: @Composable () -> Unit = { DrawerItem(MailIcons.Inbox, stringResource(R.string.mail_inboxes), me?.unreadAll ?: 0, on = false, onClick = onMail) } + val label: @Composable (String) -> Unit = { DrawerLabel(it) } LazyColumn(Modifier.weight(1f)) { + if (area == MailDrawerFoot.CALENDAR && calendarVm != null && calendarMenu != null) { + calendarDrawerItems(me, cals, calendarMenu, inboxRow, label, onToggle = calendarVm::toggleCalendar, onToggleOverlay = calendarVm::toggleOverlay) + } else if (area == MailDrawerFoot.CONTACTS && contactsVm != null && contactsState != null) { + contactsDrawerItems( + me, books, contactsTotal, contactsState.duplicates, contactsState.filter, inboxRow, label, + row = { icon, text, count, on, click -> DrawerItem(icon, text, count, on, onClick = click) }, + onFilter = { f -> contactsVm.setFilter(f); onContactsFilter() }, + onToggleBook = contactsVm::toggleBook, + onDuplicates = onDuplicates, + ) + } else { + mailDrawerItems(me, folders, box, filing, outbox, folds, onFold, onBox, onFiling, onOutbox) + } + me?.limits?.let { l -> + MailLogic.quotaPercent(l.usedBytes, l.quotaBytes)?.let { p -> + item(key = "quota") { + Column(Modifier.padding(horizontal = 12.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.mail_quota, mailSize(l.usedBytes), mailSize(l.quotaBytes)), style = MaterialTheme.typography.bodySmall, color = extra.muted) + LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + } + } + } + MailDrawerFoot(me = me, area = area, onSettings = onSettings, onContacts = onContacts, onCalendar = onCalendar) + } +} + +/** The mail's part of the menu: "Alle Posteingänge", "Zum Ablegen", "Postausgang", the accounts and their folders. */ +private fun androidx.compose.foundation.lazy.LazyListScope.mailDrawerItems( + me: MMe?, + folders: Map>, + box: MailLogic.Box, + filing: Int, + outbox: Int, + folds: Map, + onFold: (Long) -> Unit, + onBox: (MailLogic.Box) -> Unit, + onFiling: () -> Unit, + onOutbox: () -> Unit, +) { item(key = "all") { DrawerItem(MailIcons.Inbox, stringResource(R.string.mail_all_inboxes), me?.unreadAll ?: 0, on = box.all, accent = true) { onBox(MailLogic.Box()) } } @@ -645,19 +793,6 @@ private fun MailDrawer( } } } - me?.limits?.let { l -> - MailLogic.quotaPercent(l.usedBytes, l.quotaBytes)?.let { p -> - item(key = "quota") { - Column(Modifier.padding(horizontal = 12.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(stringResource(R.string.mail_quota, mailSize(l.usedBytes), mailSize(l.quotaBytes)), style = MaterialTheme.typography.bodySmall, color = extra.muted) - LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth()) - } - } - } - } - } - MailDrawerFoot(me = me, area = area, onSettings = onSettings, onContacts = onContacts, onCalendar = onCalendar) - } } /** diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt index b416468e..bd9ec37b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt @@ -401,6 +401,13 @@ fun MailAccountScreen(onBack: () -> Unit, onRelogin: (Long) -> Unit, viewModel: Text(stringResource(R.string.mail_account_syncing), style = MaterialTheme.typography.bodySmall) } } + // since Mail 1.2.0: a Microsoft account without the calendar / contacts rights (contract D6) + if (a.microsoft && a.missingScopes.isNotEmpty()) { + GcBanner(tone = GcTone.Warn, icon = GcIcons.Key) { + Text(stringResource(R.string.pim_consent, a.label), style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + TextButton(onClick = { onRelogin(a.id) }) { Text(stringResource(R.string.pim_relogin)) } + } + } GcSectionLabel(stringResource(R.string.mail_name_color)) GcTextField(value = name, onValueChange = { name = it }, label = stringResource(R.string.mail_name)) if (name != a.name.orEmpty()) TextButton(onClick = { viewModel.patch(name = name) }, enabled = !busy) { Text(stringResource(R.string.mail_save)) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt index 1d727c9b..fabc1885 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt @@ -64,6 +64,11 @@ import com.gatecontrol.android.ui.components.GcIconButton import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcTone import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.mail.calendar.InviteConsentCard +import com.gatecontrol.android.ui.mail.calendar.InviteNav +import com.gatecontrol.android.ui.mail.calendar.MailInvitationCard +import com.gatecontrol.android.mail.MInvitation +import com.gatecontrol.android.mail.MailMicrosoft /** Where the reading screen leads. */ data class MailReadNav( @@ -76,6 +81,12 @@ data class MailReadNav( val open: (Long) -> Unit, /** The sender's page (their mails in the account [accountId]). */ val sender: (email: String, name: String?, accountId: Long) -> Unit = { _, _, _ -> }, + /** Since Mail 1.2.0: "Als Kontakt speichern" of an address of this mail. */ + val saveContact: (email: String, name: String?, messageId: Long) -> Unit = { _, _, _ -> }, + val contact: (Long) -> Unit = {}, + val event: (Long) -> Unit = {}, + /** "Kalender freigeben": sign in to the Microsoft account again. */ + val relogin: (Long) -> Unit = {}, ) /** @@ -148,6 +159,13 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode DropdownMenuItem(text = { Text(stringResource(R.string.mail_move)) }, onClick = { menu = false; move = true }) } DropdownMenuItem(text = { Text(stringResource(R.string.mail_junk)) }, onClick = { menu = false; viewModel.act("junk") }) + // since Mail 1.2.0: the sender into the contacts (name, address, the signature's number …) + if (me?.has(MMe.FEATURE_CONTACTS) == true && m.summary.from.email.contains('@')) { + DropdownMenuItem( + text = { Text(stringResource(R.string.con_save_sender)) }, + onClick = { menu = false; nav.saveContact(m.summary.from.email, m.summary.from.name, m.id) }, + ) + } m.unsubscribe?.let { u -> DropdownMenuItem( text = { Text(stringResource(R.string.mail_unsubscribe)) }, @@ -202,6 +220,15 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode if (state.offline) { GcBanner(tone = GcTone.Neutral, icon = GcIcons.Wifi) { Text(stringResource(R.string.mail_offline_copy), style = MaterialTheme.typography.bodySmall) } } + // since Mail 1.2.0: the invitation the mail carries (mockup "Einladung"), above the text + if (me?.has(MMe.FEATURE_CALENDAR) == true) { + val inv = m.invite + if (inv != null) { + MailInvitationCard(inv, InviteNav(event = nav.event, contact = nav.contact, saveContact = { e, n -> nav.saveContact(e, n, m.id) }), enabled = !state.offline) + } else if (m.summary.invite == MInvitation.METHOD_REQUEST && account?.missingScopes?.any { it.equals(MailMicrosoft.CALENDAR_SCOPE, true) } == true) { + InviteConsentCard(account.label) { nav.relogin(account.id) } + } + } AiCard(state.ai, me, onRetry = { viewModel.runAi(force = true) }, onFamily = viewModel::todoToFamily, onCalendar = { todo -> addToCalendar(context, todo, todo.text) }) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt index 13d0e1d5..9ad3e41b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt @@ -42,6 +42,9 @@ data class MailSenderNav( /** A new mail to [to] from the account [accountId]. */ val write: (to: String, accountId: Long?) -> Unit, val open: (Long) -> Unit, + /** Since Mail 1.2.0: the sender's contact, or "Als Kontakt speichern". */ + val contact: (Long) -> Unit = {}, + val saveContact: (email: String, name: String?) -> Unit = { _, _ -> }, ) /** @@ -64,7 +67,12 @@ fun MailSenderScreen(nav: MailSenderNav, viewModel: MailSenderViewModel = hiltVi horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), ) { - SenderAvatar(state.label.takeIf { it != state.email }, state.email, accountColor(account), size = 72.dp) + val card = state.card + if (card != null) { + com.gatecontrol.android.ui.mail.contacts.ContactAvatar(card.label, state.email, card.id.takeIf { card.photo }, card.photoEtag, size = 72.dp) + } else { + SenderAvatar(state.label.takeIf { it != state.email }, state.email, accountColor(account), size = 72.dp) + } Text( state.label, style = MaterialTheme.typography.headlineSmall, @@ -74,7 +82,7 @@ fun MailSenderScreen(nav: MailSenderNav, viewModel: MailSenderViewModel = hiltVi if (state.label != state.email) { SelectionContainer { Text(state.email, style = MaterialTheme.typography.bodyMedium, color = extra.muted, textAlign = TextAlign.Center) } } - SenderContact(state.contact) + SenderContact(state.contact, state.card, state.looked, state.person, onOpen = nav.contact, onSave = { nav.saveContact(state.email, state.name) }) Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { GcPrimaryButton( stringResource(R.string.mail_sender_write), @@ -121,13 +129,29 @@ fun MailSenderScreen(nav: MailSenderNav, viewModel: MailSenderViewModel = hiltVi } /** - * What the person's contacts know about the sender. For now only that they are one (the name above comes from - * there); the coming contacts plugin fills this place (phone, company, …) — no contacts are kept by Mail itself. + * What the person's contacts know about the sender (contract D5): with a contact its position · company, its numbers + * (a tap calls) and "Kontakt öffnen"; without one "Als Kontakt speichern"; a GateControl person as before. Servers + * before Mail 1.2.0 (or contacts off): only that the sender is a GateControl person. */ @Composable -private fun SenderContact(contact: MContact?) { - if (contact == null) return - Text(stringResource(R.string.mail_sender_in_contacts), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.accentText) +private fun SenderContact(contact: MContact?, card: com.gatecontrol.android.mail.MCard?, looked: Boolean, person: String?, onOpen: (Long) -> Unit, onSave: () -> Unit) { + val extra = GateControlTheme.extraColors + val context = mailContext() + if (card != null) { + val job = listOfNotNull(card.title, card.company).joinToString(" · ") + if (job.isNotEmpty()) Text(job, style = MaterialTheme.typography.bodyMedium, color = extra.muted) + card.phones.forEach { p -> + TextButton(onClick = { com.gatecontrol.android.mail.MailPimLogic.telUri(p.value)?.let { openExternal(context, it) } }) { + Text(com.gatecontrol.android.ui.mail.contacts.typeName(p.type) + ": " + p.value) + } + } + TextButton(onClick = { onOpen(card.id) }) { Text(stringResource(R.string.con_open), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) } + return + } + if (person != null || contact != null) { + Text(stringResource(R.string.mail_sender_in_contacts), style = MaterialTheme.typography.bodySmall, color = extra.accentText) + } + if (looked) TextButton(onClick = onSave) { Text(stringResource(R.string.con_save_sender), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) } } private fun copy(context: Context, text: String) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt index 19d8c025..4aee629a 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt @@ -28,6 +28,12 @@ data class MailSenderState( * coming contacts plugin knows about them; for now only its name is shown. */ val contact: MContact? = null, + /** Since Mail 1.2.0 (`cards/lookup`): the sender's contact, null without one. */ + val card: com.gatecontrol.android.mail.MCard? = null, + /** The lookup came (with contacts on): "Als Kontakt speichern" when [card] is null. */ + val looked: Boolean = false, + /** A GateControl person with this address (their name). */ + val person: String? = null, /** Their mails in the account, newest first. */ val items: List = emptyList(), val next: String? = null, @@ -35,7 +41,7 @@ data class MailSenderState( val error: String? = null, ) { /** The name shown: from the contacts first, else from the mail. */ - val label: String get() = contact?.name?.takeIf { it.isNotBlank() } ?: name?.takeIf { it.isNotBlank() } ?: email + val label: String get() = card?.label?.takeIf { it.isNotBlank() } ?: contact?.name?.takeIf { it.isNotBlank() } ?: person ?: name?.takeIf { it.isNotBlank() } ?: email } /** A sender's page: who it is (the contacts' name when there is one) and their mails in the account (`GET search?from=`). */ @@ -70,7 +76,12 @@ class MailSenderViewModel @Inject constructor( } _state.update { it.copy(loading = true, error = null) } viewModelScope.launch { - api.contacts(s.email).valueOrNull()?.let { list -> _state.update { it.copy(contact = MailLogic.contactOf(list, s.email)) } } + if (repository.me.value?.has(MMe.FEATURE_CONTACTS) == true) { + // since Mail 1.2.0: the real contact (contract D5) + api.lookup(s.email).valueOrNull()?.let { l -> _state.update { it.copy(card = l.card, person = l.personName, looked = true) } } + } else { + api.contacts(s.email).valueOrNull()?.let { list -> _state.update { it.copy(contact = MailLogic.contactOf(list, s.email)) } } + } } viewModelScope.launch { when (val r = api.search(MailLogic.senderQuery(s.email, s.accountId))) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt new file mode 100644 index 00000000..8043c607 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt @@ -0,0 +1,273 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +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.draw.clip +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.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MCalendar +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MPimSide +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcSwitch +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.mail.SheetRow +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** The colors a calendar can take (the mockups' palette). */ +internal val CAL_COLORS = listOf("#46E0A0", "#79B8FF", "#F5B544", "#FF8080", "#B79CFF", "#3DDC97", "#FF9F6B", "#8C96A5") + +/** What the calendar menu asks for (long press, add, subscribe). */ +@Stable +class CalendarMenuState { + var options by mutableStateOf(null) + var overlay by mutableStateOf(null) + var renaming by mutableStateOf(null) + var deleting by mutableStateOf(null) + var adding by mutableStateOf(false) + var subscribing by mutableStateOf(false) +} + +/** A row of the menu with a check square in [color] (filled: shown), the name and its state or count. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun CheckRow(label: String, color: Color, checked: Boolean, trailing: String?, trailingWarn: Boolean = false, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) { + val extra = GateControlTheme.extraColors + val state = stringResource(if (checked) R.string.cal_shown else R.string.cal_hidden) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .clip(RoundedCornerShape(12.dp)) + .combinedClickable(role = Role.Checkbox, onClick = onClick, onLongClick = onLongClick, onLongClickLabel = onLongClick?.let { stringResource(R.string.cal_options) }) + .semantics { stateDescription = state } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier.size(18.dp).clip(RoundedCornerShape(5.dp)) + .then(if (checked) Modifier.background(color) else Modifier.border(2.dp, color, RoundedCornerShape(5.dp))), + contentAlignment = Alignment.Center, + ) { + if (checked) Icon(GcIcons.Check, contentDescription = null, tint = Color(0xFF0D1015), modifier = Modifier.size(13.dp)) + } + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + if (trailing != null) Text(trailing, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = if (trailingWarn) extra.warn else extra.muted) + } +} + +@Composable +private fun stateLabel(c: MCalendar): Pair = when (c.state) { + MPimSide.STATE_SYNCING -> stringResource(R.string.pim_state_syncing) to false + MPimSide.STATE_AUTH_FAILED -> stringResource(R.string.pim_state_auth) to true + MPimSide.STATE_UNREACHABLE -> stringResource(R.string.pim_state_unreachable) to true + MPimSide.STATE_CONSENT -> stringResource(R.string.pim_relogin) to true + else -> (if (c.kind == "business") stringResource(R.string.cal_kind_business) else null) to false +} + +/** + * The calendar's part of the menu (mockup "Seitenmenü neu"): "Posteingänge" back to the mail, the calendars with a + * check and their color grouped "GateControl", per account and "Eingeblendet · nur ansehen", their state; long press: + * color, reminders, rename and delete (own ones); "Kalender hinzufügen" and "iCal-Abo" below. + */ +internal fun LazyListScope.calendarDrawerItems( + me: MMe?, + cals: MCalendars?, + menu: CalendarMenuState, + inboxRow: @Composable () -> Unit, + label: @Composable (String) -> Unit, + onToggle: (MCalendar) -> Unit, + onToggleOverlay: (String) -> Unit, +) { + item(key = "cal-inbox") { inboxRow() } + cals ?: return + val local = cals.calendars.filter { it.accountId == null } + if (local.isNotEmpty()) { + item(key = "cal-gc") { label(stringResource(R.string.cal_group_gatecontrol)) } + local.forEach { c -> item(key = "cal${c.id}") { CalRow(c, menu, onToggle) } } + } + cals.calendars.filter { it.accountId != null }.groupBy { it.accountId }.forEach { (accountId, list) -> + item(key = "cal-a$accountId") { label(me?.account(accountId)?.label ?: stringResource(R.string.cal_group_account)) } + list.forEach { c -> item(key = "cal${c.id}") { CalRow(c, menu, onToggle) } } + } + if (cals.overlays.isNotEmpty()) { + item(key = "cal-ov") { label(stringResource(R.string.cal_group_overlays)) } + cals.overlays.forEach { o -> + item(key = "ov${o.id}") { + CheckRow(o.name, calColor(o.color), o.visible, null, onClick = { onToggleOverlay(o.id) }, onLongClick = { menu.overlay = o.id }) + } + } + } + item(key = "cal-add") { + Column(Modifier.padding(top = 6.dp)) { + AddRow(stringResource(R.string.cal_add)) { menu.adding = true } + AddRow(stringResource(R.string.cal_subscribe)) { menu.subscribing = true } + } + } +} + +@Composable +private fun CalRow(c: MCalendar, menu: CalendarMenuState, onToggle: (MCalendar) -> Unit) { + val (state, warn) = stateLabel(c) + CheckRow(c.name, calColor(c.color), c.visible, state, warn, onClick = { onToggle(c) }, onLongClick = { menu.options = c }) +} + +@Composable +private fun AddRow(label: String, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)) + .combinedClickableCompat(onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(GcIcons.Plus, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(18.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, color = extra.accentText, fontWeight = FontWeight.SemiBold) + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun Modifier.combinedClickableCompat(onClick: () -> Unit): Modifier = combinedClickable(role = Role.Button, onClick = onClick) + +/** The sheets and dialogs of the calendar menu. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarMenuDialogs(menu: CalendarMenuState, viewModel: MailCalendarViewModel, cals: MCalendars?) { + val extra = GateControlTheme.extraColors + menu.options?.let { c0 -> + val c = cals?.calendar(c0.id) ?: c0 + ModalBottomSheet(onDismissRequest = { menu.options = null }, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(bottom = 24.dp)) { + Text(c.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics { heading() }) + ColorRow(c.color) { viewModel.setColor(c, it) } + Row(Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(GcIcons.Bell, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(22.dp)) + Text(stringResource(R.string.cal_reminders_on), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(start = 16.dp)) + GcSwitch(checked = c.notify, onCheckedChange = { viewModel.setNotify(c, it) }) + } + if (c.local) SheetRow(GcIcons.Edit, stringResource(R.string.cal_rename)) { menu.options = null; menu.renaming = c } + if (c.local || c.source == MCalendar.SOURCE_ICS) { + SheetRow(GcIcons.Trash, stringResource(R.string.cal_delete), MaterialTheme.colorScheme.error) { menu.options = null; menu.deleting = c } + } + if (!c.local) { + Text(stringResource(R.string.cal_account_hint), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + } + } + } + } + menu.overlay?.let { id -> + val o = cals?.overlays?.firstOrNull { it.id == id } + if (o == null) { + menu.overlay = null + } else { + ModalBottomSheet(onDismissRequest = { menu.overlay = null }, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(bottom = 24.dp)) { + Text(o.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics { heading() }) + ColorRow(o.color) { viewModel.setOverlayColor(o.id, it) } + } + } + } + } + menu.renaming?.let { c -> + NameDialog(stringResource(R.string.cal_rename), c.name, onDone = { viewModel.rename(c, it) }, onDismiss = { menu.renaming = null }) + } + menu.deleting?.let { c -> + AlertDialog( + onDismissRequest = { menu.deleting = null }, + title = { Text(stringResource(R.string.cal_delete_title, c.name)) }, + text = { Text(stringResource(R.string.cal_delete_text)) }, + confirmButton = { TextButton(onClick = { menu.deleting = null; viewModel.delete(c) }) { Text(stringResource(R.string.mail_delete), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { menu.deleting = null }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } + if (menu.adding) { + NameDialog(stringResource(R.string.cal_add), "", onDone = viewModel::add, onDismiss = { menu.adding = false }) + } + if (menu.subscribing) { + var url by remember { mutableStateOf("") } + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { menu.subscribing = false }, + title = { Text(stringResource(R.string.cal_subscribe)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.cal_subscribe_hint), style = MaterialTheme.typography.bodySmall, color = extra.muted) + GcTextField(url, { url = it }, stringResource(R.string.cal_subscribe_url), placeholder = "https://… / webcal://…") + GcTextField(name, { name = it }, stringResource(R.string.cal_name)) + } + }, + confirmButton = { + TextButton(enabled = url.isNotBlank(), onClick = { menu.subscribing = false; viewModel.subscribe(url.trim(), name.trim().ifEmpty { null }) }) { + Text(stringResource(R.string.cal_subscribe_do)) + } + }, + dismissButton = { TextButton(onClick = { menu.subscribing = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } +} + +@Composable +private fun ColorRow(current: String, onPick: (String) -> Unit) { + Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + CAL_COLORS.forEach { hex -> + val on = hex.equals(current, ignoreCase = true) + Box( + Modifier.size(32.dp).clip(CircleShape).background(calColor(hex)) + .then(if (on) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier) + .combinedClickableCompat { onPick(hex) } + .semantics { stateDescription = if (on) "✓" else "" }, + ) + } + } +} + +@Composable +internal fun NameDialog(title: String, initial: String, onDone: (String) -> Unit, onDismiss: () -> Unit) { + var name by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { GcTextField(name, { name = it.take(80) }, stringResource(R.string.cal_name)) }, + confirmButton = { TextButton(enabled = name.isNotBlank(), onClick = { onDismiss(); onDone(name.trim()) }) { Text(stringResource(R.string.mail_save)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarFormat.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarFormat.kt new file mode 100644 index 00000000..727e567f --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarFormat.kt @@ -0,0 +1,110 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MOccurrence +import com.gatecontrol.android.mail.MRule +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.update.currentLocale +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** A calendar's color (`#RRGGBB`), the accent when it is none. */ +internal fun calColor(hex: String?, fallback: Color = Color(0xFF46E0A0)): Color = MailLogic.color(hex)?.let { Color(it) } ?: fallback + +@Composable +internal fun lang(): String = if (currentLocale().language == "en") "en" else "de" + +internal fun clock(at: Long, zone: ZoneId, locale: Locale): String = + Instant.ofEpochMilli(at).atZone(zone).format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)) + +/** "Di., 3. Nov." / "Tue, Nov 3". */ +internal fun dayText(day: LocalDate, locale: Locale): String = + day.format(DateTimeFormatter.ofPattern(if (locale.language == "de") "EEE, d. MMM" else "EEE, MMM d", locale)) + +/** "Dienstag, 3. November 2026". */ +internal fun dayLong(day: LocalDate, locale: Locale): String = day.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)) + +/** "November 2026" (the year left out in the current one). */ +internal fun monthTitle(day: LocalDate, today: LocalDate, locale: Locale): String = + day.format(DateTimeFormatter.ofPattern(if (day.year == today.year) "LLLL" else "LLLL yyyy", locale)).replaceFirstChar { it.titlecase(locale) } + +/** + * The time of an occurrence: "10:00–11:00", "ganztägig", over several days "3. Nov. 10:00 – 5. Nov. 12:00" or + * "3.–5. Nov. · ganztägig". + */ +@Composable +internal fun whenText(start: Long, end: Long, allDay: Boolean, startDate: String?, endDate: String?, zone: ZoneId): String { + val locale = currentLocale() + val allDayText = stringResource(R.string.cal_all_day) + if (allDay) { + val a = MailPimLogic.date(startDate) ?: Instant.ofEpochMilli(start).atZone(zone).toLocalDate() + val b = MailPimLogic.date(endDate) ?: a + return if (a == b) allDayText else "${dayText(a, locale)} – ${dayText(b, locale)} · $allDayText" + } + val a = Instant.ofEpochMilli(start).atZone(zone).toLocalDate() + val b = Instant.ofEpochMilli((end - 1).coerceAtLeast(start)).atZone(zone).toLocalDate() + return if (a == b) "${clock(start, zone, locale)}–${clock(end, zone, locale)}" else "${dayText(a, locale)} ${clock(start, zone, locale)} – ${dayText(b, locale)} ${clock(end, zone, locale)}" +} + +@Composable +internal fun occurrenceWhen(o: MOccurrence, zone: ZoneId): String = whenText(o.start, o.end, o.allDay, o.startDate, o.endDate, zone) + +/** "15 Minuten vorher", "1 Tag vorher", "Zu Beginn"; all-day: "Am Vortag um 18:00". */ +@Composable +internal fun reminderText(minutes: Int, allDay: Boolean = false): String { + if (allDay) { + // minutes before the midnight that starts the day: 360 → 18:00 the day before + val (daysBefore, minuteOfDay) = MailPimLogic.allDayReminder(minutes) + val time = "%d:%02d".format(minuteOfDay / 60, minuteOfDay % 60) + return when (daysBefore) { + 0 -> stringResource(R.string.cal_reminder_same_day, time) + 1 -> stringResource(R.string.cal_reminder_day_before, time) + else -> pluralStringResource(R.plurals.cal_reminder_days_before, daysBefore, daysBefore, time) + } + } + val (n, unit) = MailPimLogic.reminderParts(minutes) + return when (unit) { + null -> stringResource(R.string.cal_reminder_at_start) + "w" -> pluralStringResource(R.plurals.cal_reminder_weeks, n, n) + "d" -> pluralStringResource(R.plurals.cal_reminder_days, n, n) + "h" -> pluralStringResource(R.plurals.cal_reminder_hours, n, n) + else -> pluralStringResource(R.plurals.cal_reminder_minutes, n, n) + } +} + +/** An attendee's answer in words. */ +@Composable +internal fun responseText(response: String?): String = stringResource( + when (response) { + MOccurrence.RESPONSE_ACCEPTED -> R.string.cal_resp_accepted + MOccurrence.RESPONSE_TENTATIVE -> R.string.cal_resp_tentative + MOccurrence.RESPONSE_DECLINED -> R.string.cal_resp_declined + MOccurrence.RESPONSE_ORGANIZER -> R.string.cal_resp_organizer + else -> R.string.cal_resp_open + }, +) + +/** The rule of a series in words, in the app's language. */ +@Composable +internal fun ruleText(rule: MRule, start: LocalDate?): String = MailPimLogic.ruleText(rule, lang(), start) + +/** The view's name ("Tag", "3 Tage" …). */ +@Composable +internal fun viewName(view: String): String = stringResource( + when (view) { + "day" -> R.string.cal_view_day + "3day" -> R.string.cal_view_3day + "week" -> R.string.cal_view_week + "month" -> R.string.cal_view_month + else -> R.string.cal_view_list + }, +) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/InvitationCard.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/InvitationCard.kt new file mode 100644 index 00000000..d08fd894 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/InvitationCard.kt @@ -0,0 +1,309 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +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.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MInvitation +import com.gatecontrol.android.mail.MOccurrence +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** Where an invitation leads. */ +data class InviteNav( + val event: (eventId: Long) -> Unit, + val contact: (contactId: Long) -> Unit, + val saveContact: (email: String, name: String?) -> Unit, +) + +/** The invitation of a mail above its text (contract D3 "Einladungskarte"). */ +@Composable +fun MailInvitationCard(inv: MInvitation, nav: InviteNav, enabled: Boolean = true) { + val viewModel: MailInviteViewModel = hiltViewModel(key = "invite-${inv.id}") + LaunchedEffect(inv) { viewModel.set(inv) } + InvitationBody(viewModel, nav, enabled) +} + +/** An invitation opened from a push (a GateControl person's invitation). */ +@Composable +fun MailInvitationScreen(onBack: () -> Unit, nav: InviteNav, viewModel: MailInviteViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = mailContext() + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.cal_invitation), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + when { + state.invitation != null -> Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp)) { + Text(state.invitation!!.title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 8.dp).semantics { heading() }) + Text(state.invitation!!.organizer.label, style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted, modifier = Modifier.padding(bottom = 12.dp)) + InvitationBody(viewModel, nav, true) + } + state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + else -> ErrorBox(mailText(context, state.error), onRetry = null) + } + } +} + +@Composable +private fun InvitationBody(viewModel: MailInviteViewModel, nav: InviteNav, enabled: Boolean) { + val state by viewModel.state.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + val zone = viewModel.zone + val inv = state.invitation ?: return + LaunchedEffect(state.message) { + state.message?.let { + toast(context, if (it == "cal_answered") context.getString(R.string.cal_answered) else mailText(context, it)) + viewModel.clearMessage() + } + } + val can = enabled && !offline && !state.busy + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.cal_invitation) + if (inv.title.isNotBlank()) ": " + inv.title else "", style = MaterialTheme.typography.labelMedium, color = extra.muted) + Text( + dayText(MailPimLogic.date(inv.startDate) ?: Instant.ofEpochMilli(inv.start).atZone(zone).toLocalDate(), currentLocale()) + " · " + + whenText(inv.start, inv.end, inv.allDay, inv.startDate, inv.endDate, zone), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + val sub = listOfNotNull(inv.location, (if (lang() == "en") inv.recurrenceEn else inv.recurrenceDe), inv.online?.let { providerName(it.provider) }) + if (sub.isNotEmpty()) Text(sub.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + when { + inv.state == MInvitation.STATE_OUTDATED -> Text(stringResource(R.string.cal_inv_outdated), style = MaterialTheme.typography.bodyMedium, color = extra.muted) + inv.method == MInvitation.METHOD_CANCEL -> { + Text(stringResource(R.string.cal_inv_cancelled), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + if (inv.state != MInvitation.STATE_APPLIED && inv.eventId != null) { + GcOutlineButton(stringResource(R.string.cal_inv_remove), onClick = viewModel::apply, enabled = can, loading = state.busy) + } + } + inv.method == MInvitation.METHOD_REPLY -> { + val who = inv.attendees.firstOrNull { it.email.equals(inv.from.email, true) } + Text(stringResource(R.string.cal_inv_reply, inv.from.label, responseText(who?.response).lowercase()), style = MaterialTheme.typography.bodyMedium) + inv.comment?.let { Text("„$it“", style = MaterialTheme.typography.bodySmall, color = extra.muted) } + } + inv.method == MInvitation.METHOD_COUNTER -> { + val ps = inv.proposedStart + val pe = inv.proposedEnd + Text( + stringResource(R.string.cal_inv_counter, inv.from.label) + if (ps != null && pe != null) ": " + whenText(ps, pe, false, null, null, zone) else "", + style = MaterialTheme.typography.bodyMedium, + ) + inv.comment?.let { Text("„$it“", style = MaterialTheme.typography.bodySmall, color = extra.muted) } + if (inv.state == MInvitation.STATE_OPEN) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcPrimaryButton(stringResource(R.string.cal_inv_take), onClick = viewModel::apply, enabled = can, modifier = Modifier.weight(1f)) + GcOutlineButton(stringResource(R.string.cal_decline), onClick = { viewModel.dismiss(decline = true) }, enabled = can, modifier = Modifier.weight(1f)) + } + } + } + else -> RequestPart(inv, state.calendarId, state.changing, can, viewModel, cals?.calendars.orEmpty().filter { it.id in inv.calendarChoices }) + } + } + if (inv.attendees.isNotEmpty() && inv.method != MInvitation.METHOD_REPLY) { + val (yes, all) = MailPimLogic.acceptedOf(inv.attendees) + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.Top) { + Text(stringResource(R.string.cal_attendees) + " · " + stringResource(R.string.cal_accepted_of, yes, all), style = MaterialTheme.typography.bodySmall, color = extra.muted) + PersonRow(inv.organizer.label, inv.organizer.email, stringResource(R.string.cal_resp_organizer), null) { + inv.organizer.contactId?.let(nav.contact) ?: nav.saveContact(inv.organizer.email, inv.organizer.name.ifBlank { null }) + } + inv.attendees.filter { !it.email.equals(inv.organizer.email, true) }.forEach { a -> + PersonRow(a.label, a.email, responseText(a.response), a.response) { a.contactId?.let(nav.contact) ?: nav.saveContact(a.email, a.name.ifBlank { null }) } + } + } + } + inv.eventId?.let { id -> + TextButton(onClick = { nav.event(id) }) { Text(stringResource(R.string.cal_inv_open_event)) } + } + } +} + +/** A request: the small day grid, the answers (or "Du hast zugesagt · Ändern"), a comment, a new time, the calendar. */ +@Composable +private fun RequestPart(inv: MInvitation, calendarId: Long?, changing: Boolean, can: Boolean, viewModel: MailInviteViewModel, choices: List) { + val extra = GateControlTheme.extraColors + var comment by remember { mutableStateOf("") } + var proposing by remember { mutableStateOf(false) } + var pickCal by remember { mutableStateOf(false) } + if (!inv.allDay) DayGrid(inv, viewModel.zone) + if (inv.conflicts.isNotEmpty()) { + Text(stringResource(R.string.cal_inv_conflicts, inv.conflicts.joinToString(", ") { it.title }), style = MaterialTheme.typography.bodySmall, color = extra.warn) + } + val answered = inv.myResponse != null && inv.myResponse != MOccurrence.RESPONSE_NEEDS_ACTION && inv.state != MInvitation.STATE_OPEN + if (answered && !changing) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.cal_you_answered, responseText(inv.myResponse)), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + TextButton(onClick = viewModel::change, enabled = can) { Text(stringResource(R.string.cal_change)) } + } + return + } + ResponseButtons(inv.myResponse, can) { viewModel.respond(it, comment.ifBlank { null }) } + GcTextField(comment, { comment = it.take(2000) }, stringResource(R.string.cal_comment), placeholder = stringResource(R.string.cal_comment_hint), singleLine = false) + TextButton(onClick = { proposing = true }, enabled = can, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.cal_inv_propose), color = extra.accentText, fontWeight = FontWeight.SemiBold) + } + if (choices.isNotEmpty()) { + val current = choices.firstOrNull { it.id == calendarId } ?: choices.first() + Box { + Text( + stringResource(R.string.cal_inv_into, current.name), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.clickable(role = Role.DropdownList, enabled = choices.size > 1) { pickCal = true }.padding(vertical = 6.dp), + ) + DropdownMenu(expanded = pickCal, onDismissRequest = { pickCal = false }) { + choices.forEach { c -> + DropdownMenuItem(text = { Text(c.name) }, onClick = { pickCal = false; viewModel.pickCalendar(c.id) }) + } + } + } + } + if (proposing) ProposeDialog(inv, viewModel.zone, onDismiss = { proposing = false }) { s, e -> + proposing = false + viewModel.respond(MOccurrence.RESPONSE_TENTATIVE, comment.ifBlank { null }, s, e) + } +} + +/** "Neue Zeit vorschlagen": a day, start and end; sent as a tentative answer with the proposal. */ +@Composable +private fun ProposeDialog(inv: MInvitation, zone: ZoneId, onDismiss: () -> Unit, onPropose: (Long, Long) -> Unit) { + val s0 = MailPimLogic.local(inv.start, zone) + val e0 = MailPimLogic.local(inv.end, zone) + var day by remember { mutableStateOf(s0.toLocalDate()) } + var start by remember { mutableStateOf(s0.toLocalTime()) } + var end by remember { mutableStateOf(e0.toLocalTime()) } + val locale = currentLocale() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.cal_inv_propose)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ProposeRow(stringResource(R.string.cal_start), day, start, locale) { d, t -> day = d; t?.let { start = it } } + ProposeRow(stringResource(R.string.cal_end), day, end, locale) { d, t -> day = d; t?.let { end = it } } + } + }, + confirmButton = { + val a = MailPimLogic.ms(day, start, zone) + val b = MailPimLogic.ms(day, end, zone) + TextButton(enabled = b > a, onClick = { onPropose(a, b) }) { Text(stringResource(R.string.cal_inv_propose_send)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} + +@Composable +private fun ProposeRow(label: String, day: LocalDate, time: LocalTime, locale: java.util.Locale, onPick: (LocalDate, LocalTime?) -> Unit) { + // the pickers of the event form + DateTimeRow(label, day, time, locale, onPick) +} + +/** The day around the invitation (± 2 h): the own events and the invitation dashed. */ +@Composable +private fun DayGrid(inv: MInvitation, zone: ZoneId) { + val extra = GateControlTheme.extraColors + val first = Instant.ofEpochMilli(inv.start).atZone(zone).let { (it.hour - 1).coerceAtLeast(0) } + val last = Instant.ofEpochMilli((inv.end - 1).coerceAtLeast(inv.start)).atZone(zone).let { (it.hour + 2).coerceAtMost(24) } + val hours = (last - first).coerceIn(2, 8) + val slot = 30.dp + val day = Instant.ofEpochMilli(inv.start).atZone(zone).toLocalDate() + val base = day.atStartOfDay(zone).plusHours(first.toLong()).toInstant().toEpochMilli() + val dashColor = calColor("#79B8FF") + fun y(ms: Long) = slot * (((ms - base) / 60_000f) / 60f).coerceIn(0f, hours.toFloat()) + BoxWithConstraints(Modifier.fillMaxWidth().height(slot * hours)) { + (0 until hours).forEach { h -> + Box(Modifier.offset(y = slot * h).fillMaxWidth().height(1.dp).background(extra.border)) + Text((first + h).toString(), fontSize = 11.sp, color = extra.faint, modifier = Modifier.offset(y = slot * h + 2.dp)) + } + inv.day.filter { !it.allDay }.forEach { o -> + val top = y(o.start) + val h = (y(o.end) - top).coerceAtLeast(14.dp) + Box( + Modifier.offset(x = 40.dp, y = top).width(maxWidth - 48.dp).height(h).clip(RoundedCornerShape(6.dp)) + .background(calColor("#46E0A0").copy(alpha = 0.18f)).padding(horizontal = 8.dp, vertical = 2.dp), + ) { Text(o.title, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + val top = y(inv.start) + Box( + Modifier.offset(x = 40.dp, y = top).width(maxWidth - 48.dp).height((y(inv.end) - top).coerceAtLeast(16.dp)) + .drawBehind { + drawRoundRect(dashColor, cornerRadius = CornerRadius(6.dp.toPx()), style = Stroke(1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f)))) + } + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { Text(inv.title + " " + stringResource(R.string.cal_inv_new), fontSize = 11.sp, color = dashColor, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } +} + +/** The band of a mail whose Microsoft account lacks the calendar right: "Kalender freigeben" (sign in again). */ +@Composable +fun InviteConsentCard(account: String, onRelogin: () -> Unit) { + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + androidx.compose.material3.Icon(GcIcons.Calendar, contentDescription = null, tint = GateControlTheme.extraColors.muted, modifier = Modifier.size(20.dp)) + Text(stringResource(R.string.cal_inv_consent, account), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + } + GcOutlineButton(stringResource(R.string.cal_inv_consent_do), onClick = onRelogin, icon = GcIcons.Key) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt new file mode 100644 index 00000000..d7da66ec --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt @@ -0,0 +1,630 @@ +package com.gatecontrol.android.ui.mail.calendar + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MCalPrefs +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MEvents +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MPimSide +import com.gatecontrol.android.mail.MailMicrosoft +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.start.LocalInStartPager +import com.gatecontrol.android.ui.start.StartPageMenuButton +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import kotlinx.coroutines.delay +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.abs + +/** Where the calendar leads. */ +data class CalendarNav( + val menu: () -> Unit, + /** An occurrence (its event, the occurrence of a series). */ + val event: (eventId: Long, occurrence: String?) -> Unit, + /** "Neuer Termin": with the tapped time, else the next hour. */ + val newEvent: (start: Long?, end: Long?, allDay: Boolean) -> Unit, + /** "Neu anmelden" of a Microsoft account (calendar and contacts rights). */ + val relogin: (accountId: Long) -> Unit, +) + +/** The grid's height of an hour. */ +private val HOUR: Dp = 56.dp +private val HOURS_COL: Dp = 40.dp + +/** + * The calendar area of the Mail tab (mockup "Kalender"): the bar with the menu, month, "Heute", ‹ › and the view, + * the chips of the calendars (a tap shows or hides one), the bands (sign in again, offline), the view and "Neuer + * Termin" below. + */ +@Composable +fun MailCalendarContent(nav: CalendarNav, viewModel: MailCalendarViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + val relogins by viewModel.relogins.collectAsStateWithLifecycle() + val context = mailContext() + val zone = viewModel.zone + val locale = currentLocale() + val today = LocalDate.now(zone) + val view = state.view ?: MCalPrefs.VIEW_3DAY + + LifecycleResumeEffect(viewModel) { + viewModel.onShown() + onPauseOrDispose { } + } + LaunchedEffect(state.message) { + state.message?.let { + toast(context, mailText(context, it)) + viewModel.clearMessage() + } + } + + val slots = remember(state.events, cals) { slotsOf(state.events, cals) } + Column(Modifier.fillMaxSize()) { + CalendarBar( + title = monthTitle(if (view == MCalPrefs.VIEW_MONTH) state.anchor else viewModel.shownDays(state).firstOrNull() ?: state.anchor, today, locale), + view = view, + onMenu = nav.menu, + onToday = viewModel::today, + onStep = viewModel::step, + onView = viewModel::setView, + ) + CalendarChips(cals, onCalendar = { id -> cals?.calendar(id)?.let(viewModel::toggleCalendar) }, onOverlay = viewModel::toggleOverlay) + CalendarBands(me, cals, offline, relogins, onRelogin = { id -> + viewModel.relogin(id) + nav.relogin(id) + }) + Box(Modifier.weight(1f).fillMaxWidth()) { + val swipe = if (LocalInStartPager.current || view == MCalPrefs.VIEW_LIST) Modifier else Modifier.swipeToPage(viewModel::step) + when (view) { + MCalPrefs.VIEW_MONTH -> MonthView(state.anchor, today, cals?.prefs?.weekStart ?: 1, slots, zone, swipe, onDay = viewModel::openDay) + MCalPrefs.VIEW_LIST -> AgendaView(state.anchor, state.listDays, today, slots, zone, onSlot = { s -> s.occurrence?.let { nav.event(it.eventId, it.occurrence) } }, onEnd = viewModel::moreList) + else -> TimeGrid( + days = viewModel.shownDays(state), + today = today, + slots = slots, + zone = zone, + modifier = swipe, + onSlot = { s -> s.occurrence?.let { nav.event(it.eventId, it.occurrence) } }, + onEmpty = { day, minute -> + if (!offline) { + val (a, b) = MailPimLogic.newAt(day, minute, zone) + nav.newEvent(a, b, false) + } + }, + onDayHeader = viewModel::openDay, + ) + } + if (state.loading && state.events == null) CircularProgressIndicator(Modifier.align(Alignment.Center).size(28.dp)) + state.error?.takeIf { state.events == null && !state.loading }?.let { + Text(mailText(context, it), color = GateControlTheme.extraColors.muted, modifier = Modifier.align(Alignment.Center).padding(24.dp)) + } + } + // "Neuer Termin" (mockup: the primary button in the bar below; "Ressource buchen" comes with phase 3) + Row( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 10.dp), + ) { + GcPrimaryButton( + stringResource(R.string.cal_new_event), + onClick = { nav.newEvent(null, null, false) }, + enabled = !offline && cals?.calendars?.any { it.writable } != false, + icon = GcIcons.Plus, + ) + } + } +} + +/** The occurrences and overlay entries as the grid's slots, in their calendar's color. */ +internal fun slotsOf(events: MEvents?, cals: MCalendars?): List { + events ?: return emptyList() + val colors = cals?.calendars?.associate { it.id to it.color }.orEmpty() + val overlayColors = cals?.overlays?.associate { it.id to it.color }.orEmpty() + return events.items.map { o -> + MailPimLogic.Slot(o.key, o.title, o.start, o.end, o.allDay, o.startDate, o.endDate, colors[o.calendarId] ?: "#46E0A0", + open = o.open, cancelled = o.cancelled, location = o.location, occurrence = o) + } + events.overlays.map { i -> + MailPimLogic.Slot(i.key, i.title, i.start, i.end, i.allDay, i.startDate, i.endDate, overlayColors[i.overlayId] ?: "#8C96A5", overlay = true, location = i.location) + } +} + +/** A sideways swipe pages ‹ / › (not on a page beside Start: there it changes the page). */ +private fun Modifier.swipeToPage(onStep: (Int) -> Unit): Modifier = pointerInput(Unit) { + var total = 0f + detectHorizontalDragGestures( + onDragStart = { total = 0f }, + onDragEnd = { + if (abs(total) > 120f) onStep(if (total < 0) 1 else -1) + total = 0f + }, + onHorizontalDrag = { _, d -> total += d }, + ) +} + +@Composable +private fun CalendarBar(title: String, view: String, onMenu: () -> Unit, onToday: () -> Unit, onStep: (Int) -> Unit, onView: (String) -> Unit) { + val extra = GateControlTheme.extraColors + var menu by remember { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 8.dp, top = 12.dp), verticalAlignment = Alignment.CenterVertically) { + GcIconButton(GcIcons.Menu, contentDescription = stringResource(R.string.mail_menu), onClick = onMenu, tint = MaterialTheme.colorScheme.onBackground) + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(start = 4.dp).semantics { heading() }, + ) + GcIconButton(GcIcons.ChevronLeft, contentDescription = stringResource(R.string.cal_previous), onClick = { onStep(-1) }, size = 40.dp) + TextButton(onClick = onToday) { Text(stringResource(R.string.cal_today), fontWeight = FontWeight.SemiBold) } + GcIconButton(GcIcons.ChevronRight, contentDescription = stringResource(R.string.cal_next), onClick = { onStep(1) }, size = 40.dp) + Box { + val label = viewName(view) + Box( + Modifier + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(10.dp)) + .border(1.dp, extra.border2, RoundedCornerShape(10.dp)) + .clickable(role = Role.DropdownList) { menu = true } + .padding(horizontal = 12.dp, vertical = 8.dp) + .semantics { contentDescription = label }, + ) { Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + MCalPrefs.VIEWS.forEach { v -> + DropdownMenuItem(text = { Text(viewName(v)) }, onClick = { menu = false; onView(v) }) + } + } + } + StartPageMenuButton() + } +} + +/** The chips of the calendars and overlays (mockup): the color square, filled when shown; a tap shows or hides. */ +@Composable +private fun CalendarChips(cals: MCalendars?, onCalendar: (Long) -> Unit, onOverlay: (String) -> Unit) { + cals ?: return + val extra = GateControlTheme.extraColors + val shown = stringResource(R.string.cal_shown) + val hidden = stringResource(R.string.cal_hidden) + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val chips = cals.calendars.map { Triple(it.name, it.color, it.visible) to { onCalendar(it.id) } } + + cals.overlays.map { Triple(it.name, it.color, it.visible) to { onOverlay(it.id) } } + chips.forEach { (c, click) -> + val (name, color, on) = c + val col = calColor(color) + Row( + Modifier + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(999.dp)) + .border(1.dp, extra.border2, RoundedCornerShape(999.dp)) + .selectable(selected = on, role = Role.Checkbox, onClick = click) + .semantics { contentDescription = "$name, " + if (on) shown else hidden } + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier.size(12.dp).clip(RoundedCornerShape(4.dp)) + .then(if (on) Modifier.background(col) else Modifier.border(2.dp, col, RoundedCornerShape(4.dp))), + ) + Text(name, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (on) MaterialTheme.colorScheme.onBackground else extra.faint, maxLines = 1) + } + } + } +} + +/** + * The bands above the view: a Microsoft account without the calendar rights ("Neu anmelden"; after that still + * missing: the company has to allow them, with its link), GMX / Web.de without the host's CalDAV, offline. + */ +@Composable +internal fun CalendarBands(me: MMe?, cals: MCalendars?, offline: Boolean, relogins: Set, onRelogin: (Long) -> Unit, contacts: Boolean = false) { + val context = mailContext() + val scope = if (contacts) MailMicrosoft.CONTACTS_SCOPE else MailMicrosoft.CALENDAR_SCOPE + val missing = me?.accounts.orEmpty().filter { a -> + a.microsoft && (a.missingScopes.any { it.equals(scope, true) } || (!contacts && cals?.consent?.any { it.accountId == a.id } == true)) + } + val needsUpdate = me != null && !me.has(FEATURE_DAV) && me.accounts.any { a -> + (a.provider == MAccount.PROVIDER_GMX || a.provider == MAccount.PROVIDER_WEBDE) && (if (contacts) a.contacts else a.calendar)?.state == MPimSide.STATE_NONE + } + if (missing.isEmpty() && !offline && !needsUpdate) return + Column(Modifier.padding(horizontal = 16.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (offline) { + GcBanner(tone = GcTone.Neutral, icon = GcIcons.Wifi) { + Text(stringResource(R.string.pim_offline), style = MaterialTheme.typography.bodySmall) + } + } + missing.take(2).forEach { a -> + GcBanner(tone = GcTone.Warn, icon = GcIcons.Key) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.pim_consent, a.label), style = MaterialTheme.typography.bodySmall) + val consentUrl = me?.microsoft?.adminConsentUrl + if (a.id in relogins && consentUrl != null) { + Text(stringResource(R.string.pim_consent_company), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + TextButton(onClick = { + val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, consentUrl) + runCatching { context.startActivity(Intent.createChooser(send, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + }) { Text(stringResource(R.string.pim_consent_share)) } + } + } + TextButton(onClick = { onRelogin(a.id) }) { Text(stringResource(R.string.pim_relogin), fontWeight = FontWeight.Bold) } + } + } + if (needsUpdate) { + GcBanner(tone = GcTone.Info, icon = GcIcons.Info) { + Text(stringResource(if (contacts) R.string.pim_needs_update_contacts else R.string.pim_needs_update), style = MaterialTheme.typography.bodySmall) + } + } + } +} + +private const val FEATURE_DAV = "dav" + +// ─── day / 3 days / week ────────────────────────────────────────── + +/** + * The grid (mockup): the days' heads (today in the accent), the all-day entries above, the hours 0–24 (opened at 7), + * the entries in their calendar's color with the color line left, overlapping ones side by side, an open invitation + * dashed, a cancelled one struck through, the overlays "nur ansehen", the now line. A tap on free time: a new event. + */ +@Composable +private fun TimeGrid( + days: List, + today: LocalDate, + slots: List, + zone: ZoneId, + modifier: Modifier, + onSlot: (MailPimLogic.Slot) -> Unit, + onEmpty: (LocalDate, Int) -> Unit, + onDayHeader: (LocalDate) -> Unit, +) { + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val density = LocalDensity.current + val hourPx = with(density) { HOUR.toPx() } + val scroll = rememberScrollState() + LaunchedEffect(Unit) { scroll.scrollTo((7 * hourPx).toInt()) } + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(60_000) + now = System.currentTimeMillis() + } + } + Column(modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(start = HOURS_COL + 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + days.forEach { d -> + val on = d == today + Column( + Modifier.weight(1f).heightIn(min = 52.dp).clip(RoundedCornerShape(12.dp)) + .background(if (on) extra.accentBg else Color.Transparent) + .clickable(role = Role.Button, onClickLabel = dayLong(d, locale)) { onDayHeader(d) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(d.format(DateTimeFormatter.ofPattern("EEE", locale)).removeSuffix("."), style = MaterialTheme.typography.bodySmall, color = if (on) extra.accentText else extra.muted) + Text(d.dayOfMonth.toString(), fontWeight = FontWeight.Bold, color = if (on) extra.accentText else MaterialTheme.colorScheme.onBackground) + } + } + } + val allDay = days.map { d -> MailPimLogic.allDayOn(slots, d, zone) } + if (allDay.any { it.isNotEmpty() }) { + Row(Modifier.fillMaxWidth().padding(start = HOURS_COL + 8.dp, end = 8.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + allDay.forEach { list -> + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + list.take(3).forEach { s -> SlotBlock(s, Modifier.fillMaxWidth().heightIn(min = 22.dp), compact = true) { onSlot(s) } } + if (list.size > 3) Text("+${list.size - 3}", style = MaterialTheme.typography.labelSmall, color = extra.muted) + } + } + } + } + Box(Modifier.weight(1f).fillMaxWidth().verticalScroll(scroll)) { + Row(Modifier.fillMaxWidth().height(HOUR * 24).padding(end = 8.dp)) { + Box(Modifier.width(HOURS_COL + 8.dp).fillMaxHeight()) { + (1 until 24).forEach { h -> + Text( + h.toString(), + style = MaterialTheme.typography.labelSmall, + color = extra.faint, + modifier = Modifier.offset(x = 8.dp, y = HOUR * h - 8.dp), + ) + } + } + days.forEach { d -> + BoxWithConstraints( + Modifier.weight(1f).fillMaxHeight().padding(horizontal = 2.dp) + .drawBehind { + for (h in 0..24) { + val y = h * hourPx + drawLine(extra.border, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f) + } + } + .pointerInput(d) { detectTapGestures { p -> onEmpty(d, (p.y / hourPx * 60).toInt()) } }, + ) { + val w = maxWidth + MailPimLogic.layoutDay(slots, d, zone).forEach { p -> + val colW = w / p.cols + SlotBlock( + p.slot, + Modifier + .offset(x = colW * p.col, y = HOUR * (p.top / 60f)) + .width(colW - 2.dp) + .height(HOUR * ((p.bottom - p.top) / 60f) - 2.dp), + ) { onSlot(p.slot) } + } + if (d == today) { + val y = HOUR * (MailPimLogic.minuteOfDay(now, zone) / 60f) + Box(Modifier.offset(y = y - 1.dp).fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.error)) + Box(Modifier.offset(x = (-4).dp, y = y - 4.dp).size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.error)) + } + } + } + } + } + } +} + +/** + * One entry: the color's surface (20 %) with the color line left; an open invitation dashed in the color; a + * cancelled one struck through; an overlay's with "nur ansehen". + */ +@Composable +private fun SlotBlock(s: MailPimLogic.Slot, modifier: Modifier, compact: Boolean = false, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val c = calColor(s.color) + val dashed = s.open + Box( + modifier + .clip(RoundedCornerShape(8.dp)) + .then( + if (dashed) { + Modifier.drawBehind { + drawRoundRect(c, cornerRadius = CornerRadius(8.dp.toPx()), style = Stroke(1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f)))) + } + } else { + Modifier.background(c.copy(alpha = if (s.overlay) 0.12f else 0.2f)).drawBehind { + drawRect(c, size = Size(3.dp.toPx(), size.height)) + } + }, + ) + .clickable(role = Role.Button, enabled = !s.overlay || s.occurrence != null, onClick = onClick) + .padding(start = if (dashed) 6.dp else 7.dp, end = 4.dp, top = if (compact) 2.dp else 3.dp, bottom = 2.dp), + ) { + Column { + Text( + s.title.ifBlank { stringResource(R.string.cal_no_title) }, + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (dashed) c else MaterialTheme.colorScheme.onSurface, + textDecoration = if (s.cancelled) TextDecoration.LineThrough else null, + maxLines = if (compact) 1 else 3, + overflow = TextOverflow.Ellipsis, + ) + if (!compact) { + val sub = when { + s.open -> stringResource(R.string.cal_invite_open) + s.overlay -> stringResource(R.string.cal_read_only) + else -> s.location + } + if (!sub.isNullOrBlank()) Text(sub, fontSize = 11.sp, lineHeight = 14.sp, color = if (dashed) c else extra.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } +} + +// ─── month ──────────────────────────────────────────────────────── + +/** The month: the weeks, per day its number and up to three entries as colored lines; a tap opens the day. */ +@Composable +private fun MonthView(anchor: LocalDate, today: LocalDate, weekStart: Int, slots: List, zone: ZoneId, modifier: Modifier, onDay: (LocalDate) -> Unit) { + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val weeks = MailPimLogic.monthGrid(anchor, weekStart) + Column(modifier.fillMaxSize().padding(horizontal = 8.dp)) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + weeks.first().forEach { d -> + Text( + d.format(DateTimeFormatter.ofPattern("EEEEE", locale)), + style = MaterialTheme.typography.labelSmall, + color = extra.muted, + modifier = Modifier.weight(1f).padding(start = 4.dp), + ) + } + } + weeks.forEach { week -> + Row(Modifier.fillMaxWidth().weight(1f)) { + week.forEach { d -> + val inMonth = d.month == anchor.month + val list = MailPimLogic.allDayOn(slots, d, zone) + MailPimLogic.timedOn(slots, d, zone).sortedBy { it.start } + Column( + Modifier.weight(1f).fillMaxHeight().padding(1.dp).clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClickLabel = dayLong(d, locale)) { onDay(d) } + .padding(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Box( + Modifier.size(22.dp).clip(CircleShape).background(if (d == today) MaterialTheme.colorScheme.primary else Color.Transparent), + contentAlignment = Alignment.Center, + ) { + Text( + d.dayOfMonth.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = when { + d == today -> MaterialTheme.colorScheme.onPrimary + inMonth -> MaterialTheme.colorScheme.onBackground + else -> extra.faint + }, + ) + } + list.take(3).forEach { s -> + val c = calColor(s.color) + Text( + s.title, + fontSize = 10.sp, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Clip, + color = if (s.open) c else MaterialTheme.colorScheme.onSurface, + textDecoration = if (s.cancelled) TextDecoration.LineThrough else null, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(4.dp)).background(c.copy(alpha = 0.2f)).padding(horizontal = 3.dp), + ) + } + if (list.size > 3) Text("+${list.size - 3}", fontSize = 10.sp, color = extra.muted) + } + } + } + } + } +} + +// ─── list ───────────────────────────────────────────────────────── + +/** The agenda from [first] on, by days (today marked), down to its end and further while scrolling. */ +@Composable +private fun AgendaView(first: LocalDate, days: Int, today: LocalDate, slots: List, zone: ZoneId, onSlot: (MailPimLogic.Slot) -> Unit, onEnd: () -> Unit) { + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val groups = remember(slots, first, days) { MailPimLogic.agenda(slots, first, days, zone) } + val listState = rememberLazyListState() + LaunchedEffect(listState, groups.size) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }.collect { last -> + if (last >= listState.layoutInfo.totalItemsCount - 3) onEnd() + } + } + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 24.dp)) { + if (groups.isEmpty()) { + item(key = "none") { Text(stringResource(R.string.cal_nothing), color = extra.muted, modifier = Modifier.padding(24.dp)) } + } + groups.forEach { (day, list) -> + item(key = "d$day") { + Text( + (if (day == today) stringResource(R.string.cal_today) + " · " else "") + dayText(day, locale), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = if (day == today) extra.accentText else extra.muted, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 4.dp).semantics { heading() }, + ) + } + items(list, key = { "s$day-${it.key}" }) { s -> AgendaRow(s, zone) { onSlot(s) } } + } + } +} + +@Composable +internal fun AgendaRow(s: MailPimLogic.Slot, zone: ZoneId, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val c = calColor(s.color) + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button, enabled = s.occurrence != null, onClick = onClick).padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier.width(4.dp).height(36.dp).clip(RoundedCornerShape(2.dp)) + .then(if (s.open) Modifier.border(1.dp, c, RoundedCornerShape(2.dp)) else Modifier.background(c)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + s.title.ifBlank { stringResource(R.string.cal_no_title) }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textDecoration = if (s.cancelled) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val parts = listOfNotNull( + whenText(s.start, s.end, s.allDay, s.startDate, s.endDate, zone), + stringResource(R.string.cal_invite_open).takeIf { s.open }, + stringResource(R.string.cal_read_only).takeIf { s.overlay }, + s.location, + ) + Text(parts.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = if (s.open) c else extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt new file mode 100644 index 00000000..8cc0addd --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt @@ -0,0 +1,252 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MCalPrefs +import com.gatecontrol.android.mail.MCalendar +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MEvents +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +data class CalendarState( + val anchor: LocalDate = LocalDate.now(), + /** day | 3day | week | month | list (null until the person's setting came). */ + val view: String? = null, + val events: MEvents? = null, + val loading: Boolean = true, + val error: String? = null, + /** How many days the list ("Liste") shows from [anchor] (more while scrolling down). */ + val listDays: Int = 60, + /** A message for a moment (a code of [com.gatecontrol.android.ui.mail.mailText] or a text key). */ + val message: String? = null, +) + +/** + * The calendar area of the Mail tab (contract D3): the view and the days it shows, the occurrences of the visible + * calendars and overlays, today and paging, the calendar list of the menu (visible, color, reminders, rename, + * delete, add, iCal subscription). Live events `calendar` load the shown days again; offline the copy is read. + */ +@HiltViewModel +class MailCalendarViewModel @Inject constructor( + private val repository: MailRepository, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val calendars: StateFlow = pim.calendars + val offline: StateFlow = pim.offline + val relogins: StateFlow> = pim.relogins + + /** The device's zone: the grid's days and hours. */ + val zone: ZoneId = ZoneId.systemDefault() + + private val _state = MutableStateFlow(CalendarState()) + val state: StateFlow = _state.asStateFlow() + + private var loadJob: Job? = null + private var zoneSent = false + + init { + viewModelScope.launch { + val r = pim.refreshCalendars() + r.valueOrNull()?.let { c -> + if (_state.value.view == null) _state.update { it.copy(view = c.prefs.view) } + syncZone(c.prefs) + } + if (_state.value.view == null) _state.update { it.copy(view = pim.calendars.value?.prefs?.view ?: MCalPrefs.VIEW_3DAY) } + load() + } + viewModelScope.launch { + pim.calChanged.collect { + delay(300) + load(quiet = true) + } + } + } + + /** The person's zone on the server follows the device's (reminders of all-day events, the overlays' days). */ + private suspend fun syncZone(prefs: MCalPrefs) { + if (zoneSent) return + zoneSent = true + if (prefs.timeZone != zone.id) api.calendarSettings(MailPimJson.calSettingsBody(timeZone = zone.id)).valueOrNull()?.let(pim::putPrefs) + } + + fun onShown() { + pim.refreshCalendarsSoon() + load(quiet = true) + } + + /** The days the view shows (the list: from its first day on). */ + fun shownDays(s: CalendarState = _state.value): List { + val view = s.view ?: MCalPrefs.VIEW_3DAY + val prefs = pim.calendars.value?.prefs ?: MCalPrefs.DEFAULT + return if (view == MCalPrefs.VIEW_LIST) (0 until s.listDays).map { s.anchor.plusDays(it.toLong()) } else MailPimLogic.days(view, s.anchor, prefs.weekStart, prefs.workWeek) + } + + /** The occurrences of the shown days (again). */ + fun load(quiet: Boolean = false) { + loadJob?.cancel() + val days = shownDays() + if (days.isEmpty()) return + if (!quiet) _state.update { it.copy(loading = true, error = null) } + loadJob = viewModelScope.launch { + val (from, to) = MailPimLogic.range(days.first(), days.last(), zone) + val now = System.currentTimeMillis() + val keep = com.gatecontrol.android.mail.MailPimCacheLogic.keep(from, to, now) + when (val r = pim.events(from, to, keep)) { + is MResult.Ok -> _state.update { it.copy(events = r.value, loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + fun setView(view: String) { + if (view !in MCalPrefs.VIEWS || view == _state.value.view) return + _state.update { it.copy(view = view, anchor = if (view == MCalPrefs.VIEW_LIST) LocalDate.now(zone) else it.anchor, listDays = 60) } + load() + viewModelScope.launch { api.calendarSettings(MailPimJson.calSettingsBody(view = view)).valueOrNull()?.let(pim::putPrefs) } + } + + fun today() { + _state.update { it.copy(anchor = LocalDate.now(zone), listDays = 60) } + load() + } + + /** ‹ / › or a swipe. */ + fun step(delta: Int) { + val s = _state.value + _state.update { it.copy(anchor = MailPimLogic.step(s.view ?: MCalPrefs.VIEW_3DAY, s.anchor, delta)) } + load() + } + + /** A day of the month (or a mini calendar): the day view. */ + fun openDay(day: LocalDate) { + _state.update { it.copy(anchor = day, view = MCalPrefs.VIEW_DAY) } + load() + } + + /** The list reached its end: 30 days more. */ + fun moreList() { + val s = _state.value + if (s.loading || s.listDays >= 365) return + _state.update { it.copy(listDays = it.listDays + 30) } + load(quiet = true) + } + + fun clearMessage() = _state.update { it.copy(message = null) } + + private fun fail(r: MResult<*>) = _state.update { it.copy(message = MailHomeViewModel.code(r)) } + + // ─── the calendar list ────────────────────────────────────────── + + fun toggleCalendar(c: MCalendar) { + pim.putCalendar(c.copy(visible = !c.visible)) + viewModelScope.launch { + when (val r = api.patchCalendar(c.id, MailPimJson.calendarPatch(visible = !c.visible))) { + is MResult.Ok -> pim.putCalendar(r.value) + else -> { + pim.putCalendar(c) + fail(r) + } + } + load(quiet = true) + } + } + + fun toggleOverlay(id: String) { + val o = pim.calendars.value?.overlays?.firstOrNull { it.id == id } ?: return + pim.putOverlay(o.copy(visible = !o.visible)) + viewModelScope.launch { + when (val r = api.patchOverlay(id, MailPimJson.overlayPatch(visible = !o.visible))) { + is MResult.Ok -> pim.putOverlay(r.value) + else -> { + pim.putOverlay(o) + fail(r) + } + } + load(quiet = true) + } + } + + fun setColor(c: MCalendar, color: String) = patch(c, MailPimJson.calendarPatch(color = color)) + + fun setOverlayColor(id: String, color: String) { + viewModelScope.launch { + when (val r = api.patchOverlay(id, MailPimJson.overlayPatch(color = color))) { + is MResult.Ok -> pim.putOverlay(r.value) + else -> fail(r) + } + load(quiet = true) + } + } + + fun setNotify(c: MCalendar, on: Boolean) = patch(c, MailPimJson.calendarPatch(notify = on)) + + fun rename(c: MCalendar, name: String) { + if (name.isBlank()) return + patch(c, MailPimJson.calendarPatch(name = name)) + } + + private fun patch(c: MCalendar, body: com.google.gson.JsonObject) { + viewModelScope.launch { + when (val r = api.patchCalendar(c.id, body)) { + is MResult.Ok -> pim.putCalendar(r.value) + else -> fail(r) + } + load(quiet = true) + } + } + + fun delete(c: MCalendar) { + viewModelScope.launch { + when (val r = api.deleteCalendar(c.id)) { + is MResult.Ok -> pim.dropCalendar(c.id) + else -> fail(r) + } + load(quiet = true) + } + } + + fun add(name: String) { + if (name.isBlank()) return + viewModelScope.launch { + when (val r = api.createCalendar(name, null)) { + is MResult.Ok -> pim.putCalendar(r.value) + else -> fail(r) + } + } + } + + fun subscribe(url: String, name: String?) { + if (url.isBlank()) return + viewModelScope.launch { + when (val r = api.subscribeCalendar(url, name)) { + is MResult.Ok -> { + pim.putCalendar(r.value) + load(quiet = true) + } + else -> fail(r) + } + } + } + + /** "Neu anmelden" of a Microsoft account (the hint about the company's consent follows if it stays). */ + fun relogin(accountId: Long) = pim.markRelogin(accountId) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt new file mode 100644 index 00000000..2d75fa4d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt @@ -0,0 +1,450 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MCalendar +import com.gatecontrol.android.mail.MRule +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.mail.contacts.SuggestionRow +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * A new event or a change (contract D3 "Termin bearbeiten"): title, calendar (writable ones, new only), all-day, + * start and end, the zone (folded), the rule, place, online meeting (Teams in Microsoft 365, else a link), reminders + * (up to the calendar's number), attendees with suggestions from the contacts and groups, free / busy, private and the + * description. A series' occurrence asks "Nur dieser Termin" / "Alle Termine der Serie" before saving; a conflict + * offers "Neu laden" and "Meine Änderung erneut anwenden". + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MailEventEditScreen(onClose: () -> Unit, viewModel: MailEventEditViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val f = state.form + LaunchedEffect(state.done) { if (state.done) onClose() } + LaunchedEffect(state.message) { + state.message?.let { + toast(context, if (it == MailEventEditViewModel.SERIES_SHIFT) context.getString(R.string.cal_series_shift) else mailText(context, it)) + viewModel.clearMessage() + } + } + // a new event without a calendar yet (the list came later): the default one + LaunchedEffect(cals) { + if (viewModel.isNew && f.calendarId == null) cals?.calendars?.firstOrNull { it.writable && it.isDefault }?.let { c -> viewModel.edit { it.copy(calendarId = c.id) } } + } + val cal = cals?.calendar(f.calendarId) + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar( + title = stringResource(if (viewModel.isNew) R.string.cal_new_event else R.string.cal_edit), + onBack = onClose, + backDescription = stringResource(R.string.mail_close), + ) { + TextButton(onClick = viewModel::save, enabled = !state.saving && viewModel.problem() == null && !state.loading) { + if (state.saving) CircularProgressIndicator(Modifier.size(18.dp)) else Text(stringResource(R.string.mail_save), fontWeight = FontWeight.Bold) + } + } + if (state.loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + return@Column + } + if (state.error != null && state.existing == null && !viewModel.isNew) { + ErrorBox(mailText(context, state.error), onRetry = null) + return@Column + } + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + GcTextField(f.title, { v -> viewModel.edit { it.copy(title = v.take(255)) } }, stringResource(R.string.cal_title)) + if (viewModel.isNew) { + CalendarPicker(cals?.calendars.orEmpty().filter { it.writable }, cal) { c -> viewModel.edit { it.copy(calendarId = c.id) } } + } + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + GcSwitchRow(label = stringResource(R.string.cal_all_day), description = null, checked = f.allDay, onCheckedChange = { v -> viewModel.edit { it.copy(allDay = v) } }) + DateTimeRow(stringResource(R.string.cal_start), f.startDay, f.startTime.takeIf { !f.allDay }, locale) { d, t -> viewModel.setStart(d, t ?: f.startTime) } + DateTimeRow(stringResource(R.string.cal_end), f.endDay, f.endTime.takeIf { !f.allDay }, locale) { d, t -> viewModel.edit { it.copy(endDay = d, endTime = t ?: it.endTime) } } + if (viewModel.problem() == "end") Text(stringResource(R.string.cal_end_before), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + if (!f.allDay) ZoneRow(f.zone, viewModel.deviceZone.id) { z -> viewModel.edit { it.copy(zone = z) } } + } + RuleBlock(viewModel, f, locked = state.ruleLocked && state.existing?.occ?.occurrence != null, locale = locale) + GcTextField(f.location, { v -> viewModel.edit { it.copy(location = v.take(500)) } }, stringResource(R.string.cal_location)) + OnlineBlock(cal, f.teams, f.onlineUrl, onTeams = { v -> viewModel.editOnline { it.copy(teams = v) } }, onUrl = { v -> viewModel.editOnline { it.copy(onlineUrl = v.take(2000)) } }) + RemindersBlock(f.reminders, f.allDay, max = cal?.maxReminders ?: 3) { list -> viewModel.edit { it.copy(reminders = list) } } + GcSectionLabel(stringResource(R.string.cal_attendees)) + if (f.attendees.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + f.attendees.forEach { a -> + Row( + Modifier.clip(RoundedCornerShape(999.dp)).background(extra.panel2) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_remove)) { viewModel.removeAttendee(a) } + .padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(a.name ?: a.email, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + Icon(GcIcons.Close, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + } + } + } + } + GcTextField( + state.attendeeInput, + viewModel::setAttendeeInput, + stringResource(R.string.cal_add_attendee), + placeholder = stringResource(R.string.cal_add_attendee_hint), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { viewModel.commitAttendees() }), + ) + if (state.suggestions.isNotEmpty()) { + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface)) { + state.suggestions.take(6).forEach { c -> SuggestionRow(c) { viewModel.pickAttendee(c) } } + } + } + if (f.attendees.isNotEmpty()) { + val sender = viewModel.sender(me, cal) + val text = when { + cal?.source == MCalendar.SOURCE_GRAPH -> stringResource(R.string.cal_invites_provider) + sender != null -> stringResource(R.string.cal_invites_via, sender.label) + else -> null + } + if (text != null) GcBanner(tone = GcTone.Info, icon = GcIcons.Mail) { Text(text, style = MaterialTheme.typography.bodySmall) } + } + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), verticalArrangement = Arrangement.Top) { + GcSwitchRow(label = stringResource(R.string.cal_show_free), description = null, checked = f.free, onCheckedChange = { v -> viewModel.edit { it.copy(free = v) } }) + GcSwitchRow(label = stringResource(R.string.cal_private), description = stringResource(R.string.cal_private_desc), checked = f.private, onCheckedChange = { v -> viewModel.edit { it.copy(private = v) } }) + } + GcTextField(f.description, { v -> viewModel.edit { it.copy(description = v) } }, stringResource(R.string.cal_description), singleLine = false) + Box(Modifier.heightIn(min = 24.dp)) + } + } + if (state.askScope) { + var series by remember { mutableStateOf(false) } + val seriesAllowed = viewModel.seriesAllowed() + AlertDialog( + onDismissRequest = viewModel::dismissScope, + title = { Text(stringResource(R.string.cal_scope_title)) }, + text = { + Column { + ScopeChoice(series, seriesEnabled = seriesAllowed) { series = it } + if (!seriesAllowed) Text(stringResource(R.string.cal_scope_time_one), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + }, + confirmButton = { TextButton(onClick = { viewModel.pickScope(series) }) { Text(stringResource(R.string.mail_save)) } }, + dismissButton = { TextButton(onClick = viewModel::dismissScope) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } + state.conflict?.let { + AlertDialog( + onDismissRequest = viewModel::dismissConflict, + title = { Text(stringResource(R.string.cal_conflict_title)) }, + text = { Text(stringResource(R.string.cal_conflict_text)) }, + confirmButton = { TextButton(onClick = viewModel::applyAgain) { Text(stringResource(R.string.pim_apply_again)) } }, + dismissButton = { TextButton(onClick = viewModel::reloadConflict) { Text(stringResource(R.string.pim_reload)) } }, + ) + } +} + +@Composable +private fun CalendarPicker(list: List, current: MCalendar?, onPick: (MCalendar) -> Unit) { + var open by remember { mutableStateOf(false) } + val extra = GateControlTheme.extraColors + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.cal_calendar), style = MaterialTheme.typography.titleSmall, color = extra.muted) + Box { + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, extra.border2, RoundedCornerShape(14.dp)) + .clickable(role = Role.DropdownList) { open = true }.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + current?.let { Box(Modifier.size(12.dp).clip(RoundedCornerShape(4.dp)).background(calColor(it.color))) } + Text(current?.name ?: "–", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Icon(GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + list.forEach { c -> + DropdownMenuItem( + text = { Text(c.name) }, + leadingIcon = { Box(Modifier.size(12.dp).clip(RoundedCornerShape(4.dp)).background(calColor(c.color))) }, + onClick = { open = false; onPick(c) }, + ) + } + } + } + } +} + +/** "Beginn" / "Ende": the day and (without all-day) the time, each opening its picker. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DateTimeRow(label: String, day: LocalDate, time: LocalTime?, locale: java.util.Locale, onPick: (LocalDate, LocalTime?) -> Unit) { + var pickDay by remember { mutableStateOf(false) } + var pickTime by remember { mutableStateOf(false) } + val extra = GateControlTheme.extraColors + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = extra.muted, modifier = Modifier.weight(1f)) + PickerChip(day.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), "$label: " + dayLong(day, locale)) { pickDay = true } + if (time != null) PickerChip(time.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)), label) { pickTime = true } + } + if (pickDay) { + val st = rememberDatePickerState(initialSelectedDateMillis = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()) + DatePickerDialog( + onDismissRequest = { pickDay = false }, + confirmButton = { + TextButton(onClick = { + pickDay = false + st.selectedDateMillis?.let { ms -> onPick(java.time.Instant.ofEpochMilli(ms).atZone(ZoneOffset.UTC).toLocalDate(), time) } + }) { Text(stringResource(R.string.mail_ok)) } + }, + dismissButton = { TextButton(onClick = { pickDay = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) { DatePicker(st) } + } + if (pickTime && time != null) { + val st = rememberTimePickerState(initialHour = time.hour, initialMinute = time.minute, is24Hour = locale.language != "en") + AlertDialog( + onDismissRequest = { pickTime = false }, + text = { TimePicker(st) }, + confirmButton = { TextButton(onClick = { pickTime = false; onPick(day, LocalTime.of(st.hour, st.minute)) }) { Text(stringResource(R.string.mail_ok)) } }, + dismissButton = { TextButton(onClick = { pickTime = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } +} + +@Composable +internal fun PickerChip(text: String, description: String, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Box( + Modifier.heightIn(min = 40.dp).clip(RoundedCornerShape(10.dp)).background(extra.panel2) + .clickable(role = Role.Button, onClick = onClick).semantics { contentDescription = description }.padding(horizontal = 12.dp, vertical = 9.dp), + ) { Text(text, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) } +} + +/** The zone, folded: "Zeitzone: Europe/Berlin"; open, a field (IANA name). */ +@Composable +private fun ZoneRow(zone: String, device: String, onZone: (String) -> Unit) { + var open by remember { mutableStateOf(zone != device) } + var text by remember(zone) { mutableStateOf(zone) } + val extra = GateControlTheme.extraColors + if (!open) { + Text( + stringResource(R.string.cal_zone_line, zone), + style = MaterialTheme.typography.bodySmall, + color = extra.accentText, + modifier = Modifier.clickable(role = Role.Button) { open = true }.padding(vertical = 6.dp), + ) + } else { + val ok = MailPimLogic.zone(text.trim()) != null + GcTextField( + text, + { v -> + text = v + if (MailPimLogic.zone(v.trim()) != null) onZone(v.trim()) + }, + stringResource(R.string.cal_zone), + placeholder = "Europe/Berlin", + error = if (ok) null else stringResource(R.string.cal_zone_bad), + ) + } +} + +/** The rule: none, daily, weekly on days, monthly on the day / the n-th weekday, yearly; the end never / on / after n. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RuleBlock(viewModel: MailEventEditViewModel, f: EventForm, locked: Boolean, locale: java.util.Locale) { + val extra = GateControlTheme.extraColors + var open by remember { mutableStateOf(false) } + val nth = MailPimLogic.nthWeekday(f.startDay) + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.cal_repeat), style = MaterialTheme.typography.titleSmall, color = extra.muted) + if (locked) { + Text(viewModel.rule(f)?.let { ruleText(it, f.startDay) } ?: "", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.cal_rule_locked), style = MaterialTheme.typography.bodySmall, color = extra.muted) + return@GcCard + } + Box { + PickerChip(ruleKindText(f.ruleKind, f.startDay, nth), stringResource(R.string.cal_repeat)) { open = true } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + MailPimLogic.RULE_KINDS.forEach { k -> + DropdownMenuItem(text = { Text(ruleKindText(k, f.startDay, nth)) }, onClick = { open = false; viewModel.editRule { it.copy(ruleKind = k) } }) + } + } + } + if (f.ruleKind == MailPimLogic.RULE_NONE) return@GcCard + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.cal_every), style = MaterialTheme.typography.bodyMedium) + GcTextField( + f.interval.toString(), + { v -> v.filter { it.isDigit() }.take(3).toIntOrNull()?.let { n -> viewModel.editRule { it.copy(interval = n.coerceIn(1, 999)) } } }, + label = "", + modifier = Modifier.size(width = 80.dp, height = 76.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + if (f.ruleKind == MRule.WEEKLY) { + val days = f.byDay.ifEmpty { listOf(MRule.DAYS[f.startDay.dayOfWeek.value - 1]) } + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + MRule.DAYS.forEachIndexed { i, d -> + val on = d in days + val name = java.time.DayOfWeek.of(i + 1).getDisplayName(java.time.format.TextStyle.SHORT, locale).removeSuffix(".") + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(999.dp)) + .background(if (on) extra.accentBg else extra.panel2) + .selectable(selected = on, role = Role.Checkbox) { + val next = if (on) days - d else days + d + if (next.isNotEmpty()) viewModel.editRule { it.copy(byDay = next) } + }, + contentAlignment = Alignment.Center, + ) { Text(name, style = MaterialTheme.typography.labelMedium, color = if (on) extra.accentText else extra.muted, fontWeight = FontWeight.SemiBold) } + } + } + } + Text(stringResource(R.string.cal_ends), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + listOf(EventForm.END_NEVER to R.string.cal_ends_never, EventForm.END_UNTIL to R.string.cal_ends_on, EventForm.END_COUNT to R.string.cal_ends_after).forEach { (k, label) -> + com.gatecontrol.android.ui.components.GcFilterChip(stringResource(label), selected = f.ruleEnd == k, onClick = { + viewModel.editRule { it.copy(ruleEnd = k, until = it.until ?: it.startDay.plusMonths(3)) } + }) + } + } + when (f.ruleEnd) { + EventForm.END_UNTIL -> DateTimeRow(stringResource(R.string.cal_ends_on), f.until ?: f.startDay.plusMonths(3), null, locale) { d, _ -> viewModel.editRule { it.copy(until = d) } } + EventForm.END_COUNT -> GcTextField( + f.count.toString(), + { v -> v.filter { it.isDigit() }.take(4).toIntOrNull()?.let { n -> viewModel.editRule { it.copy(count = n.coerceIn(1, 1000)) } } }, + stringResource(R.string.cal_ends_count), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + viewModel.rule(f)?.let { Text(ruleText(it, f.startDay), style = MaterialTheme.typography.bodySmall, color = extra.accentText) } + } +} + +@Composable +private fun ruleKindText(kind: String, start: LocalDate, nth: Pair): String = when (kind) { + MailPimLogic.RULE_NONE -> stringResource(R.string.cal_repeat_never) + MRule.DAILY -> stringResource(R.string.cal_repeat_daily) + MRule.WEEKLY -> stringResource(R.string.cal_repeat_weekly) + MailPimLogic.RULE_MONTHLY_DAY -> stringResource(R.string.cal_repeat_monthly_day, start.dayOfMonth) + MailPimLogic.RULE_MONTHLY_WEEKDAY -> ruleText(MRule(MRule.MONTHLY, byDay = listOf(nth.second), bySetPos = nth.first), start) + else -> stringResource(R.string.cal_repeat_yearly) +} + +/** Online: Teams (Microsoft 365 calendars) or a link. */ +@Composable +private fun OnlineBlock(cal: MCalendar?, teams: Boolean, url: String, onTeams: (Boolean) -> Unit, onUrl: (String) -> Unit) { + if (cal?.canOnline == true) { + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), verticalArrangement = Arrangement.Top) { + GcSwitchRow(label = stringResource(R.string.cal_teams), description = null, checked = teams, onCheckedChange = onTeams) + } + } + if (!teams) { + GcTextField(url, onUrl, stringResource(R.string.cal_online_link), placeholder = "https://…", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) + } +} + +/** The reminders (up to [max]): each one picked from the choices, "+" adds, "×" removes. */ +@Composable +private fun RemindersBlock(list: List, allDay: Boolean, max: Int, onChange: (List) -> Unit) { + val extra = GateControlTheme.extraColors + val choices = if (allDay) MailPimLogic.ALL_DAY_REMINDERS else MailPimLogic.REMINDERS + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.cal_reminders), style = MaterialTheme.typography.titleSmall, color = extra.muted) + if (list.isEmpty()) Text(stringResource(R.string.cal_no_reminder), style = MaterialTheme.typography.bodyMedium, color = extra.muted) + list.forEachIndexed { i, m -> + var open by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + PickerChip(reminderText(m, allDay), stringResource(R.string.cal_reminders)) { open = true } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + (choices + m).distinct().sorted().forEach { c -> + DropdownMenuItem(text = { Text(reminderText(c, allDay)) }, onClick = { open = false; onChange(list.toMutableList().also { l -> l[i] = c }.distinct()) }) + } + } + } + com.gatecontrol.android.ui.components.GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.mail_remove), onClick = { onChange(list - m) }, size = 40.dp) + } + } + if (list.size < max.coerceAtLeast(1)) { + TextButton(onClick = { onChange((list + (choices.firstOrNull { it !in list } ?: choices.first())).distinct()) }) { + Icon(GcIcons.Plus, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringResource(R.string.cal_add_reminder), modifier = Modifier.padding(start = 6.dp)) + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt new file mode 100644 index 00000000..edf45573 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt @@ -0,0 +1,444 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MAttendeeInput +import com.gatecontrol.android.mail.MCalendar +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MEvent +import com.gatecontrol.android.mail.MEventInput +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MRule +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import javax.inject.Inject + +/** The form of an event (contract D3 "Termin bearbeiten"). */ +data class EventForm( + val calendarId: Long? = null, + val title: String = "", + val allDay: Boolean = false, + val startDay: LocalDate = LocalDate.now(), + val startTime: LocalTime = LocalTime.of(9, 0), + val endDay: LocalDate = LocalDate.now(), + val endTime: LocalTime = LocalTime.of(10, 0), + val zone: String = ZoneId.systemDefault().id, + /** none | daily | weekly | monthly_day | monthly_weekday | yearly */ + val ruleKind: String = MailPimLogic.RULE_NONE, + val interval: Int = 1, + val byDay: List = emptyList(), + /** never | until | count */ + val ruleEnd: String = END_NEVER, + val until: LocalDate? = null, + val count: Int = 10, + val location: String = "", + val teams: Boolean = false, + val onlineUrl: String = "", + val reminders: List = emptyList(), + val attendees: List = emptyList(), + val free: Boolean = false, + val private: Boolean = false, + val description: String = "", +) { + companion object { + const val END_NEVER = "never" + const val END_UNTIL = "until" + const val END_COUNT = "count" + } +} + +data class EventEditState( + val form: EventForm = EventForm(), + val loading: Boolean = false, + val saving: Boolean = false, + /** The event being changed (null: a new one). */ + val existing: MEvent? = null, + /** The rule can do more than the form: only this occurrence, or replacing the whole rule. */ + val ruleLocked: Boolean = false, + val ruleTouched: Boolean = false, + val onlineTouched: Boolean = false, + /** A series' occurrence: ask "Nur dieser Termin" / "Alle Termine der Serie" before saving. */ + val askScope: Boolean = false, + /** `409 conflict`: the newer state (dialog "Der Termin wurde inzwischen geändert"). */ + val conflict: MEvent? = null, + val attendeeInput: String = "", + val suggestions: List = emptyList(), + val error: String? = null, + val message: String? = null, + /** Saved: the screen closes. */ + val done: Boolean = false, +) + +/** A new event or a change (contract D3): the form, the attendees' suggestions, saving with the etag, conflicts. */ +@HiltViewModel +class MailEventEditViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val calendars: StateFlow = pim.calendars + val deviceZone: ZoneId = ZoneId.systemDefault() + + private val id: Long? = saved.get(Screen.MailEventEdit.ARG_ID)?.toLongOrNull()?.takeIf { it > 0 } + private val occurrence: String? = saved.get(Screen.MailEventEdit.ARG_OCC)?.takeIf { it.isNotBlank() } + private val argScope: String? = saved.get(Screen.MailEventEdit.ARG_SCOPE) + + private val _state = MutableStateFlow(EventEditState()) + val state: StateFlow = _state.asStateFlow() + + private var suggestJob: Job? = null + /** The scope the person picked ("occurrence" | "series"), null until asked. */ + private var scope: String? = argScope?.takeIf { it == "occurrence" || it == "series" } + + val isNew: Boolean get() = id == null + + init { + if (pim.calendars.value == null) viewModelScope.launch { pim.refreshCalendars() } + if (id != null) load() else newForm(saved) + } + + private fun newForm(saved: SavedStateHandle) { + val cals = pim.calendars.value + val now = System.currentTimeMillis() + val start = saved.get(Screen.MailEventEdit.ARG_START)?.toLongOrNull() + val end = saved.get(Screen.MailEventEdit.ARG_END)?.toLongOrNull() + val allDay = saved.get(Screen.MailEventEdit.ARG_ALL_DAY) == "1" + val (a, b) = if (start != null) start to (end ?: (start + 3_600_000L)) else MailPimLogic.nextHour(now, deviceZone) + val s = MailPimLogic.local(a, deviceZone) + val e = MailPimLogic.local(maxOf(b, a), deviceZone) + val cal = saved.get(Screen.MailEventEdit.ARG_CAL)?.toLongOrNull() ?: defaultCalendar(cals) + val to = saved.get(Screen.MailEventEdit.ARG_TO)?.let { MailLogic.parseRecipients(it).addresses }.orEmpty() + val prefs = cals?.prefs + _state.update { + it.copy( + form = EventForm( + calendarId = cal, + allDay = allDay, + startDay = s.toLocalDate(), + startTime = s.toLocalTime(), + endDay = e.toLocalDate(), + endTime = e.toLocalTime(), + zone = deviceZone.id, + reminders = listOfNotNull(if (allDay) prefs?.allDayReminder else prefs?.defaultReminder ?: 15), + attendees = to.map { a -> MAttendeeInput(a.email, a.name) }, + ), + ) + } + } + + private fun defaultCalendar(cals: MCalendars?): Long? { + val writable = cals?.calendars?.filter { it.writable }.orEmpty() + return cals?.prefs?.defaultCalendarId?.takeIf { d -> writable.any { it.id == d } } ?: writable.firstOrNull { it.isDefault }?.id ?: writable.firstOrNull()?.id + } + + private fun load() { + val eventId = id ?: return + _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + when (val r = pim.event(eventId, occurrence)) { + is MResult.Ok -> fill(r.value) + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + /** The form from an event (also "Neu laden" of a conflict). */ + private fun fill(ev: MEvent) { + val o = ev.occ + val zone = MailPimLogic.zone(o.timeZone) ?: deviceZone + val form = if (o.allDay) { + val (a, b) = MailPimLogic.datesOf(o, deviceZone) + EventForm(startDay = a, endDay = b, allDay = true) + } else { + val s = MailPimLogic.local(o.start, zone) + val e = MailPimLogic.local(o.end, zone) + EventForm(startDay = s.toLocalDate(), startTime = s.toLocalTime(), endDay = e.toLocalDate(), endTime = e.toLocalTime()) + } + val rule = ev.recurrence?.rule + _state.update { + it.copy( + loading = false, + existing = ev, + ruleLocked = ev.recurrence?.editable == false, + ruleTouched = false, + onlineTouched = false, + conflict = null, + form = form.copy( + calendarId = o.calendarId, + title = o.title, + zone = zone.id, + ruleKind = MailPimLogic.kindOf(rule), + interval = rule?.interval ?: 1, + byDay = rule?.byDay.orEmpty(), + ruleEnd = when { + rule?.until != null -> EventForm.END_UNTIL + rule?.count != null -> EventForm.END_COUNT + else -> EventForm.END_NEVER + }, + until = MailPimLogic.date(rule?.until), + count = rule?.count ?: 10, + location = o.location.orEmpty(), + teams = o.online?.provider == "teams" && o.online.url.isBlank(), + onlineUrl = o.online?.url.orEmpty(), + reminders = ev.reminders, + attendees = ev.attendees.filter { a -> !a.self }.map { a -> MAttendeeInput(a.email, a.name.ifBlank { null }, a.role) }, + free = o.busy == "free", + private = o.private, + description = ev.description, + ), + ) + } + } + + fun edit(change: (EventForm) -> EventForm) = _state.update { it.copy(form = change(it.form)) } + + /** The rule changed (it is sent with the series only then). */ + fun editRule(change: (EventForm) -> EventForm) = _state.update { it.copy(form = change(it.form), ruleTouched = true) } + + fun editOnline(change: (EventForm) -> EventForm) = _state.update { it.copy(form = change(it.form), onlineTouched = true) } + + /** The start moves: the end keeps the length. */ + fun setStart(day: LocalDate, time: LocalTime) { + val f = _state.value.form + val zone = MailPimLogic.zone(f.zone) ?: deviceZone + val length = MailPimLogic.ms(f.endDay, f.endTime, zone) - MailPimLogic.ms(f.startDay, f.startTime, zone) + val endMs = MailPimLogic.ms(day, time, zone) + length.coerceAtLeast(0) + val e = MailPimLogic.local(endMs, zone) + val days = java.time.temporal.ChronoUnit.DAYS.between(f.startDay, f.endDay) + edit { + if (it.allDay) it.copy(startDay = day, endDay = day.plusDays(days.coerceAtLeast(0))) + else it.copy(startDay = day, startTime = time, endDay = e.toLocalDate(), endTime = e.toLocalTime()) + } + } + + // ─── attendees ────────────────────────────────────────────────── + + fun setAttendeeInput(v: String) { + val ends = v.endsWith(",") || v.endsWith(";") || v.endsWith("\n") + if (ends) { + commitAttendees(v) + return + } + _state.update { it.copy(attendeeInput = v) } + suggestJob?.cancel() + val q = v.trim() + if (q.length < 2) { + _state.update { it.copy(suggestions = emptyList()) } + return + } + suggestJob = viewModelScope.launch { + delay(250) + val found = api.contacts(q).valueOrNull().orEmpty() + val taken = _state.value.form.attendees.map { it.email.lowercase() }.toSet() + _state.update { it.copy(suggestions = found.filter { c -> c.group || c.email.lowercase() !in taken }) } + } + } + + fun commitAttendees(raw: String? = null) { + val parsed = MailLogic.parseRecipients(raw ?: _state.value.attendeeInput) + if (parsed.addresses.isEmpty() && raw == null) return + _state.update { s -> + val list = (s.form.attendees + parsed.addresses.map { MAttendeeInput(it.email, it.name) }).distinctBy { it.email.lowercase() } + s.copy(form = s.form.copy(attendees = list.take(100)), attendeeInput = parsed.rest, suggestions = emptyList()) + } + } + + /** A suggestion: its address, or a group's members. */ + fun pickAttendee(c: MContact) { + val add = if (c.group) c.members.map { MAttendeeInput(it.email, it.name) } else listOf(MAttendeeInput(c.email, c.name)) + _state.update { s -> + s.copy(form = s.form.copy(attendees = (s.form.attendees + add).distinctBy { it.email.lowercase() }.take(100)), attendeeInput = "", suggestions = emptyList()) + } + } + + fun removeAttendee(a: MAttendeeInput) = edit { it.copy(attendees = it.attendees - a) } + + /** The account the invitations go from ("Einladungen werden gesendet über …"), null when the provider sends them. */ + fun sender(me: MMe?, cal: MCalendar?): MAccount? { + cal ?: return null + cal.accountId?.let { return me?.account(it) } + val kind = if (cal.kind == "business") MAccount.KIND_BUSINESS else MAccount.KIND_PERSONAL + return me?.accounts?.firstOrNull { it.kind == kind } ?: me?.accounts?.firstOrNull() + } + + // ─── saving ───────────────────────────────────────────────────── + + /** The rule of the form (null: none). */ + fun rule(f: EventForm = _state.value.form): MRule? = MailPimLogic.ruleFor( + f.ruleKind, f.startDay, f.interval.coerceIn(1, 999), f.byDay, + until = f.until?.takeIf { f.ruleEnd == EventForm.END_UNTIL }?.toString(), + count = f.count.takeIf { f.ruleEnd == EventForm.END_COUNT }?.coerceIn(1, 1000), + ) + + /** What is wrong with the form (a text key), null when it can be saved. */ + fun problem(f: EventForm = _state.value.form): String? { + if (f.title.isBlank()) return "title" + if (f.calendarId == null) return "calendar" + val zone = MailPimLogic.zone(f.zone) ?: return "zone" + if (f.allDay) { + if (f.endDay.isBefore(f.startDay)) return "end" + } else if (MailPimLogic.ms(f.endDay, f.endTime, zone) < MailPimLogic.ms(f.startDay, f.startTime, zone)) return "end" + return null + } + + /** The form's start and end in ms (all-day: the midnights of the first day and after the last). */ + private fun times(f: EventForm): Pair { + val zone = MailPimLogic.zone(f.zone) ?: deviceZone + return if (f.allDay) MailPimLogic.range(f.startDay, f.endDay, deviceZone) + else MailPimLogic.ms(f.startDay, f.startTime, zone) to MailPimLogic.ms(f.endDay, f.endTime, zone) + } + + /** Whether the time of a shown occurrence was changed in the form. */ + fun timeChanged(s: EventEditState = _state.value): Boolean { + val ev = s.existing ?: return true + val (a, b) = times(s.form) + val o = ev.occ + val (oa, ob) = if (o.allDay) MailPimLogic.datesOf(o, deviceZone).let { (x, y) -> MailPimLogic.range(x, y, deviceZone) } else o.start to o.end + return s.form.allDay != o.allDay || a != oa || b != ob || (!s.form.allDay && s.form.zone != (o.timeZone ?: s.form.zone)) + } + + /** + * Whether the whole series may take this change: a changed time needs the series' own times (`Event.series`, + * Mail 1.3.0) to move it by as much; without them only this occurrence can change its time. + */ + fun seriesAllowed(s: EventEditState = _state.value): Boolean = + s.existing?.occ?.occurrence == null || !timeChanged(s) || s.existing.series != null + + private fun input(s: EventEditState, series: Boolean): MEventInput { + val f = s.form + val zone = MailPimLogic.zone(f.zone) ?: deviceZone + val (sd, ed) = MailPimLogic.allDayDates(f.startDay, f.endDay) + val sendRule = s.existing == null || (series && s.ruleTouched) + val sendOnline = s.existing == null || s.onlineTouched + val ev = s.existing + // the whole series of a shown occurrence: its time only when changed, moved from the series' own times + val seriesOfOcc = series && ev != null && ev.occ.occurrence != null + val sendTime = !seriesOfOcc || (timeChanged(s) && ev?.series != null) + val (start0, end0) = times(f) + val shift = if (seriesOfOcc && ev?.series != null) { + MailPimLogic.shiftSeries(ev.series, ev.occ, f.allDay, start0, end0, sd, ed, zone) + } else { + null + } + val start = shift?.start ?: start0 + val end = shift?.end ?: end0 + val ssd = shift?.startDate ?: sd + val sed = shift?.endDate ?: ed + return MEventInput( + calendarId = f.calendarId.takeIf { s.existing == null }, + title = f.title.trim(), + allDay = f.allDay.takeIf { sendTime }, + start = start.takeIf { !f.allDay && sendTime }, + end = end.takeIf { !f.allDay && sendTime }, + startDate = ssd.takeIf { f.allDay && sendTime }, + endDate = sed.takeIf { f.allDay && sendTime }, + timeZone = zone.id.takeIf { sendTime }, + location = f.location.trim().takeIf { it.isNotEmpty() || s.existing != null }, + description = f.description.takeIf { it.isNotEmpty() || s.existing != null }, + teams = f.teams, + onlineUrl = f.onlineUrl.trim(), + onlineSet = sendOnline && (s.existing != null || f.teams || f.onlineUrl.isNotBlank()), + reminders = f.reminders, + recurrence = rule(f), + recurrenceSet = sendRule && (s.existing != null || f.ruleKind != MailPimLogic.RULE_NONE), + attendees = f.attendees, + busy = if (f.free) "free" else "busy", + private = f.private, + sendInvites = if (f.attendees.isNotEmpty()) true else null, + ) + } + + /** Saving: a series' occurrence asks first ("Nur dieser" / "Alle"). */ + fun save() { + val s = _state.value + if (s.saving || problem() != null) return + commitAttendees(s.attendeeInput.takeIf { it.isNotBlank() }) + val ev = s.existing + if (ev != null && ev.occ.occurrence != null && scope == null) { + _state.update { it.copy(askScope = true) } + return + } + send(etag = ev?.etag) + } + + fun pickScope(series: Boolean) { + if (series && !seriesAllowed()) return + scope = if (series) "series" else "occurrence" + _state.update { it.copy(askScope = false) } + send(etag = _state.value.existing?.etag) + } + + fun dismissScope() = _state.update { it.copy(askScope = false) } + + private fun send(etag: String?) { + val s = _state.value + _state.update { it.copy(saving = true) } + viewModelScope.launch { + val ev = s.existing + val r = if (ev == null) { + api.createEvent(MailPimJson.eventBody(input(s, series = true))) + } else { + val sc = scope ?: "series" + val occ = ev.occ.occurrence.takeIf { sc == "occurrence" } + api.updateEvent(ev.id, MailPimJson.eventBody(input(s, series = sc == "series"), scope = if (ev.occ.occurrence != null) sc else null, occurrence = occ, etag = etag)) + } + when (r) { + is MResult.Ok -> _state.update { it.copy(saving = false, done = true) } + is MResult.Failed -> { + val current = if (r.code == "conflict") MailPimJson.conflictEvent(r.error) else null + // Mail 1.3.0: the series cannot take this move (`invalid { field: 'start', reason: 'series_shift' }`) + val shiftRefused = r.code == "invalid" && r.error?.get("reason")?.takeIf { it.isJsonPrimitive }?.asString == SERIES_SHIFT + when { + current != null -> _state.update { it.copy(saving = false, conflict = current) } + shiftRefused -> _state.update { it.copy(saving = false, message = SERIES_SHIFT) } + else -> _state.update { it.copy(saving = false, message = r.code) } + } + } + else -> _state.update { it.copy(saving = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** Conflict: "Meine Änderung erneut anwenden" (the form over the newer state). */ + fun applyAgain() { + val current = _state.value.conflict ?: return + _state.update { it.copy(conflict = null, existing = current) } + send(etag = current.etag) + } + + /** Conflict: "Neu laden" (the newer state into the form; the own change is gone). */ + fun reloadConflict() { + val current = _state.value.conflict ?: return + fill(current) + } + + fun dismissConflict() = _state.update { it.copy(conflict = null) } + + fun clearMessage() = _state.update { it.copy(message = null) } + + companion object { + const val SERIES_SHIFT = "series_shift" + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventScreen.kt new file mode 100644 index 00000000..5173b11f --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventScreen.kt @@ -0,0 +1,342 @@ +package com.gatecontrol.android.ui.mail.calendar + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.LaunchedEffect +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAttendee +import com.gatecontrol.android.mail.MEvent +import com.gatecontrol.android.mail.MOccurrence +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.MailHtmlView +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.openExternal +import com.gatecontrol.android.ui.mail.openLink +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.mail.contacts.ContactAvatar +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import java.time.Instant + +/** Where an event leads. */ +data class EventNav( + val back: () -> Unit, + /** "Bearbeiten" (the occurrence; the edit asks this one or the series before saving). */ + val edit: (eventId: Long, occurrence: String?) -> Unit, + /** An attendee or the organizer: their contact, else "Als Kontakt speichern". */ + val contact: (contactId: Long) -> Unit, + val saveContact: (email: String, name: String?) -> Unit, + val mailTo: (String) -> Unit, +) + +/** An event (contract D3 "Termin ansehen"). */ +@Composable +fun MailEventScreen(nav: EventNav, viewModel: MailEventViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + val context = mailContext() + var deleting by remember { mutableStateOf(false) } + LaunchedEffect(state.message) { + state.message?.let { + toast(context, if (it == "cal_answered") context.getString(R.string.cal_answered) else mailText(context, it)) + viewModel.clearMessage() + } + } + LaunchedEffect(state.gone) { if (state.gone) nav.back() } + val ev = state.event + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.cal_event), onBack = nav.back, backDescription = stringResource(R.string.mail_back)) { + if (ev != null && ev.canEdit) { + GcIconButton(GcIcons.Edit, contentDescription = stringResource(R.string.cal_edit), onClick = { nav.edit(ev.id, ev.occ.occurrence) }, enabled = !offline) + } + if (ev != null && ev.canDelete) { + GcIconButton(GcIcons.Trash, contentDescription = stringResource(R.string.mail_delete), onClick = { deleting = true }, enabled = !offline && !state.busy) + } + } + when { + ev != null -> EventBody(ev, cals?.calendar(ev.occ.calendarId)?.let { it.name to it.color }, viewModel, nav, offline, state.busy) + state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + else -> ErrorBox(mailText(context, state.error), onRetry = { viewModel.load() }) + } + } + if (deleting && ev != null) DeleteDialog(ev, onDelete = { series, notify, comment -> deleting = false; viewModel.delete(series, notify, comment) }, onDismiss = { deleting = false }) +} + +@Composable +private fun EventBody(ev: MEvent, calendar: Pair?, viewModel: MailEventViewModel, nav: EventNav, offline: Boolean, busy: Boolean) { + val extra = GateControlTheme.extraColors + val context = mailContext() + val zone = viewModel.zone + val o = ev.occ + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + o.title.ifBlank { stringResource(R.string.cal_no_title) }, + style = MaterialTheme.typography.headlineSmall, + textDecoration = if (o.cancelled) TextDecoration.LineThrough else null, + modifier = Modifier.semantics { heading() }, + ) + if (o.cancelled) Text(stringResource(R.string.cal_cancelled), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + calendar?.let { (name, color) -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.size(10.dp).clip(RoundedCornerShape(3.dp)).background(calColor(color))) + Text(name, style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + } + } + GcCard(contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + InfoRow(GcIcons.Clock, occurrenceWhen(o, zone), sub = otherZoneText(o, viewModel)) + ev.recurrence?.let { r -> + val start = MailPimLogic.date(o.startDate) ?: Instant.ofEpochMilli(o.start).atZone(zone).toLocalDate() + InfoRow(GcIcons.Refresh, ruleText(r.rule, start)) + } + o.location?.let { loc -> + InfoRow(GcIcons.Map, loc, action = stringResource(R.string.cal_open_map)) { + openExternal(context, "geo:0,0?q=" + Uri.encode(loc)) + } + } + o.online?.let { on -> + InfoRow(GcIcons.Link, stringResource(R.string.cal_online_meeting, providerName(on.provider)), action = stringResource(R.string.cal_join)) { openLink(context, on.url) } + } + if (ev.reminders.isNotEmpty()) { + InfoRow(GcIcons.Bell, ev.reminders.map { reminderText(it, o.allDay) }.joinToString(", ")) + } + if (o.busy == "free") InfoRow(GcIcons.Eye, stringResource(R.string.cal_free)) + if (o.private) InfoRow(GcIcons.Lock, stringResource(R.string.cal_private)) + } + if (ev.canRespond) { + RespondBlock(o.myResponse, enabled = !offline && !busy, onRespond = viewModel::respond) + } + if (ev.organizer != null || ev.attendees.isNotEmpty()) { + val (yes, all) = MailPimLogic.acceptedOf(ev.attendees) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.cal_attendees) + if (all > 0) " · " + stringResource(R.string.cal_accepted_of, yes, all) else "", + style = MaterialTheme.typography.labelMedium, + color = extra.muted, + modifier = Modifier.semantics { heading() }, + ) + GcCard(contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 6.dp), verticalArrangement = Arrangement.Top) { + ev.organizer?.let { org -> + PersonRow(org.label, org.email, stringResource(R.string.cal_resp_organizer), null) { org.contactId?.let(nav.contact) ?: nav.saveContact(org.email, org.name.ifBlank { null }) } + } + ev.attendees.filter { it.email != ev.organizer?.email }.forEach { a -> + PersonRow(a.label, a.email, if (a.role == MAttendee.ROLE_RESOURCE) stringResource(R.string.cal_resource) else responseText(a.response), a.response) { + a.contactId?.let(nav.contact) ?: nav.saveContact(a.email, a.name.ifBlank { null }) + } + } + } + } + } + if (ev.descriptionHtml != null) { + MailHtmlView(ev.descriptionHtml, loader = { null }, onLink = { openLink(context, it) }, modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp)) + } else if (ev.description.isNotBlank()) { + SelectionContainer { Text(ev.description, style = MaterialTheme.typography.bodyMedium) } + } + if (ev.readOnly) Text(stringResource(R.string.cal_read_only_hint), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Box(Modifier.height(24.dp)) + } +} + +@Composable +private fun otherZoneText(o: MOccurrence, viewModel: MailEventViewModel): String? { + if (o.allDay) return null + val z = MailPimLogic.otherZone(o.timeZone, viewModel.zone, o.start) ?: return null + return "${clock(o.start, z, currentLocale())}–${clock(o.end, z, currentLocale())} · ${z.id} (${MailPimLogic.offsetLabel(z, o.start)})" +} + +@Composable +internal fun providerName(p: String): String = when (p) { + "teams" -> "Teams" + "zoom" -> "Zoom" + "meet" -> "Google Meet" + "webex" -> "Webex" + else -> stringResource(R.string.cal_online_other) +} + +@Composable +private fun InfoRow(icon: ImageVector, text: String, sub: String? = null, action: String? = null, onClick: (() -> Unit)? = null) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp) + .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClickLabel = action, onClick = onClick) else Modifier) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(icon, contentDescription = null, tint = extra.muted, modifier = Modifier.size(20.dp)) + Column(Modifier.weight(1f)) { + Text(text, style = MaterialTheme.typography.bodyMedium) + if (sub != null) Text(sub, style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + if (action != null) Text(action, style = MaterialTheme.typography.labelLarge, color = extra.accentText, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +internal fun PersonRow(name: String, email: String, state: String?, response: String?, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(role = Role.Button, onClick = onClick).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ContactAvatar(name, email, size = 28.dp) + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + if (name != email) Text(email, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1) + } + if (state != null) { + Text( + state, + style = MaterialTheme.typography.bodySmall, + color = when (response) { + MOccurrence.RESPONSE_ACCEPTED -> extra.accentText + MOccurrence.RESPONSE_DECLINED -> MaterialTheme.colorScheme.error + MOccurrence.RESPONSE_TENTATIVE -> extra.warn + else -> extra.muted + }, + ) + } + } +} + +/** Zusagen / Mit Vorbehalt / Absagen with a comment (mockup "Einladung"); the own answer marked. */ +@Composable +internal fun RespondBlock(mine: String?, enabled: Boolean, onRespond: (String, String?) -> Unit) { + val extra = GateControlTheme.extraColors + var comment by remember { mutableStateOf("") } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (mine != null && mine != MOccurrence.RESPONSE_NEEDS_ACTION && mine != MOccurrence.RESPONSE_ORGANIZER) { + Text(stringResource(R.string.cal_you_answered, responseText(mine)), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + ResponseButtons(mine, enabled) { onRespond(it, comment.ifBlank { null }) } + GcTextField(comment, { comment = it.take(2000) }, stringResource(R.string.cal_comment), placeholder = stringResource(R.string.cal_comment_hint), singleLine = false) + } +} + +@Composable +internal fun ResponseButtons(mine: String?, enabled: Boolean, onRespond: (String) -> Unit) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val yes = mine == MOccurrence.RESPONSE_ACCEPTED + val maybe = mine == MOccurrence.RESPONSE_TENTATIVE + val no = mine == MOccurrence.RESPONSE_DECLINED + val m = Modifier.weight(1f) + if (yes || mine == null || mine == MOccurrence.RESPONSE_NEEDS_ACTION) { + GcPrimaryButton(stringResource(R.string.cal_accept), onClick = { onRespond(MOccurrence.RESPONSE_ACCEPTED) }, enabled = enabled, modifier = m, minHeight = 48.dp) + } else { + GcOutlineButton(stringResource(R.string.cal_accept), onClick = { onRespond(MOccurrence.RESPONSE_ACCEPTED) }, enabled = enabled, modifier = m) + } + if (maybe) { + GcPrimaryButton(stringResource(R.string.cal_tentative), onClick = { onRespond(MOccurrence.RESPONSE_TENTATIVE) }, enabled = enabled, modifier = m) + } else { + GcOutlineButton(stringResource(R.string.cal_tentative), onClick = { onRespond(MOccurrence.RESPONSE_TENTATIVE) }, enabled = enabled, modifier = m) + } + if (no) { + GcPrimaryButton(stringResource(R.string.cal_decline), onClick = { onRespond(MOccurrence.RESPONSE_DECLINED) }, enabled = enabled, modifier = m) + } else { + GcOutlineButton(stringResource(R.string.cal_decline), onClick = { onRespond(MOccurrence.RESPONSE_DECLINED) }, enabled = enabled, modifier = m) + } + } +} + +/** Delete: this occurrence or the series; with attendees whether they hear of it, and a comment. */ +@Composable +private fun DeleteDialog(ev: MEvent, onDelete: (series: Boolean, notify: Boolean, comment: String?) -> Unit, onDismiss: () -> Unit) { + var series by remember { mutableStateOf(ev.occ.occurrence == null) } + val people = ev.attendees.any { !it.self } + var notify by remember { mutableStateOf(true) } + var comment by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.cal_delete_event)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (ev.occ.occurrence != null) { + ScopeChoice(series) { series = it } + } + if (people) { + Row(Modifier.fillMaxWidth().selectable(selected = notify, role = Role.Checkbox) { notify = !notify }, verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = notify, onCheckedChange = null) + Text(stringResource(if (ev.canRespond) R.string.cal_delete_tell_organizer else R.string.cal_delete_tell), modifier = Modifier.padding(start = 8.dp)) + } + if (notify) GcTextField(comment, { comment = it.take(1000) }, stringResource(R.string.cal_comment), singleLine = false) + } + } + }, + confirmButton = { TextButton(onClick = { onDelete(series, people && notify, comment.ifBlank { null }) }) { Text(stringResource(R.string.mail_delete), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} + +/** "Nur dieser Termin" / "Alle Termine der Serie". */ +@Composable +internal fun ScopeChoice(series: Boolean, seriesEnabled: Boolean = true, onPick: (Boolean) -> Unit) { + Column { + listOf(false to R.string.cal_scope_one, true to R.string.cal_scope_series).forEach { (v, label) -> + val enabled = !v || seriesEnabled + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).selectable(selected = series == v, enabled = enabled, role = Role.RadioButton) { onPick(v) }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = series == v, onClick = null, enabled = enabled) + Text(stringResource(label), modifier = Modifier.padding(start = 8.dp)) + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventViewModel.kt new file mode 100644 index 00000000..bcdc6052 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventViewModel.kt @@ -0,0 +1,102 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MEvent +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.ZoneId +import javax.inject.Inject + +data class EventState( + val event: MEvent? = null, + val loading: Boolean = true, + val error: String? = null, + /** An answer or a delete on its way. */ + val busy: Boolean = false, + val message: String? = null, + /** Deleted: the screen closes. */ + val gone: Boolean = false, +) + +/** One event (contract D3 "Termin ansehen"): load it, answer (with a comment), delete (this one or the series). */ +@HiltViewModel +class MailEventViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val calendars: StateFlow = pim.calendars + val offline: StateFlow = pim.offline + val zone: ZoneId = ZoneId.systemDefault() + + val id: Long = saved.get(Screen.MailEvent.ARG_ID)?.toLongOrNull() ?: 0L + val occurrence: String? = saved.get(Screen.MailEvent.ARG_OCC)?.takeIf { it.isNotBlank() } + + private val _state = MutableStateFlow(EventState()) + val state: StateFlow = _state.asStateFlow() + + init { + if (pim.calendars.value == null) pim.refreshCalendarsSoon() + load() + viewModelScope.launch { + pim.calChanged.collect { c -> if (c.eventId == null || c.eventId == id) load(quiet = true) } + } + } + + fun load(quiet: Boolean = false) { + if (id <= 0) { + _state.update { it.copy(loading = false, error = "not_found") } + return + } + if (!quiet) _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + when (val r = pim.event(id, occurrence)) { + is MResult.Ok -> _state.update { it.copy(event = r.value, loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + /** Zusagen / Mit Vorbehalt / Absagen (with a comment; the occurrence of a series only this one). */ + fun respond(response: String, comment: String?) { + val ev = _state.value.event ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.respondEvent(ev.id, MailPimJson.respondBody(response, comment, occurrence = ev.occ.occurrence))) { + is MResult.Ok -> _state.update { it.copy(event = r.value, busy = false, message = "cal_answered") } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** Delete this occurrence or the whole series ([notify]: the attendees hear of it / the organizer gets a no). */ + fun delete(series: Boolean, notify: Boolean, comment: String?) { + val ev = _state.value.event ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val scope = if (series || ev.occ.occurrence == null) "series" else "occurrence" + when (val r = api.deleteEvent(ev.id, scope, ev.occ.occurrence.takeIf { scope == "occurrence" }, notify, comment)) { + is MResult.Ok -> _state.update { it.copy(busy = false, gone = true) } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + fun clearMessage() = _state.update { it.copy(message = null) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailInviteViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailInviteViewModel.kt new file mode 100644 index 00000000..5069f248 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailInviteViewModel.kt @@ -0,0 +1,126 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MInvitation +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.ZoneId +import javax.inject.Inject + +data class InviteState( + val invitation: MInvitation? = null, + val loading: Boolean = false, + val busy: Boolean = false, + val error: String? = null, + val message: String? = null, + /** The calendar it goes into ("Eintragen in"), null: the server's choice. */ + val calendarId: Long? = null, + /** "Ändern" of an answered one: the buttons again. */ + val changing: Boolean = false, +) + +/** + * The card of an invitation (mockup "Einladung", contract C6/D3): answer with a comment and a calendar, propose + * another time, take over a counter proposal, remove a cancelled one, dismiss. In a mail it starts from + * `Message.invite`; from a push it loads by id. + */ +@HiltViewModel +class MailInviteViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val calendars: StateFlow = pim.calendars + val offline: StateFlow = pim.offline + val zone: ZoneId = ZoneId.systemDefault() + + private val _state = MutableStateFlow(InviteState()) + val state: StateFlow = _state.asStateFlow() + + init { + if (pim.calendars.value == null) pim.refreshCalendarsSoon() + saved.get(Screen.MailInvitation.ARG_ID)?.toLongOrNull()?.takeIf { it > 0 }?.let(::load) + viewModelScope.launch { + pim.calChanged.collect { c -> + val inv = _state.value.invitation ?: return@collect + if (c.invitationId == inv.id) load(inv.id, quiet = true) + } + } + } + + /** The invitation of a mail (shown at once; changes come with the mail or the live event). */ + fun set(inv: MInvitation) { + val cur = _state.value.invitation + if (cur != null && cur.id == inv.id && cur.state == inv.state && cur.myResponse == inv.myResponse) return + _state.update { it.copy(invitation = inv, calendarId = it.calendarId ?: inv.calendarId) } + } + + fun load(id: Long, quiet: Boolean = false) { + if (!quiet) _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + when (val r = api.invitation(id)) { + is MResult.Ok -> _state.update { it.copy(invitation = r.value, loading = false, calendarId = it.calendarId ?: r.value.calendarId) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + fun pickCalendar(id: Long) = _state.update { it.copy(calendarId = id) } + + fun change() = _state.update { it.copy(changing = true) } + + /** Zusagen / Mit Vorbehalt / Absagen (also with a proposed time). */ + fun respond(response: String, comment: String?, proposeStart: Long? = null, proposeEnd: Long? = null) { + val inv = _state.value.invitation ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val body = MailPimJson.respondBody(response, comment, proposeStart = proposeStart, proposeEnd = proposeEnd, calendarId = _state.value.calendarId) + when (val r = api.respondInvitation(inv.id, body)) { + is MResult.Ok -> _state.update { it.copy(invitation = r.value.invitation, busy = false, changing = false, message = "cal_answered") } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** counter: take the proposal; cancel: remove the event from the calendar. */ + fun apply() { + val inv = _state.value.invitation ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.applyInvitation(inv.id)) { + is MResult.Ok -> _state.update { it.copy(invitation = r.value.invitation, busy = false) } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** counter: decline the proposal ([decline]); otherwise only hide the card. */ + fun dismiss(decline: Boolean) { + val inv = _state.value.invitation ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.dismissInvitation(inv.id, decline)) { + is MResult.Ok -> load(inv.id, quiet = true).also { _state.update { s -> s.copy(busy = false) } } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + fun clearMessage() = _state.update { it.copy(message = null) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactComponents.kt new file mode 100644 index 00000000..bcb23826 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactComponents.kt @@ -0,0 +1,145 @@ +package com.gatecontrol.android.ui.mail.contacts + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MailEntryPoint +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.mail.accountColor +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.EntryPointAccessors + +/** The colors of initials (like the mockup), picked by the name. */ +private val INITIAL_COLORS = listOf(0xFF79B8FF, 0xFF46E0A0, 0xFFF5B544, 0xFFB79CFF, 0xFFFF8080, 0xFF8C96A5) + +internal fun initialsColor(seed: String): Color = Color(INITIAL_COLORS[(seed.lowercase().hashCode() and 0x7fffffff) % INITIAL_COLORS.size]) + +/** Decoded contact photos while the app runs (the files are in the offline copy). */ +private object CardPhotos { + val memory = object : LruCache(8 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount + } +} + +/** A contact's photo ([id] with its [etag]); null without one, while loading or offline without a copy. */ +@Composable +internal fun rememberCardPhoto(id: Long?, etag: String?): ImageBitmap? { + val context = LocalContext.current.applicationContext + val preview = LocalInspectionMode.current + val repo = remember(context, preview) { + if (preview) null else runCatching { EntryPointAccessors.fromApplication(context, MailEntryPoint::class.java).mailPim() }.getOrNull() + } + val key = if (id != null && etag != null) "$id:$etag" else null + val bitmap by produceState(key?.let { CardPhotos.memory.get(it) }?.asImageBitmap(), key, repo) { + if (key == null || id == null || etag == null || repo == null || value != null) return@produceState + val p = repo.photo(id, etag) ?: return@produceState + val bmp = runCatching { BitmapFactory.decodeByteArray(p.bytes, 0, p.bytes.size) }.getOrNull() ?: return@produceState + CardPhotos.memory.put(key, bmp) + value = bmp.asImageBitmap() + } + return bitmap +} + +/** Round: the contact's photo, else the initials on a color of the name. */ +@Composable +internal fun ContactAvatar(name: String, email: String?, photoId: Long? = null, photoEtag: String? = null, size: Dp = 40.dp) { + val photo = rememberCardPhoto(photoId, photoEtag) + Box( + Modifier.size(size).clip(CircleShape).background(initialsColor(name.ifBlank { email.orEmpty() })), + contentAlignment = Alignment.Center, + ) { + if (photo != null) { + Image(photo, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(size)) + } else { + Text( + MailLogic.initials(name.ifBlank { null }, email.orEmpty().ifBlank { "?" }), + color = Color(0xFF0D1015), + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.36f).sp, + ) + } + } +} + +/** The color dot of a contact's source: the account's color; own contacts have none. */ +@Composable +internal fun sourceColor(me: MMe?, accountId: Long?): Color? = me?.account(accountId)?.let { accountColor(it) } + +/** The account a source belongs to (its label for "aus "). */ +internal fun accountLabel(me: MMe?, accountId: Long?): String? = me?.account(accountId)?.let(MAccount::label) + +/** + * A suggestion while typing addresses (writing, attendees): photo or initials, name, the address and where it comes + * from; a group as "Familie (5)" with its members. + */ +@Composable +internal fun SuggestionRow(c: MContact, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (c.group) { + Box(Modifier.size(32.dp).clip(RoundedCornerShape(10.dp)).background(extra.accentBg), contentAlignment = Alignment.Center) { + Icon(GcIcons.Users, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(18.dp)) + } + } else { + ContactAvatar(c.name.orEmpty(), c.email, size = 32.dp) + } + Column(Modifier.weight(1f)) { + Text(c.name ?: c.email, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val sub = if (c.group) c.members.joinToString(", ") { it.label } else listOfNotNull(c.email.takeIf { c.name != null }, sourceLabel(c.source)).joinToString(" · ") + if (sub.isNotEmpty()) Text(sub, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun sourceLabel(source: String): String? = when (source) { + MContact.SOURCE_CONTACT -> stringResource(R.string.pim_source_contact) + MContact.SOURCE_PERSON -> stringResource(R.string.pim_source_person) + MContact.SOURCE_RECENT -> stringResource(R.string.pim_source_recent) + else -> null +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactsDrawer.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactsDrawer.kt new file mode 100644 index 00000000..7ebdd632 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/ContactsDrawer.kt @@ -0,0 +1,57 @@ +package com.gatecontrol.android.ui.mail.contacts + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MBooks +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.mail.accountColor +import com.gatecontrol.android.ui.mail.calendar.CheckRow +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** + * The contacts' part of the menu (mockup "Seitenmenü neu"): "Posteingänge" back to the mail, "Alle Kontakte", the + * address books with a check (shown in the list) and their count, the groups with their count, "Dubletten". + */ +internal fun LazyListScope.contactsDrawerItems( + me: MMe?, + books: MBooks?, + total: Int, + duplicates: Int, + filter: ContactsFilter, + inboxRow: @Composable () -> Unit, + label: @Composable (String) -> Unit, + row: @Composable (icon: androidx.compose.ui.graphics.vector.ImageVector?, label: String, count: Int, on: Boolean, onClick: () -> Unit) -> Unit, + onFilter: (ContactsFilter) -> Unit, + onToggleBook: (Long) -> Unit, + onDuplicates: () -> Unit, +) { + item(key = "con-inbox") { inboxRow() } + item(key = "con-all") { row(GcIcons.User, stringResource(R.string.con_all), total, filter == ContactsFilter()) { onFilter(ContactsFilter()) } } + books ?: return + if (books.items.isNotEmpty()) { + item(key = "con-books") { label(stringResource(R.string.con_books_label)) } + books.items.forEach { b -> + item(key = "book${b.id}") { + val color = me?.account(b.accountId)?.let { accountColor(it) } ?: GateControlTheme.extraColors.accentText + CheckRow( + label = b.name, + color = color, + checked = b.visible, + trailing = b.count.toString(), + onClick = { onToggleBook(b.id) }, + onLongClick = { onFilter(ContactsFilter(book = b.id)) }, + ) + } + } + } + if (books.groups.isNotEmpty()) { + item(key = "con-groups") { label(stringResource(R.string.con_groups)) } + books.groups.forEach { g -> + item(key = "grp${g.id}") { row(null, g.name, g.count, filter.group == g.id) { onFilter(ContactsFilter(group = g.id)) } } + } + } + item(key = "con-dup") { row(GcIcons.Users, stringResource(R.string.con_duplicates), duplicates, false, onDuplicates) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditScreen.kt new file mode 100644 index 00000000..dff3be58 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditScreen.kt @@ -0,0 +1,388 @@ +package com.gatecontrol.android.ui.mail.contacts + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.graphics.scale +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MCard +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.mail.SheetRow +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.mail.calendar.calColor +import com.gatecontrol.android.ui.theme.GateControlTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +/** The photo as the server takes it: at most 512 px, JPEG, at most 256 KB. */ +private const val PHOTO_PX = 512 +private const val PHOTO_MAX = 256 * 1024 + +/** + * A new contact or a change (mockup "Kontakt bearbeiten"): photo (camera or gallery, 512 px JPEG), first and last + * name, company, position, several addresses and numbers with their kind, postal addresses, birthday (the year + * optional), note, groups and the address book of a new one. Saving sends the etag; a conflict offers "Neu laden" + * and "Meine Änderung erneut anwenden". + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MailContactEditScreen(onClose: () -> Unit, onSaved: (Long) -> Unit, viewModel: MailContactEditViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val books by viewModel.books.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val context = mailContext() + val scope = rememberCoroutineScope() + val extra = GateControlTheme.extraColors + var photoSheet by remember { mutableStateOf(false) } + var deleting by remember { mutableStateOf(false) } + val c = state.card + LaunchedEffect(state.done) { + state.done?.let { if (it > 0) onSaved(it) else onClose() } + } + LaunchedEffect(state.message) { + state.message?.let { + toast(context, mailText(context, it)) + viewModel.clearMessage() + } + } + val gallery = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + uri ?: return@rememberLauncherForActivityResult + scope.launch { + val bytes = withContext(Dispatchers.IO) { readPhoto(context, uri) } + if (bytes != null) viewModel.setPhoto(PhotoChange.Set(bytes)) else toast(context, mailText(context, "too_large")) + } + } + val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bmp -> + bmp ?: return@rememberLauncherForActivityResult + scope.launch { withContext(Dispatchers.Default) { jpeg(bmp) }?.let { viewModel.setPhoto(PhotoChange.Set(it)) } } + } + // the app holds CAMERA (QR codes): the camera app may only be asked once it is granted + val cameraPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) runCatching { camera.launch(null) } else toast(context, context.getString(R.string.con_photo_no_camera)) + } + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar(title = stringResource(if (viewModel.isNew) R.string.con_new else R.string.con_edit_title), onBack = onClose, backDescription = stringResource(R.string.mail_close)) { + TextButton(onClick = viewModel::save, enabled = viewModel.canSave(state)) { + if (state.saving) CircularProgressIndicator(Modifier.size(18.dp)) else Text(stringResource(R.string.mail_save), fontWeight = FontWeight.Bold) + } + } + if (state.loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + return@Column + } + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + state.existing?.let { ex -> + GcBanner(tone = GcTone.Info, icon = GcIcons.User) { + Text(stringResource(R.string.con_exists, ex.label), style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + TextButton(onClick = viewModel::addToExisting) { Text(stringResource(R.string.con_add_to, ex.label), fontWeight = FontWeight.Bold) } + } + } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.size(88.dp)) { + val p = state.photo + when { + p is PhotoChange.Set -> { + val bmp = remember(p) { BitmapFactory.decodeByteArray(p.bytes, 0, p.bytes.size)?.asImageBitmap() } + if (bmp != null) Image(bmp, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(88.dp).clip(CircleShape)) + } + p == PhotoChange.Remove -> ContactAvatar(c.label, c.emails.firstOrNull()?.value, size = 88.dp) + else -> ContactAvatar(c.label, c.emails.firstOrNull()?.value, c.id.takeIf { c.photo }, c.photoEtag, size = 88.dp) + } + val desc = stringResource(R.string.con_photo) + Box( + Modifier.align(Alignment.BottomEnd).offset(2.dp, 2.dp).size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary) + .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) + .clickable(role = Role.Button) { photoSheet = true }.semantics { contentDescription = desc }, + contentAlignment = Alignment.Center, + ) { Icon(GcIcons.Camera, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(16.dp)) } + } + val book = books?.book(if (viewModel.isNew) state.addressbookId else c.addressbookId) + if (!viewModel.isNew && book != null && !book.local) { + Text(stringResource(R.string.con_goes_to, me?.account(book.accountId)?.label ?: book.name), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + } + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcTextField(c.name.given, { v -> viewModel.edit { it.copy(name = it.name.copy(given = v.take(200), display = "")) } }, stringResource(R.string.con_given), modifier = Modifier.weight(1f)) + GcTextField(c.name.family, { v -> viewModel.edit { it.copy(name = it.name.copy(family = v.take(200), display = "")) } }, stringResource(R.string.con_family), modifier = Modifier.weight(1f)) + } + GcTextField(c.company.orEmpty(), { v -> viewModel.edit { it.copy(company = v.take(200)) } }, stringResource(R.string.con_company)) + GcTextField(c.title.orEmpty(), { v -> viewModel.edit { it.copy(title = v.take(200)) } }, stringResource(R.string.con_position)) + } + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Label(stringResource(R.string.con_email)) + c.emails.forEachIndexed { i, e -> + MultiRow( + type = e.type, types = MCard.EMAIL_TYPES, value = e.value, label = stringResource(R.string.con_email), + keyboard = KeyboardType.Email, + onType = { t -> viewModel.edit { it.copy(emails = it.emails.toMutableList().also { l -> l[i] = e.copy(type = t) }) } }, + onValue = { v -> viewModel.edit { it.copy(emails = it.emails.toMutableList().also { l -> l[i] = e.copy(value = v.trim().take(254)) }) } }, + onRemove = { viewModel.edit { it.copy(emails = it.emails.filterIndexed { j, _ -> j != i }) } }, + ) + } + AddButton(stringResource(R.string.con_add_email), viewModel::addEmail) + } + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Label(stringResource(R.string.con_phone)) + c.phones.forEachIndexed { i, p -> + MultiRow( + type = p.type, types = MCard.PHONE_TYPES, value = p.value, label = stringResource(R.string.con_phone), + keyboard = KeyboardType.Phone, + onType = { t -> viewModel.edit { it.copy(phones = it.phones.toMutableList().also { l -> l[i] = p.copy(type = t) }) } }, + onValue = { v -> viewModel.edit { it.copy(phones = it.phones.toMutableList().also { l -> l[i] = p.copy(value = v.take(40)) }) } }, + onRemove = { viewModel.edit { it.copy(phones = it.phones.filterIndexed { j, _ -> j != i }) } }, + ) + } + AddButton(stringResource(R.string.con_add_phone), viewModel::addPhone) + } + c.addresses.forEachIndexed { i, a -> + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TypeButton(a.type, MCard.ADDRESS_TYPES) { t -> viewModel.edit { it.copy(addresses = it.addresses.toMutableList().also { l -> l[i] = a.copy(type = t) }) } } + Text(" · " + stringResource(R.string.con_address), style = MaterialTheme.typography.labelMedium, color = extra.muted, modifier = Modifier.weight(1f)) + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.mail_remove), onClick = { viewModel.edit { it.copy(addresses = it.addresses.filterIndexed { j, _ -> j != i }) } }, size = 36.dp) + } + fun set(f: (com.gatecontrol.android.mail.MCardAddress) -> com.gatecontrol.android.mail.MCardAddress) = + viewModel.edit { it.copy(addresses = it.addresses.toMutableList().also { l -> l[i] = f(a) }) } + GcTextField(a.street, { v -> set { it.copy(street = v.take(200)) } }, stringResource(R.string.con_street)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcTextField(a.postalCode, { v -> set { it.copy(postalCode = v.take(20)) } }, stringResource(R.string.con_postal), modifier = Modifier.width(110.dp)) + GcTextField(a.city, { v -> set { it.copy(city = v.take(100)) } }, stringResource(R.string.con_city), modifier = Modifier.weight(1f)) + } + GcTextField(a.country, { v -> set { it.copy(country = v.take(100)) } }, stringResource(R.string.con_country)) + } + } + AddButton(stringResource(R.string.con_add_address), viewModel::addAddress) + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcTextField( + state.birthdayDayMonth, { viewModel.setBirthday(it, state.birthdayYear) }, stringResource(R.string.con_birthday), + placeholder = "14.03.", modifier = Modifier.weight(1f), + error = if (viewModel.birthdayBad(state)) stringResource(R.string.con_birthday_bad) else null, + ) + GcTextField(state.birthdayYear, { viewModel.setBirthday(state.birthdayDayMonth, it) }, stringResource(R.string.con_year), modifier = Modifier.width(110.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + GcTextField(c.note, { v -> viewModel.edit { it.copy(note = v.take(8000)) } }, stringResource(R.string.con_note), singleLine = false) + } + val groups = books?.groups.orEmpty() + if (groups.isNotEmpty()) { + GcCard(contentPadding = PaddingValues(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Label(stringResource(R.string.con_groups)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + groups.forEach { g -> + val on = g.id in c.groups + val col = calColor(g.color, extra.accentText) + Row( + Modifier.heightIn(min = 32.dp).clip(RoundedCornerShape(999.dp)) + .then(if (on) Modifier.background(col.copy(alpha = 0.16f)) else Modifier.border(1.dp, extra.border2, RoundedCornerShape(999.dp))) + .clickable(role = Role.Checkbox) { viewModel.edit { it.copy(groups = if (on) it.groups - g.id else it.groups + g.id) } } + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (on) Icon(GcIcons.Check, contentDescription = null, tint = col, modifier = Modifier.size(14.dp)) + Text(g.name, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (on) col else extra.muted) + } + } + } + } + } + if (viewModel.isNew) { + val writable = books?.items.orEmpty().filter { it.writable } + if (writable.size > 1) { + var open by remember { mutableStateOf(false) } + Box { + GcOutlineButton( + stringResource(R.string.con_book, books?.book(state.addressbookId)?.name ?: "–"), + onClick = { open = true }, + icon = GcIcons.IdCard, + ) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + writable.forEach { b -> DropdownMenuItem(text = { Text(b.name) }, onClick = { open = false; viewModel.setBook(b.id) }) } + } + } + } + } else { + GcOutlineButton(stringResource(R.string.con_delete), onClick = { deleting = true }, icon = GcIcons.Trash) + } + Box(Modifier.heightIn(min = 24.dp)) + } + } + if (photoSheet) { + ModalBottomSheet(onDismissRequest = { photoSheet = false }, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(bottom = 24.dp)) { + SheetRow(GcIcons.Camera, stringResource(R.string.con_photo_camera)) { + photoSheet = false + cameraPermission.launch(android.Manifest.permission.CAMERA) + } + SheetRow(GcIcons.Photo, stringResource(R.string.con_photo_gallery)) { + photoSheet = false + gallery.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + if (c.photo || state.photo is PhotoChange.Set) { + SheetRow(GcIcons.Trash, stringResource(R.string.con_photo_remove), MaterialTheme.colorScheme.error) { photoSheet = false; viewModel.setPhoto(PhotoChange.Remove) } + } + } + } + } + if (deleting) { + AlertDialog( + onDismissRequest = { deleting = false }, + title = { Text(stringResource(R.string.con_delete_title, c.label)) }, + text = { Text(stringResource(if (c.source == "local") R.string.con_delete_text else R.string.con_delete_text_account)) }, + confirmButton = { TextButton(onClick = { deleting = false; viewModel.delete() }) { Text(stringResource(R.string.mail_delete), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { deleting = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } + state.conflict?.let { + AlertDialog( + onDismissRequest = viewModel::reloadConflict, + title = { Text(stringResource(R.string.con_conflict_title)) }, + text = { Text(stringResource(R.string.con_conflict_text)) }, + confirmButton = { TextButton(onClick = viewModel::applyAgain) { Text(stringResource(R.string.pim_apply_again)) } }, + dismissButton = { TextButton(onClick = viewModel::reloadConflict) { Text(stringResource(R.string.pim_reload)) } }, + ) + } +} + +@Composable +private fun Label(text: String) { + Text(text.uppercase(), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = GateControlTheme.extraColors.faint) +} + +@Composable +private fun AddButton(label: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(GcIcons.Plus, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(label, modifier = Modifier.padding(start = 6.dp), fontWeight = FontWeight.SemiBold) + } +} + +/** A kind ("Arbeit ▾"), the value and "×" (mockup). */ +@Composable +private fun MultiRow(type: String, types: List, value: String, label: String, keyboard: KeyboardType, onType: (String) -> Unit, onValue: (String) -> Unit, onRemove: () -> Unit) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.padding(bottom = 4.dp)) { TypeButton(type, types, onType) } + GcTextField(value, onValue, "", modifier = Modifier.weight(1f).semantics { contentDescription = label }, keyboardOptions = KeyboardOptions(keyboardType = keyboard)) + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.mail_remove), onClick = onRemove, size = 40.dp) + } +} + +@Composable +private fun TypeButton(type: String, types: List, onType: (String) -> Unit) { + val extra = GateControlTheme.extraColors + var open by remember { mutableStateOf(false) } + Box { + Row( + Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)).border(1.dp, extra.border2, RoundedCornerShape(12.dp)) + .clickable(role = Role.DropdownList) { open = true }.padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(typeName(type), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + Icon(GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + types.forEach { t -> DropdownMenuItem(text = { Text(typeName(t)) }, onClick = { open = false; onType(t) }) } + } + } +} + +/** A picture from the gallery as the contact's photo (scaled to 512 px, JPEG ≤ 256 KB); null when it cannot be read. */ +private fun readPhoto(context: Context, uri: Uri): ByteArray? = runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + var sample = 1 + while (bounds.outWidth / (sample * 2) >= PHOTO_PX && bounds.outHeight / (sample * 2) >= PHOTO_PX) sample *= 2 + val bmp = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample }) } ?: return null + jpeg(bmp) +}.getOrNull() + +/** [bmp] cut to a centered square of at most 512 px, as JPEG small enough for the server. */ +internal fun jpeg(bmp: Bitmap): ByteArray? { + val side = minOf(bmp.width, bmp.height) + if (side <= 0) return null + val square = Bitmap.createBitmap(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side) + val scaled = if (side > PHOTO_PX) square.scale(PHOTO_PX, PHOTO_PX) else square + var quality = 88 + while (quality >= 40) { + val out = ByteArrayOutputStream() + scaled.compress(Bitmap.CompressFormat.JPEG, quality, out) + if (out.size() <= PHOTO_MAX) return out.toByteArray() + quality -= 12 + } + return null +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditViewModel.kt new file mode 100644 index 00000000..8d869b30 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactEditViewModel.kt @@ -0,0 +1,245 @@ +package com.gatecontrol.android.ui.mail.contacts + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MBooks +import com.gatecontrol.android.mail.MCard +import com.gatecontrol.android.mail.MCardAddress +import com.gatecontrol.android.mail.MCardEmail +import com.gatecontrol.android.mail.MCardName +import com.gatecontrol.android.mail.MCardPhone +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** A new photo (JPEG, at most 512 px) or the photo's removal. */ +sealed class PhotoChange { + class Set(val bytes: ByteArray) : PhotoChange() + data object Remove : PhotoChange() +} + +data class ContactEditState( + val card: MCard = MCard(emails = listOf(MCardEmail("", "work", true)), phones = listOf(MCardPhone("", "mobile"))), + /** New contacts: the address book it goes into (writable ones). */ + val addressbookId: Long? = null, + /** The birthday as typed: day and month (`14.03.`) and the year (optional). */ + val birthdayDayMonth: String = "", + val birthdayYear: String = "", + val photo: PhotoChange? = null, + /** "Als Kontakt speichern": the contact that has the address already ("Zu hinzufügen"). */ + val existing: MCard? = null, + val loading: Boolean = false, + val saving: Boolean = false, + val conflict: MCard? = null, + val message: String? = null, + /** Saved: the contact's id (the screen closes). */ + val done: Long? = null, +) + +/** + * A new contact, a change, or "Als Kontakt speichern" from a mail (contract D4/D5): the form, photo, groups, + * the address book of a new one, saving with the etag and its conflicts. + */ +@HiltViewModel +class MailContactEditViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val books: StateFlow = pim.books + + private var id: Long? = saved.get(Screen.MailContactEdit.ARG_ID)?.toLongOrNull()?.takeIf { it > 0 } + private val message: Long? = saved.get(Screen.MailContactEdit.ARG_MESSAGE)?.toLongOrNull()?.takeIf { it > 0 } + private val email: String? = saved.get(Screen.MailContactEdit.ARG_EMAIL)?.trim()?.takeIf { it.contains('@') } + private val name: String? = saved.get(Screen.MailContactEdit.ARG_NAME)?.trim()?.takeIf { it.isNotEmpty() } + private val group: Long? = saved.get(Screen.MailContactEdit.ARG_GROUP)?.toLongOrNull()?.takeIf { it > 0 } + + private val _state = MutableStateFlow(ContactEditState()) + val state: StateFlow = _state.asStateFlow() + + val isNew: Boolean get() = id == null + + init { + if (pim.books.value == null) viewModelScope.launch { pim.refreshBooks() } + when { + id != null -> load(id!!) + email != null -> draft(email) + else -> start(MCard(name = splitName(name), groups = listOfNotNull(group))) + } + } + + private fun splitName(n: String?): MCardName { + n ?: return MCardName() + val parts = n.split(Regex("\\s+")) + return if (parts.size >= 2) MCardName(given = parts.dropLast(1).joinToString(" "), family = parts.last()) else MCardName(given = n) + } + + private fun start(card: MCard) { + val b = MailPimLogic.birthdayParts(card.birthday) + _state.update { + it.copy( + card = card.copy( + emails = card.emails.ifEmpty { listOf(MCardEmail("", "work", true)) }, + phones = card.phones.ifEmpty { listOf(MCardPhone("", "mobile")) }, + ), + birthdayDayMonth = b?.let { (m, d, _) -> "%02d.%02d.".format(d, m) }.orEmpty(), + birthdayYear = b?.third?.toString().orEmpty(), + addressbookId = it.addressbookId ?: defaultBook(), + loading = false, + ) + } + } + + private fun defaultBook(): Long? { + val b = pim.books.value ?: return null + val writable = b.items.filter { it.writable } + return b.prefs.defaultAddressbookId?.takeIf { d -> writable.any { it.id == d } } ?: writable.firstOrNull { it.isDefault }?.id ?: writable.firstOrNull()?.id + } + + private fun load(cardId: Long) { + _state.update { it.copy(loading = true) } + viewModelScope.launch { + when (val r = pim.card(cardId)) { + is MResult.Ok -> start(r.value) + else -> _state.update { it.copy(loading = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** "Als Kontakt speichern": name and address from the mail, phone, company and position from the signature. */ + private fun draft(address: String) { + _state.update { it.copy(loading = true) } + viewModelScope.launch { + when (val r = api.cardDraft(message, address)) { + is MResult.Ok -> { + val d = r.value.draft + start(d.copy(name = if (d.name.given.isBlank() && d.name.family.isBlank()) splitName(name ?: d.name.display.ifBlank { null }) else d.name, groups = listOfNotNull(group))) + _state.update { it.copy(existing = r.value.existing) } + } + else -> start(MCard(name = splitName(name), emails = listOf(MCardEmail(address, "other", true)), groups = listOfNotNull(group))) + } + } + } + + /** "Zu hinzufügen": the existing contact with the new addresses and numbers. */ + fun addToExisting() { + val ex = _state.value.existing ?: return + val draft = _state.value.card + id = ex.id + start( + ex.copy( + emails = (ex.emails + draft.emails.filter { it.value.isNotBlank() }).distinctBy { it.value.lowercase() }, + phones = (ex.phones + draft.phones.filter { it.value.isNotBlank() }).distinctBy { MailPimLogic.phoneKey(it.value).ifEmpty { it.value } }, + company = ex.company ?: draft.company, + title = ex.title ?: draft.title, + ), + ) + _state.update { it.copy(existing = null) } + } + + fun edit(change: (MCard) -> MCard) = _state.update { it.copy(card = change(it.card)) } + + fun setBook(bookId: Long) = _state.update { it.copy(addressbookId = bookId) } + + fun setBirthday(dayMonth: String, year: String) = _state.update { it.copy(birthdayDayMonth = dayMonth.take(6), birthdayYear = year.filter { c -> c.isDigit() }.take(4)) } + + fun setPhoto(change: PhotoChange?) = _state.update { it.copy(photo = change) } + + /** Whether the birthday as typed is no date. */ + fun birthdayBad(s: ContactEditState = _state.value): Boolean = + (s.birthdayDayMonth.isNotBlank() || s.birthdayYear.isNotBlank()) && MailPimLogic.birthdayOf(s.birthdayDayMonth, s.birthdayYear) == null + + fun canSave(s: ContactEditState = _state.value): Boolean { + val c = s.card + val any = c.name.given.isNotBlank() || c.name.family.isNotBlank() || !c.company.isNullOrBlank() || c.emails.any { it.value.isNotBlank() } || c.phones.any { it.value.isNotBlank() } + return any && !birthdayBad(s) && !s.saving && !s.loading && (s.card.emails.all { it.value.isBlank() || it.value.contains('@') }) + } + + private fun form(s: ContactEditState): MCard { + val c = s.card + val emails = c.emails.filter { it.value.isNotBlank() }.mapIndexed { i, e -> e.copy(value = e.value.trim(), primary = i == 0) } + return c.copy( + emails = emails, + phones = c.phones.filter { it.value.isNotBlank() }, + addresses = c.addresses.filter { !it.empty }, + birthday = MailPimLogic.birthdayOf(s.birthdayDayMonth, s.birthdayYear), + ) + } + + fun save() = send(_state.value.card.etag) + + private fun send(etag: String?) { + val s = _state.value + if (!canSave(s)) return + _state.update { it.copy(saving = true) } + viewModelScope.launch { + val cardId = id + val r = if (cardId == null) api.createCard(MailPimJson.cardBody(form(s), addressbookId = s.addressbookId)) + else api.patchCard(cardId, MailPimJson.cardBody(form(s), etag = etag)) + when (r) { + is MResult.Ok -> { + when (val p = s.photo) { + is PhotoChange.Set -> api.putCardPhoto(r.value.id, "image/jpeg", p.bytes) + PhotoChange.Remove -> if (r.value.photo) api.deleteCardPhoto(r.value.id) + null -> Unit + } + pim.syncCardsSoon() + _state.update { it.copy(saving = false, done = r.value.id) } + } + is MResult.Failed -> { + val current = if (r.code == "conflict") MailPimJson.conflictCard(r.error) else null + _state.update { if (current != null) it.copy(saving = false, conflict = current) else it.copy(saving = false, message = r.code) } + } + else -> _state.update { it.copy(saving = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** Conflict: "Meine Änderung erneut anwenden" (the form over the newer state). */ + fun applyAgain() { + val current = _state.value.conflict ?: return + _state.update { it.copy(conflict = null, card = it.card.copy(etag = current.etag)) } + send(current.etag) + } + + /** Conflict: "Neu laden" (the newer state into the form). */ + fun reloadConflict() { + val current = _state.value.conflict ?: return + _state.update { it.copy(conflict = null) } + start(current) + } + + fun delete() { + val cardId = id ?: return + viewModelScope.launch { + when (val r = api.deleteCard(cardId)) { + is MResult.Ok -> { + pim.forgetCard(cardId) + _state.update { it.copy(done = 0L) } + } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun clearMessage() = _state.update { it.copy(message = null) } + + fun addEmail() = edit { it.copy(emails = it.emails + MCardEmail("", "home")) } + fun addPhone() = edit { it.copy(phones = it.phones + MCardPhone("", "mobile")) } + fun addAddress() = edit { it.copy(addresses = it.addresses + MCardAddress(type = "home")) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactScreen.kt new file mode 100644 index 00000000..ff48c1c1 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactScreen.kt @@ -0,0 +1,334 @@ +package com.gatecontrol.android.ui.mail.contacts + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +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.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MOccurrence +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.MailDot +import com.gatecontrol.android.ui.mail.accountColor +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.mailTime +import com.gatecontrol.android.ui.mail.openExternal +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.mail.calendar.calColor +import com.gatecontrol.android.ui.mail.calendar.occurrenceWhen +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Where a contact leads. */ +data class ContactNav( + val back: () -> Unit, + val edit: (Long) -> Unit, + /** A new mail to these addresses. */ + val mailTo: (String) -> Unit, + val openMail: (Long) -> Unit, + /** "Alle Mails von …": the sender's page. */ + val sender: (email: String, name: String?) -> Unit, + val event: (eventId: Long, occurrence: String?) -> Unit, +) + +/** + * A contact (mockup "Kontakt"): the photo, name, position · company and where it comes from; Mail, Anrufen, + * Bearbeiten; the addresses, numbers, postal addresses (to the map), birthday, note and groups; the last mails and + * the shared events. A read-only source has no "Bearbeiten" ("aus , nur lesend"). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MailContactScreen(nav: ContactNav, viewModel: MailContactViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val books by viewModel.books.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + val locale = currentLocale() + var menu by remember { mutableStateOf(false) } + var deleting by remember { mutableStateOf(false) } + var pickMail by remember { mutableStateOf(false) } + var pickCall by remember { mutableStateOf(false) } + LaunchedEffect(state.gone) { if (state.gone) nav.back() } + LaunchedEffect(state.message) { + state.message?.let { + toast(context, mailText(context, it)) + viewModel.clearMessage() + } + } + val c = state.card + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + GcIconButton(GcIcons.Back, contentDescription = stringResource(R.string.mail_back), onClick = nav.back, tint = MaterialTheme.colorScheme.onBackground) + Box(Modifier.weight(1f)) + if (c != null && !c.readOnly) { + Box { + GcIconButton(GcIcons.MoreVertical, contentDescription = stringResource(R.string.con_more), onClick = { menu = true }, tint = MaterialTheme.colorScheme.onBackground) + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.con_delete), color = MaterialTheme.colorScheme.error) }, + leadingIcon = { Icon(GcIcons.Trash, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp)) }, + enabled = !offline, + onClick = { menu = false; deleting = true }, + ) + } + } + } + } + when { + c != null -> Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + ContactAvatar(c.label, c.emails.firstOrNull()?.value, c.id.takeIf { c.photo }, c.photoEtag, size = 96.dp) + Text(c.label, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 6.dp).semantics { heading() }) + val job = listOfNotNull(c.title, c.department, c.company).joinToString(" · ") + if (job.isNotEmpty()) Text(job, style = MaterialTheme.typography.bodyMedium, color = extra.muted, textAlign = TextAlign.Center) + val book = books?.book(c.addressbookId) + val linked = c.linked.mapNotNull { l -> books?.book(l.addressbookId)?.name }.distinct() + val src = listOfNotNull(accountLabel(me, book?.accountId) ?: book?.name) + linked.map { stringResource(R.string.con_linked_with, it) } + if (src.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + me?.account(book?.accountId)?.let { MailDot(accountColor(it)) } + Text(src.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = extra.muted, textAlign = TextAlign.Center) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ActionTile(GcIcons.Mail, stringResource(R.string.con_mail), enabled = c.emails.isNotEmpty(), modifier = Modifier.weight(1f)) { + if (c.emails.size == 1) nav.mailTo(c.emails[0].value) else pickMail = true + } + ActionTile(GcIcons.Phone, stringResource(R.string.con_call), enabled = c.phones.isNotEmpty(), modifier = Modifier.weight(1f)) { + if (c.phones.size == 1) call(context, c.phones[0].value) else pickCall = true + } + ActionTile(GcIcons.Edit, stringResource(R.string.con_edit), enabled = !c.readOnly && !offline, modifier = Modifier.weight(1f)) { nav.edit(c.id) } + } + if (c.readOnly) { + Text(stringResource(R.string.con_read_only, accountLabel(me, books?.book(c.addressbookId)?.accountId) ?: books?.book(c.addressbookId)?.name ?: ""), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + c.emails.forEach { e -> Field(stringResource(R.string.con_email) + " · " + typeName(e.type), e.value, link = true) { nav.mailTo(e.value) } } + c.phones.forEach { p -> Field(typeName(p.type), p.value, link = true) { call(context, p.value) } } + c.addresses.forEach { a -> + val text = listOf(a.street, listOf(a.postalCode, a.city).filter { it.isNotBlank() }.joinToString(" "), a.region, a.country).filter { it.isNotBlank() }.joinToString("\n") + Field(typeName(a.type) + " · " + stringResource(R.string.con_address), text, action = GcIcons.Map) { openExternal(context, "geo:0,0?q=" + Uri.encode(text.replace('\n', ' '))) } + } + c.birthday?.let { b -> birthdayText(b, locale)?.let { Field(stringResource(R.string.con_birthday), it) } } + c.urls.forEach { u -> Field(stringResource(R.string.con_web), u, link = true) { openExternal(context, u) } } + if (c.note.isNotBlank()) Field(stringResource(R.string.con_note), c.note) + val groups = books?.groups.orEmpty().filter { it.id in c.groups } + if (groups.isNotEmpty()) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.con_groups), style = MaterialTheme.typography.bodySmall, color = extra.muted) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + groups.forEach { g -> + val col = calColor(g.color, extra.accentText) + Text(g.name, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = col, + modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(col.copy(alpha = 0.14f)).padding(horizontal = 10.dp, vertical = 6.dp)) + } + } + } + } + } + state.activity?.let { a -> + if (a.mails.isNotEmpty()) { + GcSectionLabel(stringResource(R.string.con_last_mails), Modifier.padding(horizontal = 4.dp)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + a.mails.forEach { m -> + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).clickable(role = Role.Button) { nav.openMail(m.id) }.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + MailDot(accountColor(me?.account(m.accountId))) + Column(Modifier.weight(1f)) { + Text(m.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, style = MaterialTheme.typography.bodyMedium, fontWeight = if (m.unread) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (m.snippet.isNotBlank()) Text(m.snippet, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(mailTime(m.date), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + } + c.emails.firstOrNull()?.let { e -> + TextButton(onClick = { nav.sender(e.value, c.label) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.con_all_mails, c.name.given.ifBlank { c.label }), fontWeight = FontWeight.SemiBold) + } + } + } + } + if (a.events.isNotEmpty()) { + GcSectionLabel(stringResource(R.string.con_shared_events), Modifier.padding(horizontal = 4.dp)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + a.events.forEach { o -> SharedEvent(o, cals?.calendar(o.calendarId)?.color, locale) { nav.event(o.eventId, o.occurrence) } } + } + } + } + Box(Modifier.height(24.dp)) + } + state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + else -> ErrorBox(mailText(context, state.error), onRetry = { viewModel.load() }) + } + } + if (pickMail && c != null) Picker(c.emails.map { it.value }, onPick = { pickMail = false; nav.mailTo(it) }, onDismiss = { pickMail = false }) + if (pickCall && c != null) Picker(c.phones.map { it.value }, onPick = { pickCall = false; call(context, it) }, onDismiss = { pickCall = false }) + if (deleting && c != null) { + AlertDialog( + onDismissRequest = { deleting = false }, + title = { Text(stringResource(R.string.con_delete_title, c.label)) }, + text = { Text(stringResource(if (c.source == "local") R.string.con_delete_text else R.string.con_delete_text_account)) }, + confirmButton = { TextButton(onClick = { deleting = false; viewModel.delete() }) { Text(stringResource(R.string.mail_delete), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { deleting = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } +} + +private fun call(context: android.content.Context, number: String) { + MailPimLogic.telUri(number)?.let { openExternal(context, it) } +} + +@Composable +private fun ActionTile(icon: ImageVector, label: String, enabled: Boolean, modifier: Modifier, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Column( + modifier.heightIn(min = 64.dp).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surface) + .clickable(role = Role.Button, enabled = enabled, onClick = onClick).padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + ) { + Icon(icon, contentDescription = null, tint = if (enabled) extra.accentText else extra.faint, modifier = Modifier.size(22.dp)) + Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (enabled) MaterialTheme.colorScheme.onSurface else extra.faint) + } +} + +@Composable +private fun Field(label: String, value: String, link: Boolean = false, action: ImageVector? = null, onClick: (() -> Unit)? = null) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp) + .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.bodySmall, color = extra.muted) + SelectionContainer { Text(value, style = MaterialTheme.typography.bodyLarge, color = if (link) extra.accentText else MaterialTheme.colorScheme.onSurface) } + } + if (action != null) Icon(action, contentDescription = null, tint = extra.muted, modifier = Modifier.size(20.dp)) + } +} + +@Composable +internal fun typeName(type: String): String = stringResource( + when (type) { + "work" -> R.string.con_type_work + "home" -> R.string.con_type_home + "mobile" -> R.string.con_type_mobile + "fax" -> R.string.con_type_fax + else -> R.string.con_type_other + }, +) + +/** "14. März 1981 · wird 46" (without a year: "14. März"). */ +@Composable +private fun birthdayText(b: String, locale: java.util.Locale): String? { + val (m, d, y) = MailPimLogic.birthdayParts(b) ?: return null + val date = runCatching { LocalDate.of(y ?: 2000, m, d) }.getOrNull() ?: return null + val text = date.format(DateTimeFormatter.ofPattern(if (y != null) (if (locale.language == "de") "d. MMMM yyyy" else "MMMM d, yyyy") else (if (locale.language == "de") "d. MMMM" else "MMMM d"), locale)) + val turns = MailPimLogic.turns(b, LocalDate.now()) + return if (turns != null) text + " · " + stringResource(R.string.con_turns, turns) else text +} + +@Composable +private fun SharedEvent(o: MOccurrence, color: String?, locale: java.util.Locale, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val zone = ZoneId.systemDefault() + val day = java.time.Instant.ofEpochMilli(o.start).atZone(zone).toLocalDate() + val c = calColor(color) + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.width(44.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(day.format(DateTimeFormatter.ofPattern("EEE", locale)).removeSuffix("."), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Text(day.dayOfMonth.toString(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + Box(Modifier.width(3.dp).height(36.dp).clip(RoundedCornerShape(2.dp)).background(c)) + Column(Modifier.weight(1f)) { + Text(o.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val sub = listOfNotNull(stringResource(R.string.cal_invite_open).takeIf { o.open }, occurrenceWhen(o, zone), o.location).joinToString(" · ") + Text(sub, style = MaterialTheme.typography.bodySmall, color = if (o.open) c else extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun Picker(values: List, onPick: (String) -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + text = { + Column { + values.forEach { v -> + Text(v, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(role = Role.Button) { onPick(v) }.padding(vertical = 12.dp)) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactViewModel.kt new file mode 100644 index 00000000..8ba63451 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactViewModel.kt @@ -0,0 +1,87 @@ +package com.gatecontrol.android.ui.mail.contacts + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MActivity +import com.gatecontrol.android.mail.MBooks +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MCard +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ContactState( + val card: MCard? = null, + val activity: MActivity? = null, + val loading: Boolean = true, + val error: String? = null, + val message: String? = null, + val gone: Boolean = false, +) + +/** One contact (mockup "Kontakt"): its fields, its last mails and shared events, delete. */ +@HiltViewModel +class MailContactViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val books: StateFlow = pim.books + val calendars: StateFlow = pim.calendars + val offline: StateFlow = pim.offline + + val id: Long = saved.get(Screen.MailContact.ARG_ID)?.toLongOrNull() ?: 0L + + private val _state = MutableStateFlow(ContactState()) + val state: StateFlow = _state.asStateFlow() + + init { + if (pim.books.value == null) pim.refreshBooksSoon() + load() + viewModelScope.launch { + pim.contactsChanged.collect { c -> if (c.contactIds.isEmpty() || id in c.contactIds) load(quiet = true) } + } + } + + fun load(quiet: Boolean = false) { + if (id <= 0) { + _state.update { it.copy(loading = false, error = "not_found") } + return + } + if (!quiet) _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + when (val r = pim.card(id)) { + is MResult.Ok -> _state.update { it.copy(card = r.value, loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + api.activity(id).valueOrNull()?.let { a -> _state.update { it.copy(activity = a) } } + } + } + + fun delete() { + viewModelScope.launch { + when (val r = api.deleteCard(id)) { + is MResult.Ok -> { + pim.forgetCard(id) + _state.update { it.copy(gone = true) } + } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun clearMessage() = _state.update { it.copy(message = null) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsScreen.kt new file mode 100644 index 00000000..0898d1d1 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsScreen.kt @@ -0,0 +1,440 @@ +package com.gatecontrol.android.ui.mail.contacts + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MCardSummary +import com.gatecontrol.android.mail.MExport +import com.gatecontrol.android.mail.MGroup +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MailCache +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.mail.MailDivider +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.mail.calendar.CalendarBands +import com.gatecontrol.android.ui.mail.calendar.NameDialog +import com.gatecontrol.android.ui.start.StartPageMenuButton +import com.gatecontrol.android.ui.theme.GateControlTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** Where the contacts lead. */ +data class ContactsNav( + val menu: () -> Unit, + val contact: (Long) -> Unit, + val newContact: () -> Unit, + val group: (Long) -> Unit, + val duplicates: () -> Unit, + val relogin: (Long) -> Unit, +) + +/** A vCard file of at most this size is read for the import (the server takes 5 MB). */ +private const val VCF_MAX = 5L * 1024 * 1024 + +/** + * The contacts area of the Mail tab (mockup "Kontakte"): the bar with the menu, the count and "⋯" (import, export, + * duplicates), the search, Kontakte | Gruppen, the band of possible duplicates, the letters' sections with the A–Z + * bar, the rows (photo or initials, name, position · company or the first address, the source's dot) and "Neuer + * Kontakt". + */ +@Composable +fun MailContactsContent(nav: ContactsNav, viewModel: MailContactsViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + val shown by viewModel.shown.collectAsStateWithLifecycle() + val all by viewModel.total.collectAsStateWithLifecycle() + val books by viewModel.books.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + val relogins by viewModel.relogins.collectAsStateWithLifecycle() + val context = mailContext() + val scope = rememberCoroutineScope() + val extra = GateControlTheme.extraColors + var menu by remember { mutableStateOf(false) } + var newGroup by remember { mutableStateOf(false) } + var saveAfter by remember { mutableStateOf(false) } + + LifecycleResumeEffect(viewModel) { + viewModel.onShown() + onPauseOrDispose { } + } + LaunchedEffect(state.message) { + val m = state.message ?: return@LaunchedEffect + val parts = m.split(':') + toast(context, if (parts[0] == "import" && parts.size == 4) context.getString(R.string.con_imported, parts[1].toInt(), parts[2].toInt(), parts[3].toInt()) else mailText(context, m)) + viewModel.clearMessage() + } + val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri ?: return@rememberLauncherForActivityResult + scope.launch { + val text = withContext(Dispatchers.IO) { readText(context, uri) } + if (text == null) viewModel.failed("too_large") else viewModel.importVcf(text) + } + } + val saver = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/vcard")) { uri -> + val ex = state.export + viewModel.exported() + if (uri == null || ex == null) return@rememberLauncherForActivityResult + scope.launch { + val ok = withContext(Dispatchers.IO) { runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(ex.bytes) } != null }.getOrDefault(false) } + toast(context, context.getString(if (ok) R.string.con_exported else R.string.mail_err_failed)) + } + } + LaunchedEffect(state.export) { + val ex = state.export ?: return@LaunchedEffect + if (saveAfter) { + saver.launch(ex.name) + } else { + share(context, ex) + viewModel.exported() + } + } + + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 8.dp, top = 12.dp), verticalAlignment = Alignment.CenterVertically) { + GcIconButton(GcIcons.Menu, contentDescription = stringResource(R.string.mail_menu), onClick = nav.menu, tint = MaterialTheme.colorScheme.onBackground) + Column(Modifier.weight(1f).padding(start = 4.dp)) { + val title = state.filter.group?.let { g -> books?.groups?.firstOrNull { it.id == g }?.name } + ?: state.filter.book?.let { b -> books?.book(b)?.name } + ?: stringResource(R.string.mail_contacts) + Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.semantics { heading() }) + val n = all.size + val b = books?.items?.size ?: 0 + Text( + pluralStringResource(R.plurals.con_count, n, n) + if (b > 0) " · " + pluralStringResource(R.plurals.con_books, b, b) else "", + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + Box { + GcIconButton(GcIcons.MoreVertical, contentDescription = stringResource(R.string.con_more), onClick = { menu = true }, tint = MaterialTheme.colorScheme.onBackground) + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.con_import)) }, + leadingIcon = { Icon(GcIcons.Download, contentDescription = null, modifier = Modifier.size(20.dp)) }, + enabled = !offline && !state.busy, + onClick = { menu = false; importer.launch(arrayOf("text/vcard", "text/x-vcard", "text/directory", "text/plain", "application/octet-stream")) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.con_export_share)) }, + leadingIcon = { Icon(GcIcons.Share, contentDescription = null, modifier = Modifier.size(20.dp)) }, + enabled = !offline && !state.busy, + onClick = { menu = false; saveAfter = false; viewModel.export() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.con_export_save)) }, + leadingIcon = { Icon(GcIcons.Upload, contentDescription = null, modifier = Modifier.size(20.dp)) }, + enabled = !offline && !state.busy, + onClick = { menu = false; saveAfter = true; viewModel.export() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.con_duplicates)) }, + leadingIcon = { Icon(GcIcons.Users, contentDescription = null, modifier = Modifier.size(20.dp)) }, + onClick = { menu = false; nav.duplicates() }, + ) + } + } + StartPageMenuButton() + } + SearchField(state.query, viewModel::setQuery) + TabsRow(state.tab, books?.groups?.size ?: 0, viewModel::setTab) + CalendarBands(me, null, offline, relogins, onRelogin = { id -> viewModel.relogin(id); nav.relogin(id) }, contacts = true) + if (state.tab == ContactsState.TAB_GROUPS) { + GroupsList(books?.groups.orEmpty(), all, onGroup = nav.group, onNew = { newGroup = true }, offline = offline) + } else { + ContactsList(shown, me, books, state, nav, viewModel) + } + } + if (state.tab == ContactsState.TAB_CONTACTS && !offline) { + NewButton(stringResource(R.string.con_new), Modifier.align(Alignment.BottomEnd).padding(16.dp), nav.newContact) + } + if (state.busy) CircularProgressIndicator(Modifier.align(Alignment.Center).size(28.dp)) + } + if (newGroup) NameDialog(stringResource(R.string.con_new_group), "", onDone = viewModel::createGroup, onDismiss = { newGroup = false }) +} + +private fun readText(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + val bytes = input.readNBytesCompat(VCF_MAX + 1) + if (bytes.size > VCF_MAX) null else String(bytes, Charsets.UTF_8) + } +}.getOrNull() + +private fun java.io.InputStream.readNBytesCompat(max: Long): ByteArray { + val out = java.io.ByteArrayOutputStream() + val buf = ByteArray(16 * 1024) + while (out.size() <= max) { + val n = read(buf) + if (n < 0) break + out.write(buf, 0, n) + } + return out.toByteArray() +} + +/** The vCard file shared (FileProvider `mail_open`, removed with Mail's copy). */ +private fun share(context: Context, ex: MExport) { + runCatching { + val dir = File(context.cacheDir, MailCache.OPEN_DIR + "/export").apply { mkdirs() } + val file = File(dir, ex.name).apply { writeBytes(ex.bytes) } + val uri = FileProvider.getUriForFile(context, context.packageName + ".provider", file) + val send = Intent(Intent.ACTION_SEND).setType(ex.mime).putExtra(Intent.EXTRA_STREAM, uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(Intent.createChooser(send, ex.name).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }.onFailure { toast(context, context.getString(R.string.mail_no_app)) } +} + +@Composable +private fun SearchField(q: String, onChange: (String) -> Unit) { + val extra = GateControlTheme.extraColors + val label = stringResource(R.string.con_search) + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surface).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Search, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + Box(Modifier.weight(1f)) { + if (q.isEmpty()) Text(stringResource(R.string.con_search_hint), color = extra.faint, style = MaterialTheme.typography.bodyLarge, maxLines = 1) + BasicTextField( + value = q, + onValueChange = onChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().semantics { contentDescription = label }, + ) + } + if (q.isNotEmpty()) GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.con_search_clear), onClick = { onChange("") }, size = 32.dp, iconSize = 16.dp) + } +} + +@Composable +private fun TabsRow(tab: String, groups: Int, onTab: (String) -> Unit) { + val extra = GateControlTheme.extraColors + Column { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + listOf( + ContactsState.TAB_CONTACTS to stringResource(R.string.mail_contacts), + ContactsState.TAB_GROUPS to stringResource(R.string.con_groups) + " · $groups", + ).forEach { (id, label) -> + val on = tab == id + Column(Modifier.weight(1f).heightIn(min = 44.dp).selectable(selected = on, role = Role.Tab) { onTab(id) }, horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (on) extra.accentText else extra.muted, modifier = Modifier.padding(vertical = 12.dp)) + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).heightIn(min = 2.dp, max = 2.dp).background(if (on) MaterialTheme.colorScheme.primary else Color.Transparent)) + } + } + } + MailDivider() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ContactsList(shown: List, me: MMe?, books: com.gatecontrol.android.mail.MBooks?, state: ContactsState, nav: ContactsNav, viewModel: MailContactsViewModel) { + val extra = GateControlTheme.extraColors + val context = mailContext() + val sections = remember(shown) { MailPimLogic.sections(shown) } + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + // the index of each letter's head in the list (for the A–Z bar) + val heads = remember(sections, state.duplicates) { + var i = if (state.duplicates > 0) 1 else 0 + sections.associate { (letter, list) -> (letter to i).also { i += list.size + 1 } } + } + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 96.dp)) { + if (state.duplicates > 0) { + item(key = "dup") { + Row( + Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp).fillMaxWidth().clip(RoundedCornerShape(12.dp)) + .background(extra.warnBg).clickable(role = Role.Button, onClick = nav.duplicates).padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Users, contentDescription = null, tint = extra.warn, modifier = Modifier.size(18.dp)) + Text(pluralStringResource(R.plurals.con_duplicates_n, state.duplicates, state.duplicates), style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.con_look), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = extra.warn) + } + } + } + if (shown.isEmpty()) { + item(key = "empty") { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + when { + state.loading -> CircularProgressIndicator(Modifier.size(28.dp)) + state.error != null -> Text(mailText(context, state.error), color = extra.muted) + else -> Text(stringResource(if (state.query.isBlank()) R.string.con_none else R.string.mail_search_none), color = extra.muted) + } + } + } + } + sections.forEach { (letter, list) -> + item(key = "l$letter") { + Text(letter, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = extra.accentText, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp).semantics { heading() }) + } + items(list, key = { "c${it.id}" }) { c -> + val book = books?.book(c.addressbookId) + ContactRow(c, sourceColor(me, book?.accountId), accountLabel(me, book?.accountId)) { nav.contact(c.id) } + } + } + } + if (sections.size > 4 && state.query.isBlank()) { + Column( + Modifier.align(Alignment.CenterEnd).width(20.dp).padding(end = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + sections.forEach { (letter, _) -> + Text( + letter, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = extra.muted, + modifier = Modifier.clickable(role = Role.Button, onClickLabel = letter) { + heads[letter]?.let { scope.launch { listState.scrollToItem(it) } } + }.padding(vertical = 1.dp), + ) + } + } + } + } +} + +/** A contact in the list (mockup): photo or initials, name, position · company or the first address, the source's dot. */ +@Composable +internal fun ContactRow(c: MCardSummary, source: Color?, sourceName: String?, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ContactAvatar(c.name, c.emails.firstOrNull(), c.id.takeIf { c.photo }, c.photoEtag) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(c.name.ifBlank { c.emails.firstOrNull() ?: "–" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val sub = MailPimLogic.subline(c) + if (sub.isNotEmpty()) Text(sub, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (source != null) { + Box(Modifier.size(8.dp).clip(RoundedCornerShape(99.dp)).background(source).semantics { sourceName?.let { contentDescription = it } }) + } + } +} + +@Composable +private fun GroupsList(groups: List, all: List, onGroup: (Long) -> Unit, onNew: () -> Unit, offline: Boolean) { + val extra = GateControlTheme.extraColors + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 24.dp)) { + items(groups, key = { it.id }) { g -> + val names = all.filter { g.id in it.groups }.map { it.name }.filter { it.isNotBlank() } + Row( + Modifier.fillMaxWidth().heightIn(min = 60.dp).clickable(role = Role.Button) { onGroup(g.id) }.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val tint = com.gatecontrol.android.ui.mail.calendar.calColor(g.color, extra.accentText) + Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(tint.copy(alpha = 0.16f)), contentAlignment = Alignment.Center) { + Icon(GcIcons.Users, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(g.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + if (names.isNotEmpty()) { + val more = g.count - names.take(2).size + Text( + names.take(2).joinToString(", ") + if (more > 0) " " + pluralStringResource(R.plurals.con_and_more, more, more) else "", + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text(g.count.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = extra.muted) + } + MailDivider() + } + item(key = "new") { + com.gatecontrol.android.ui.components.GcOutlineButton( + stringResource(R.string.con_new_group), + onClick = onNew, + enabled = !offline, + icon = GcIcons.Plus, + fillWidth = false, + modifier = Modifier.padding(16.dp), + ) + } + } +} + +/** The accent pill with "+" (like "Neue Mail"). */ +@Composable +internal fun NewButton(label: String, modifier: Modifier, onClick: () -> Unit) { + val scheme = MaterialTheme.colorScheme + Row( + modifier.heightIn(min = 56.dp).clip(RoundedCornerShape(18.dp)).background(scheme.primary).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(GcIcons.Plus, contentDescription = null, tint = scheme.onPrimary, modifier = Modifier.size(20.dp)) + Text(label, color = scheme.onPrimary, fontWeight = FontWeight.Bold) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsViewModel.kt new file mode 100644 index 00000000..23c71a72 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailContactsViewModel.kt @@ -0,0 +1,186 @@ +package com.gatecontrol.android.ui.mail.contacts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MBooks +import com.gatecontrol.android.mail.MCardSummary +import com.gatecontrol.android.mail.MExport +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** What the list shows: all contacts, one address book or one group. */ +data class ContactsFilter(val book: Long? = null, val group: Long? = null) + +data class ContactsState( + val query: String = "", + /** contacts | groups */ + val tab: String = TAB_CONTACTS, + val filter: ContactsFilter = ContactsFilter(), + val loading: Boolean = true, + val error: String? = null, + /** Possible duplicates (the band and the menu). */ + val duplicates: Int = 0, + /** Found by the server while nothing is copied yet. */ + val remote: List? = null, + val message: String? = null, + /** The vCard file to share or save. */ + val export: MExport? = null, + val busy: Boolean = false, +) { + companion object { + const val TAB_CONTACTS = "contacts" + const val TAB_GROUPS = "groups" + } +} + +/** + * The contacts area of the Mail tab (contract D4): every contact (copied, searched on the device), the address books + * and groups of the menu, the duplicates' count, a new group, vCard import (Gmail) and export. + */ +@HiltViewModel +class MailContactsViewModel @Inject constructor( + private val repository: MailRepository, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val books: StateFlow = pim.books + val offline: StateFlow = pim.offline + val relogins: StateFlow> = pim.relogins + + private val _state = MutableStateFlow(ContactsState()) + val state: StateFlow = _state.asStateFlow() + + /** The list shown: filtered by address book or group, searched on the device. */ + val shown: StateFlow> = combine(pim.cards, _state) { cards, s -> + val list = when { + s.filter.group != null -> cards.filter { s.filter.group in it.groups } + s.filter.book != null -> cards.filter { it.addressbookId == s.filter.book } + else -> cards + } + if (s.query.isBlank()) list else (s.remote?.takeIf { cards.isEmpty() } ?: MailPimLogic.search(list, s.query)) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val total: StateFlow> = pim.cards + + private var searchJob: Job? = null + + init { + refresh() + viewModelScope.launch { + pim.contactsChanged.collect { refreshDuplicates() } + } + } + + fun refresh() { + _state.update { it.copy(loading = pim.cards.value.isEmpty(), error = null) } + viewModelScope.launch { + pim.refreshBooks() + when (val r = pim.syncCards()) { + is MResult.Ok -> _state.update { it.copy(loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r).takeIf { pim.cards.value.isEmpty() }) } + } + refreshDuplicates() + } + } + + fun onShown() { + pim.refreshBooksSoon() + pim.syncCardsSoon() + } + + private fun refreshDuplicates() { + viewModelScope.launch { + api.duplicates().valueOrNull()?.let { d -> _state.update { it.copy(duplicates = d.size) } } + } + } + + fun setQuery(q: String) { + _state.update { it.copy(query = q.take(100), remote = null) } + searchJob?.cancel() + if (pim.cards.value.isNotEmpty() || q.trim().length < 2) return + // nothing copied yet: the server searches + searchJob = viewModelScope.launch { + delay(300) + api.cards(q = q).valueOrNull()?.let { (page, _) -> _state.update { it.copy(remote = page.items) } } + } + } + + fun setTab(tab: String) = _state.update { it.copy(tab = tab) } + + fun setFilter(f: ContactsFilter) = _state.update { it.copy(filter = f, tab = ContactsState.TAB_CONTACTS) } + + fun toggleBook(id: Long) { + val b = pim.books.value?.book(id) ?: return + viewModelScope.launch { + when (val r = api.patchAddressbook(id, !b.visible)) { + is MResult.Ok -> { + pim.refreshBooks() + pim.syncCards(full = true) + } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun createGroup(name: String) { + if (name.isBlank()) return + viewModelScope.launch { + when (val r = api.createGroup(name)) { + is MResult.Ok -> pim.refreshBooks() + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + /** vCard import (a file of Google Contacts, …) into the default (or [book]) address book. */ + fun importVcf(text: String, book: Long? = null) { + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.importVcf(text, book)) { + is MResult.Ok -> { + _state.update { it.copy(busy = false, message = "import:${r.value.imported}:${r.value.updated}:${r.value.skipped}") } + pim.syncCards() + pim.refreshBooks() + } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** vCard export of the list shown (an address book, a group or all). */ + fun export() { + val f = _state.value.filter + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.exportVcf(f.book, f.group)) { + is MResult.Ok -> _state.update { it.copy(busy = false, export = r.value) } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + fun exported() = _state.update { it.copy(export = null) } + + fun failed(code: String) = _state.update { it.copy(message = code) } + + fun clearMessage() = _state.update { it.copy(message = null) } + + fun relogin(accountId: Long) = pim.markRelogin(accountId) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailDuplicatesScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailDuplicatesScreen.kt new file mode 100644 index 00000000..5c9f7fd9 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailDuplicatesScreen.kt @@ -0,0 +1,277 @@ +package com.gatecontrol.android.ui.mail.contacts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MBooks +import com.gatecontrol.android.mail.MCard +import com.gatecontrol.android.mail.MDuplicate +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MMergePick +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class DuplicatesState( + val items: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, + /** The group being merged: its full contacts. */ + val merging: MDuplicate? = null, + val cards: List = emptyList(), + val busy: Boolean = false, + val message: String? = null, +) + +/** "Dubletten" (contract D4): the possible duplicates, merging (with the fields to keep) and "Keine Dublette". */ +@HiltViewModel +class MailDuplicatesViewModel @Inject constructor(private val repository: MailRepository) : ViewModel() { + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val books: StateFlow = pim.books + + private val _state = MutableStateFlow(DuplicatesState()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + when (val r = api.duplicates()) { + is MResult.Ok -> _state.update { it.copy(items = r.value, loading = false) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + /** "Zusammenführen": the full contacts for the preview. */ + fun open(d: MDuplicate) { + _state.update { it.copy(merging = d, cards = emptyList(), busy = true) } + viewModelScope.launch { + val cards = d.cards.mapNotNull { c -> pim.card(c.id).valueOrNull() } + _state.update { it.copy(cards = cards, busy = false) } + } + } + + fun close() = _state.update { it.copy(merging = null, cards = emptyList()) } + + fun merge(primary: Long, pick: MMergePick) { + val d = _state.value.merging ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.merge(MailPimJson.mergeBody(d.cards.map { it.id }, primary, pick, joinNotes = true))) { + is MResult.Ok -> { + _state.update { s -> s.copy(busy = false, merging = null, items = s.items - d, message = "merged") } + pim.syncCards(full = true) + } + else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + } + } + } + + /** "Keine Dublette": the pair is not shown again. */ + fun ignore(d: MDuplicate) { + viewModelScope.launch { + when (val r = api.ignoreDuplicates(d.cards.map { it.id })) { + is MResult.Ok -> _state.update { s -> s.copy(items = s.items - d) } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun clearMessage() = _state.update { it.copy(message = null) } +} + +/** "Dubletten" (mockup "Kontakte" band): the groups with their reason, "Zusammenführen" and "Keine Dublette". */ +@Composable +fun MailDuplicatesScreen(onBack: () -> Unit, onContact: (Long) -> Unit, viewModel: MailDuplicatesViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val books by viewModel.books.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + LaunchedEffect(state.message) { + state.message?.let { + toast(context, if (it == "merged") context.getString(R.string.con_merged) else mailText(context, it)) + viewModel.clearMessage() + } + } + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.con_duplicates), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + when { + state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + state.error != null -> ErrorBox(mailText(context, state.error), onRetry = viewModel::load) + state.items.isEmpty() -> ErrorBox(stringResource(R.string.con_no_duplicates), onRetry = null) + else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(state.items, key = { it.key }) { d -> + GcCard(contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.Top) { + Text(reasonText(d.reason), style = MaterialTheme.typography.labelMedium, color = extra.warn, modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) + d.cards.forEach { c -> + val book = books?.book(c.addressbookId) + ContactRow(c, sourceColor(me, book?.accountId), accountLabel(me, book?.accountId)) { onContact(c.id) } + } + Row(Modifier.padding(horizontal = 14.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcPrimaryButton(stringResource(R.string.con_merge), onClick = { viewModel.open(d) }, modifier = Modifier.weight(1f)) + GcOutlineButton(stringResource(R.string.con_not_duplicate), onClick = { viewModel.ignore(d) }, modifier = Modifier.weight(1f)) + } + } + } + } + } + } + state.merging?.let { d -> MergeDialog(d, state.cards, state.busy, onMerge = viewModel::merge, onDismiss = viewModel::close) } +} + +@Composable +private fun reasonText(reason: String): String = stringResource( + when (reason) { + "email" -> R.string.con_reason_email + "phone" -> R.string.con_reason_phone + else -> R.string.con_reason_name + }, +) + +/** + * The merge preview: which contact stays, per differing field which value; contacts of different address books are + * linked ("beim Anbieter bleibt beides"). + */ +@Composable +private fun MergeDialog(d: MDuplicate, cards: List, busy: Boolean, onMerge: (Long, MMergePick) -> Unit, onDismiss: () -> Unit) { + var primary by remember(d.key) { mutableLongStateOf(d.cards.first().id) } + var pick by remember(d.key) { mutableStateOf(MMergePick()) } + val extra = GateControlTheme.extraColors + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.con_merge)) }, + text = { + if (cards.size < 2) { + Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } + return@AlertDialog + } + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (MailPimLogic.links(d.cards)) { + GcBanner(tone = GcTone.Info, icon = GcIcons.Link) { Text(stringResource(R.string.con_merge_link), style = MaterialTheme.typography.bodySmall) } + } + Text(stringResource(R.string.con_merge_keep), style = MaterialTheme.typography.labelMedium, color = extra.muted) + cards.forEach { c -> Choice(c.label + (c.company?.let { " · $it" } ?: ""), primary == c.id) { primary = c.id } } + val conflicts = MailPimLogic.conflicts(cards) + conflicts.forEach { field -> + Text(fieldName(field), style = MaterialTheme.typography.labelMedium, color = extra.muted, modifier = Modifier.padding(top = 6.dp)) + cards.forEach { c -> + val value = when (field) { + "name" -> c.label + "company" -> c.company + "title" -> c.title + else -> c.birthday + } ?: return@forEach + val chosen = when (field) { + "name" -> pick.name + "company" -> pick.company + "title" -> pick.title + else -> pick.birthday + } ?: primary + Choice(value, chosen == c.id) { + pick = when (field) { + "name" -> pick.copy(name = c.id) + "company" -> pick.copy(company = c.id) + "title" -> pick.copy(title = c.id) + else -> pick.copy(birthday = c.id) + } + } + } + } + val preview = MailPimLogic.merged(cards, primary, pick) + Text( + stringResource(R.string.con_merge_result, preview.emails.size, preview.phones.size), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(top = 6.dp), + ) + } + }, + confirmButton = { TextButton(enabled = !busy && cards.size >= 2, onClick = { onMerge(primary, pick) }) { Text(stringResource(R.string.con_merge), fontWeight = FontWeight.Bold) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} + +@Composable +private fun fieldName(field: String): String = stringResource( + when (field) { + "name" -> R.string.con_name + "company" -> R.string.con_company + "title" -> R.string.con_position + else -> R.string.con_birthday + }, +) + +@Composable +private fun Choice(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 8.dp)) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailGroupScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailGroupScreen.kt new file mode 100644 index 00000000..afb8c60d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/contacts/MailGroupScreen.kt @@ -0,0 +1,246 @@ +package com.gatecontrol.android.ui.mail.contacts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAddress +import com.gatecontrol.android.mail.MCardSummary +import com.gatecontrol.android.mail.MGroupDetail +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.MailDivider +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.mail.calendar.NameDialog +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class GroupState( + val detail: MGroupDetail? = null, + val loading: Boolean = true, + val error: String? = null, + val message: String? = null, + val gone: Boolean = false, +) + +/** A group (distribution list): its members, add and remove, rename, delete. */ +@HiltViewModel +class MailGroupViewModel @Inject constructor(private val repository: MailRepository, saved: SavedStateHandle) : ViewModel() { + private val pim = repository.pim + private val api = repository.api + val cards: StateFlow> = pim.cards + val offline: StateFlow = pim.offline + val id: Long = saved.get(Screen.MailGroup.ARG_ID)?.toLongOrNull() ?: 0L + + private val _state = MutableStateFlow(GroupState()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + when (val r = api.group(id)) { + is MResult.Ok -> _state.update { it.copy(detail = r.value, loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + private fun members(body: com.google.gson.JsonObject) { + viewModelScope.launch { + when (val r = api.groupMembers(id, body)) { + is MResult.Ok -> { + _state.update { it.copy(detail = r.value) } + pim.refreshBooks() + pim.syncCardsSoon() + } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun addContact(cardId: Long) = members(MailPimJson.membersBody(addContacts = listOf(cardId))) + + fun addAddress(a: MAddress) = members(MailPimJson.membersBody(addEmails = listOf(a))) + + fun remove(memberId: Long) = members(MailPimJson.membersBody(remove = listOf(memberId))) + + fun rename(name: String) { + viewModelScope.launch { + when (val r = api.renameGroup(id, name)) { + is MResult.Ok -> { + _state.update { s -> s.copy(detail = s.detail?.copy(group = r.value)) } + pim.refreshBooks() + } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun delete() { + viewModelScope.launch { + when (val r = api.deleteGroup(id)) { + is MResult.Ok -> { + pim.refreshBooks() + _state.update { it.copy(gone = true) } + } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } + + fun clearMessage() = _state.update { it.copy(message = null) } +} + +/** A group: "Mail an alle", the members (a tap opens the contact), add and remove; rename and delete in "⋯". */ +@Composable +fun MailGroupScreen(onBack: () -> Unit, onContact: (Long) -> Unit, mailTo: (String) -> Unit, viewModel: MailGroupViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val cards by viewModel.cards.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + var menu by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + var deleting by remember { mutableStateOf(false) } + var adding by remember { mutableStateOf(false) } + LaunchedEffect(state.gone) { if (state.gone) onBack() } + LaunchedEffect(state.message) { + state.message?.let { + toast(context, mailText(context, it)) + viewModel.clearMessage() + } + } + val d = state.detail + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = d?.group?.name ?: stringResource(R.string.con_group), onBack = onBack, backDescription = stringResource(R.string.mail_back)) { + Box { + GcIconButton(GcIcons.MoreVertical, contentDescription = stringResource(R.string.con_more), onClick = { menu = true }, enabled = d != null && !offline) + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.cal_rename)) }, onClick = { menu = false; renaming = true }) + DropdownMenuItem(text = { Text(stringResource(R.string.con_group_delete), color = MaterialTheme.colorScheme.error) }, onClick = { menu = false; deleting = true }) + } + } + } + when { + d != null -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 24.dp)) { + item(key = "actions") { + Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val emails = d.members.mapNotNull { it.email } + GcPrimaryButton(stringResource(R.string.con_mail_all), onClick = { mailTo(emails.joinToString(", ")) }, enabled = emails.isNotEmpty(), modifier = Modifier.weight(1f), icon = GcIcons.Mail) + com.gatecontrol.android.ui.components.GcOutlineButton(stringResource(R.string.con_add_member), onClick = { adding = true }, enabled = !offline, modifier = Modifier.weight(1f), icon = GcIcons.Plus) + } + } + if (d.members.isEmpty()) item(key = "none") { Text(stringResource(R.string.con_group_empty), color = extra.muted, modifier = Modifier.padding(16.dp)) } + items(d.members, key = { it.id }) { m -> + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp) + .clickable(role = Role.Button, enabled = m.contactId != null) { m.contactId?.let(onContact) } + .padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val card = cards.firstOrNull { it.id == m.contactId } + ContactAvatar(m.name.ifBlank { m.email.orEmpty() }, m.email, card?.id?.takeIf { card.photo }, card?.photoEtag) + Column(Modifier.weight(1f)) { + Text(m.name.ifBlank { m.email ?: "–" }, style = MaterialTheme.typography.bodyLarge, maxLines = 1) + if (m.name.isNotBlank() && m.email != null) Text(m.email, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1) + } + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.mail_remove), onClick = { viewModel.remove(m.id) }, enabled = !offline, size = 40.dp) + } + MailDivider() + } + } + state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + else -> ErrorBox(mailText(context, state.error), onRetry = viewModel::load) + } + } + if (renaming && d != null) NameDialog(stringResource(R.string.cal_rename), d.group.name, onDone = viewModel::rename, onDismiss = { renaming = false }) + if (deleting && d != null) { + AlertDialog( + onDismissRequest = { deleting = false }, + title = { Text(stringResource(R.string.con_group_delete_title, d.group.name)) }, + text = { Text(stringResource(R.string.con_group_delete_text)) }, + confirmButton = { TextButton(onClick = { deleting = false; viewModel.delete() }) { Text(stringResource(R.string.mail_delete), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { deleting = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } + if (adding && d != null) { + AddMemberDialog(cards.filter { c -> d.members.none { it.contactId == c.id } }, onContact = { adding = false; viewModel.addContact(it) }, onAddress = { adding = false; viewModel.addAddress(it) }, onDismiss = { adding = false }) + } +} + +/** Members to add: a contact (searched on the device) or an address typed in. */ +@Composable +private fun AddMemberDialog(cards: List, onContact: (Long) -> Unit, onAddress: (MAddress) -> Unit, onDismiss: () -> Unit) { + var q by remember { mutableStateOf("") } + val found = remember(q, cards) { MailPimLogic.search(cards, q).take(8) } + val typed = MailLogic.parseAddress(q.trim())?.takeIf { MailLogic.isEmail(it.email) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.con_add_member)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + GcTextField(q, { q = it.take(100) }, stringResource(R.string.con_search), placeholder = stringResource(R.string.con_search_hint)) + if (q.isNotBlank()) found.forEach { c -> ContactRow(c, null, null) { onContact(c.id) } } + if (typed != null) TextButton(onClick = { onAddress(typed) }) { Text(stringResource(R.string.con_add_address_member, typed.email)) } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt index e2a6bd54..35cc6d27 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt @@ -41,6 +41,8 @@ class StartPageMenu( val locked: Boolean, val onUnpin: () -> Unit, val onArrange: () -> Unit, + /** A page without a tab (Mail's calendar, contacts): "Seite entfernen", it stays in the Mail tab. */ + val pageOnly: Boolean = false, ) /** @@ -76,10 +78,11 @@ fun StartPageMenuButton(menu: StartPageMenu, modifier: Modifier = Modifier) { DropdownMenuItem( text = { Column { - Text(stringResource(if (menu.widgets) R.string.pages_remove else R.string.pages_as_tab)) + Text(stringResource(if (menu.widgets || menu.pageOnly) R.string.pages_remove else R.string.pages_as_tab)) val hint = when { menu.locked -> R.string.arrange_locked menu.widgets -> null + menu.pageOnly -> R.string.pages_in_mail_desc else -> R.string.pages_as_tab_desc } if (hint != null) { @@ -92,7 +95,7 @@ fun StartPageMenuButton(menu: StartPageMenu, modifier: Modifier = Modifier) { } } }, - leadingIcon = { Icon(if (menu.widgets) GcIcons.Trash else GcIcons.Pin, contentDescription = null, modifier = Modifier.size(20.dp)) }, + leadingIcon = { Icon(if (menu.widgets || menu.pageOnly) GcIcons.Trash else GcIcons.Pin, contentDescription = null, modifier = Modifier.size(20.dp)) }, enabled = !menu.locked, onClick = { open = false; menu.onUnpin() }, ) @@ -132,6 +135,8 @@ fun featureLabel(id: String): Int? = when (id) { StartPages.DRIVE -> R.string.drive_tab StartPages.NETWORK -> R.string.nav_network StartPages.FAMILIE -> R.string.nav_familie + StartPages.CALENDAR -> R.string.mail_calendar + StartPages.CONTACTS -> R.string.mail_contacts else -> null } @@ -144,5 +149,7 @@ fun startPageIcon(id: String): ImageVector = when (id) { StartPages.DRIVE -> GcIcons.Cloud StartPages.NETWORK -> GcIcons.Globe StartPages.FAMILIE -> GcIcons.Family + StartPages.CALENDAR -> GcIcons.Calendar + StartPages.CONTACTS -> GcIcons.User else -> GcIcons.ViewGrid } diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt index e4d76e59..dadcaf9c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt @@ -202,6 +202,8 @@ fun StartPager( val unpinNow: (String) -> Unit = { id -> val message = if (StartPages.isWidgets(id)) { resources.getString(R.string.pages_snack_removed) + } else if (StartPages.isPageOnly(id)) { + resources.getString(R.string.pages_snack_in_mail, startPageLabel(resources, id)) } else { resources.getString(R.string.pages_snack_unpinned, startPageLabel(resources, id)) } @@ -217,6 +219,7 @@ fun StartPager( locked = locked, onUnpin = { unpin(id) }, onArrange = onArrangePages, + pageOnly = StartPages.isPageOnly(id), ) Column(Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt index 21142216..14f14e76 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt @@ -195,7 +195,7 @@ fun StartPagesArrangeScreen( grip = !locked, trailing = { GcOutlineButton( - text = stringResource(if (StartPages.isWidgets(id)) R.string.pages_remove_short else R.string.pages_as_tab_short), + text = stringResource(if (StartPages.isWidgets(id) || StartPages.isPageOnly(id)) R.string.pages_remove_short else R.string.pages_as_tab_short), onClick = { remove(id) }, enabled = !locked, fillWidth = false, @@ -323,9 +323,10 @@ fun StartPagesSettingsScreen( val actions = rememberPageActions(viewModel) val locked = access.locked val pinned = StartPages.visible(all, available) - val tabs = listOf(START_PAGE) + available.filter { it !in all } + val tabs = listOf(START_PAGE) + available.filter { it !in all && !StartPages.isPageOnly(it) } val tabLabel = stringResource(R.string.pages_tab) val pageLabel = stringResource(R.string.pages_page) + val inMailLabel = stringResource(R.string.pages_in_mail) Column( Modifier @@ -397,7 +398,7 @@ fun StartPagesSettingsScreen( Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) Box(Modifier.width(168.dp).semantics { contentDescription = groupLabel }) { GcSegmented( - options = listOf(false to tabLabel, true to pageLabel), + options = listOf(false to if (StartPages.isPageOnly(id)) inMailLabel else tabLabel, true to pageLabel), selected = asPage, onSelect = { page -> if (!locked && page != asPage) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt index 785c6a90..168b235b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt @@ -26,8 +26,12 @@ import javax.inject.Inject class StartPagesViewModel @Inject constructor( private val store: StartPagesStore, arrangement: StartArrangement, + mail: com.gatecontrol.android.mail.MailRepository, ) : ViewModel() { + /** Mail's `me` (its calendar and contacts can be pages beside Start). */ + val mailMe: StateFlow = mail.me + /** All page ids in their order (StartPages.visible picks those shown). */ val pages: StateFlow> = store.pages diff --git a/app/src/main/res/values-de/strings_pim.xml b/app/src/main/res/values-de/strings_pim.xml new file mode 100644 index 00000000..4ebec0b7 --- /dev/null +++ b/app/src/main/res/values-de/strings_pim.xml @@ -0,0 +1,271 @@ + + + + Kalender + Kalender-Einladungen + Posteingänge + Einladung + OK + Im Mail-Tab + Bleibt im Mail-Tab + „%1$s“ ist wieder nur im Mail-Tab + Offline – geht wieder mit Verbindung + Kalender und Kontakte von %1$s freigeben + Deine Firma muss die neuen Rechte freigeben. Gib diesen Link an deine Administration weiter. + Link teilen + Neu anmelden + Kalender braucht ein GateControl-Update. + Kontakte brauchen ein GateControl-Update. + Meine Änderung erneut anwenden + Neu laden + Kontakt + GateControl + Zuletzt + Abgleich + Anmeldung + Fehler + Neuer Termin + Termin + Termin bearbeiten + Heute + Zurück + Weiter + Tag + 3 Tage + Woche + Monat + Liste + eingeblendet + ausgeblendet + Ganztägig + (Ohne Titel) + Einladung offen + nur ansehen + Keine Termine. + Optionen + Firma + GateControl + Konto + Eingeblendet · nur ansehen + Kalender hinzufügen + iCal-Abo + Eine Adresse im iCal-Format, z. B. bei Gmail die „Geheime Adresse im iCal-Format“. Wird nur angezeigt, nie geändert. + Adresse + Abonnieren + Name + Erinnerungen + Umbenennen + Kalender löschen + „%1$s“ löschen? + Der Kalender und alle seine Termine werden gelöscht. + Name und Farbe ändern sich nur hier, nicht beim Anbieter. + Abgesagt + Karte + Online-Besprechung (%1$s) + Link + Teilnehmen + Als frei angezeigt + Privat + Andere sehen nur „beschäftigt“ + Teilnehmer + %1$d von %2$d zugesagt + Ressource + zugesagt + mit Vorbehalt + abgesagt + Organisation + noch keine Antwort + Dieser Termin lässt sich hier nur ansehen. + Deine Antwort: %1$s + Zusagen + Mit Vorbehalt + Absagen + Kommentar + Kommentar hinzufügen + Antwort gesendet + Termin löschen? + Teilnehmer benachrichtigen + Absage an die Organisation senden + Nur dieser Termin + Alle Termine der Serie + Serie ändern? + Eine geänderte Zeit lässt sich mit dieser Server-Version nur für diesen Termin speichern. + Titel + Kalender + Beginn + Ende + Das Ende liegt vor dem Beginn. + Zeitzone + Zeitzone: %1$s + Unbekannte Zeitzone (z. B. Europe/Berlin) + Wiederholung + Nie + Täglich + Wöchentlich an Tagen + Monatlich am %1$d. + Jährlich + Diese Regel kann mehr als das Formular. Ändere nur diesen Termin oder die Serie in der App des Anbieters. + Alle + Endet + Nie + Am + Nach + Anzahl Termine + Ort + Teams-Besprechung + Online-Link + Erinnerungen + Keine Erinnerung + Erinnerung hinzufügen + Zu Beginn + Am Tag um %1$s + Am Vortag um %1$s + Teilnehmer hinzufügen + Name, Adresse oder Gruppe + Einladungen werden gesendet über %1$s + Microsoft verschickt die Einladungen + Als frei anzeigen + Beschreibung + Der Termin wurde inzwischen geändert + Jemand hat den Termin geändert, seit du ihn geöffnet hast. Lade den neuen Stand oder wende deine Änderung darauf an. + Einladung + Es gibt eine neuere Einladung. + Termin abgesagt + Aus Kalender entfernen + %1$s hat %2$s + %1$s schlägt eine neue Zeit vor + Übernehmen + Zur selben Zeit: %1$s + Ändern + Neue Zeit vorschlagen + Vorschlagen + Eintragen in: %1$s + (neu) + Termin öffnen + Kalender von %1$s freigeben, um hier zu antworten. + Kalender freigeben + Alle Kontakte + Adressbücher + Gruppen + Gruppe + Dubletten + Weitere Aktionen + vCard importieren + Als vCard teilen + Als vCard speichern + %1$d importiert, %2$d aktualisiert, %3$d übersprungen + Gespeichert + Kontakte durchsuchen + Name, Firma, Adresse oder Nummer + Suche leeren + Neuer Kontakt + Neue Gruppe + Noch keine Kontakte. + Ansehen + Mail + Anrufen + Bearbeiten + Kontakt bearbeiten + Kontakt löschen + „%1$s“ löschen? + Der Kontakt wird gelöscht. + Der Kontakt wird auch beim Anbieter gelöscht. + Aus %1$s, nur lesend + verknüpft mit %1$s + E-Mail + Telefon + Anschrift + Geburtstag + Tag und Monat wie 14.03. + Jahr (optional) + wird %1$d + Web + Notiz + Letzte Mails + Alle Mails von %1$s + Gemeinsame Termine + Arbeit + Privat + Mobil + Fax + Andere + Foto ändern + Foto aufnehmen + Foto auswählen + Foto entfernen + Die Kamera ist nicht erlaubt. + %1$s · Änderungen gehen auch dorthin + Vorname + Nachname + Firma + Position + Name + E-Mail-Adresse hinzufügen + Telefonnummer hinzufügen + Anschrift hinzufügen + Straße und Hausnummer + PLZ + Ort + Land + Adressbuch: %1$s + %1$s hat diese Adresse schon. + Zu %1$s hinzufügen + Der Kontakt wurde inzwischen geändert + Jemand hat den Kontakt geändert, seit du ihn geöffnet hast. Lade den neuen Stand oder wende deine Änderung darauf an. + Kontakt öffnen + Als Kontakt speichern + Zusammenführen + Keine Dublette + Keine möglichen Dubletten. + Gleiche Adresse + Gleiche Nummer + Gleicher Name + Sie stammen aus verschiedenen Adressbüchern: sie werden verknüpft, beim Anbieter bleibt beides. + Welcher Kontakt bleibt + Zusammen: %1$d Adressen, %2$d Nummern + Zusammengeführt + Mail an alle + Hinzufügen + Noch keine Mitglieder. + Gruppe löschen + „%1$s“ löschen? + Die Gruppe wird gelöscht, ihre Kontakte bleiben. + %1$s hinzufügen + + %d Minute vorher + %d Minuten vorher + + + %d Stunde vorher + %d Stunden vorher + + + %d Tag vorher + %d Tage vorher + + + %d Woche vorher + %d Wochen vorher + + + %1$d Tag vorher um %2$s + %1$d Tage vorher um %2$s + + + %d Kontakt + %d Kontakte + + + %d Adressbuch + %d Adressbücher + + + %d mögliche Dublette + %d mögliche Dubletten + + + und %d weitere + und %d weitere + + So lässt sich die ganze Serie nicht verschieben (z. B. um eine ganze Periode oder über den 28. hinaus). Ändere nur diesen Termin oder lege die Regel der Serie neu fest. + diff --git a/app/src/main/res/values/strings_pim.xml b/app/src/main/res/values/strings_pim.xml new file mode 100644 index 00000000..8a1ce7e2 --- /dev/null +++ b/app/src/main/res/values/strings_pim.xml @@ -0,0 +1,271 @@ + + + + Calendar + Calendar invitations + Inboxes + Invitation + OK + In Mail + Stays in the Mail tab + “%1$s” is in the Mail tab only again + Offline – works again once connected + Allow calendar and contacts of %1$s + Your company has to allow the new rights. Pass this link on to your administrator. + Share link + Sign in again + The calendar needs a GateControl update. + Contacts need a GateControl update. + Apply my change again + Reload + Contact + GateControl + Recently + Syncing + Sign-in + Error + New event + Event + Edit event + Today + Back + Next + Day + 3 days + Week + Month + List + shown + hidden + All day + (No title) + Invitation open + view only + Nothing planned. + Options + Business + GateControl + Account + Shown here · view only + Add calendar + iCal subscription + An address in iCal format, e.g. Gmail’s “Secret address in iCal format”. Only shown, never changed. + Address + Subscribe + Name + Reminders + Rename + Delete calendar + Delete “%1$s”? + The calendar and all its events will be deleted. + Name and color only change here, not at the provider. + Cancelled + Map + Online meeting (%1$s) + link + Join + Shown as free + Private + Others only see “busy” + Attendees + %1$d of %2$d accepted + Resource + accepted + tentative + declined + Organizer + no answer yet + This event can only be viewed here. + You answered: %1$s + Accept + Tentative + Decline + Comment + Add a comment + Answer sent + Delete event? + Tell the attendees + Send a decline to the organizer + Only this event + All events of the series + Change the series? + A changed time can only be saved for this event with this server version. + Title + Calendar + Start + End + The end lies before the start. + Time zone + Time zone: %1$s + Unknown time zone (e.g. Europe/Berlin) + Repeat + Never + Daily + Weekly on days + Monthly on day %1$d + Yearly + This rule can do more than the form. Change only this event, or the series in the provider’s app. + Every + Ends + Never + On + After + Number of events + Place + Teams meeting + Online link + Reminders + No reminder + Add reminder + At the start + On the day at %1$s + The day before at %1$s + Add attendees + Name, address or group + Invitations are sent via %1$s + Microsoft sends the invitations + Show as free + Description + The event was changed meanwhile + Someone changed this event since you opened it. Load the new state, or apply your change to it. + Invitation + There is a newer invitation. + Event cancelled + Remove from calendar + %1$s has %2$s + %1$s proposes another time + Take over + At the same time: %1$s + Change + Propose a new time + Propose + Add to: %1$s + (new) + Open event + Allow the calendar of %1$s to answer here. + Allow calendar + All contacts + Address books + Groups + Group + Duplicates + More actions + Import vCard + Share as vCard + Save as vCard + %1$d imported, %2$d updated, %3$d skipped + Saved + Search contacts + Name, company, address or number + Clear search + New contact + New group + No contacts yet. + Look + Mail + Call + Edit + Edit contact + Delete contact + Delete “%1$s”? + The contact will be deleted. + The contact will also be deleted at the provider. + From %1$s, view only + linked with %1$s + Email + Phone + Address + Birthday + Day and month like 14.03. + Year (optional) + turns %1$d + Web + Note + Recent mails + All mails from %1$s + Shared events + Work + Private + Mobile + Fax + Other + Change photo + Take photo + Choose photo + Remove photo + The camera is not allowed. + %1$s · changes go there too + First name + Last name + Company + Position + Name + Add email address + Add phone number + Add address + Street and number + Postcode + City + Country + Address book: %1$s + %1$s already has this address. + Add to %1$s + The contact was changed meanwhile + Someone changed this contact since you opened it. Load the new state, or apply your change to it. + Open contact + Save as contact + Merge + Not a duplicate + No possible duplicates. + Same email address + Same phone number + Same name + They come from different address books: they are linked, the provider keeps both. + Which contact stays + Together: %1$d addresses, %2$d numbers + Merged + Mail to all + Add + No members yet. + Delete group + Delete “%1$s”? + The group goes; its contacts stay. + Add %1$s + + %d minute before + %d minutes before + + + %d hour before + %d hours before + + + %d day before + %d days before + + + %d week before + %d weeks before + + + %1$d day before at %2$s + %1$d days before at %2$s + + + %d contact + %d contacts + + + %d address book + %d address books + + + %d possible duplicate + %d possible duplicates + + + and %d more + and %d more + + The whole series cannot be moved like this (e.g. by a whole period or past the 28th). Change only this event, or set the series’ rule anew. + diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailPimJsonTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailPimJsonTest.kt new file mode 100644 index 00000000..d39ceedc --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailPimJsonTest.kt @@ -0,0 +1,329 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.notify.model.PushMessage +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MailPimJsonTest { + + private fun o(s: String) = JsonParser.parseString(s).asJsonObject + + @Test + fun `me - calendar and contacts features, account sides, missing scopes, consent link`() { + val me = MailJson.me( + o( + """{"features":["mail","calendar","contacts","dav"],"microsoft":{"clientId":"c","adminConsentUrl":"https://login.microsoftonline.com/x/adminconsent"}, + "accounts":[{"id":3,"email":"a@firma.de","provider":"m365","calendar":{"sync":true,"state":"consent_required","readOnly":false,"server":"graph.microsoft.com"}, + "contacts":{"sync":false,"state":"off","readOnly":false,"server":null},"missingScopes":["Calendars.ReadWrite","Contacts.ReadWrite"]}, + {"id":4,"email":"b@gmx.de","provider":"gmx"}]}""", + ), + )!! + assertTrue(me.has(MMe.FEATURE_CALENDAR)) + assertTrue(me.has(MMe.FEATURE_CONTACTS)) + assertEquals("https://login.microsoftonline.com/x/adminconsent", me.microsoft.adminConsentUrl) + val a = me.account(3)!! + assertEquals(MPimSide.STATE_CONSENT, a.calendar?.state) + assertEquals(MPimSide.STATE_OFF, a.contacts?.state) + assertFalse(a.contacts!!.sync) + assertEquals(listOf(MailMicrosoft.CALENDAR_SCOPE, MailMicrosoft.CONTACTS_SCOPE), a.missingScopes) + // an older server: no sides, nothing missing + assertNull(me.account(4)!!.calendar) + assertTrue(me.account(4)!!.missingScopes.isEmpty()) + assertNull(MailJson.me(o("""{"features":["mail"],"microsoft":{"adminConsentUrl":"http://x"}}"""))!!.microsoft.adminConsentUrl) + } + + @Test + fun `suggestions - contacts, groups with their members, the old form still read`() { + val list = MailJson.contacts( + o( + """{"items":[{"name":"Anna Lang","email":"anna@x.de","source":"contact","contactId":7,"photo":true}, + {"name":"Familie (2)","email":null,"source":"group","groupId":3,"members":[{"name":"Mama","email":"m@x.de"},{"email":"p@x.de"}]}, + {"name":"","email":null,"source":"group","groupId":4,"members":[]}, + {"name":"Tim","email":"tim@firma.de","source":"person"},{"email":"old@x.de"}]}""", + ), + )!! + assertEquals(4, list.size) + assertEquals(7L, list[0].contactId) + assertTrue(list[0].photo) + assertTrue(list[1].group) + assertEquals(listOf("m@x.de", "p@x.de"), list[1].members.map { it.email }) + assertEquals(MContact.SOURCE_RECENT, list[3].source) + } + + @Test + fun `summary and message - the invitation label and card`() { + val s = MailJson.summary(o("""{"id":1,"accountId":2,"from":{"email":"a@b.de"},"invite":"request"}"""))!! + assertEquals("request", s.invite) + assertNull(MailJson.summary(o("""{"id":1,"accountId":2,"from":{"email":"a@b.de"},"invite":"weird"}"""))!!.invite) + val m = MailJson.message(o("""{"id":1,"accountId":2,"from":{"email":"a@b.de"},"invite":$INVITATION}"""))!! + assertEquals(9L, m.invite?.id) + assertNull(MailJson.message(o("""{"id":1,"accountId":2,"from":{"email":"a@b.de"},"invite":null}"""))!!.invite) + } + + @Test + fun `calendars - list, overlays, settings, consent`() { + val c = MailPimJson.calendars( + o( + """{"calendars":[{"id":1,"source":"local","accountId":null,"name":"Privat","color":"#46e0a0","kind":"private","writable":true,"visible":true,"notify":true, + "isDefault":true,"canInvite":true,"canOnline":false,"maxReminders":3,"state":"ok","error":null,"syncedAt":null,"version":4}, + {"id":2,"source":"graph","accountId":3,"name":"Kalender","color":"bad","writable":true,"visible":false,"maxReminders":1,"state":"syncing","error":{"code":"unreachable","at":5}}, + {"id":0,"name":"kaputt"}], + "overlays":[{"id":"alltag:7","source":"alltag","name":"Abfuhr","color":"#8C96A5","visible":false}], + "prefs":{"defaultCalendarId":1,"view":"week","weekStart":0,"workWeek":true,"timeZone":"Europe/Berlin","reminders":true,"defaultReminder":null,"allDayReminder":1080}, + "consent":[{"accountId":3,"missing":["Calendars.ReadWrite"]}]}""", + ), + )!! + assertEquals(listOf(1L, 2L), c.calendars.map { it.id }) + assertEquals("#46E0A0", c.calendars[0].color) + assertEquals(1, c.calendars[1].maxReminders) + assertEquals("unreachable", c.calendars[1].errorCode) + assertFalse(c.calendars[1].visible) + assertEquals("alltag:7", c.overlays.single().id) + assertEquals("week", c.prefs.view) + assertEquals(0, c.prefs.weekStart) + assertNull(c.prefs.defaultReminder) + assertEquals(1080, c.prefs.allDayReminder) + assertEquals(3L, c.consent.single().accountId) + } + + @Test + fun `events - occurrences, overlays, pending, all-day dates`() { + val e = MailPimJson.events( + o( + """{"items":[{"key":"5:1730800800000","eventId":5,"calendarId":1,"occurrence":"1730800800000","title":"Weekly","start":1730800800000,"end":1730804400000, + "allDay":false,"timeZone":"Europe/Berlin","location":"Raum 1","online":{"url":"https://teams.microsoft.com/l/x","provider":"teams"},"status":"confirmed", + "busy":"busy","myResponse":"needs_action","recurring":true,"attendees":4,"private":false,"reminder":15}, + {"eventId":6,"calendarId":1,"title":"Urlaub","start":1,"end":2,"allDay":true,"startDate":"2026-11-04","online":{"url":"javascript:alert(1)"}}, + {"eventId":7,"title":"ohne Beginn"}], + "overlays":[{"key":"bday:3:2026","overlayId":"birthdays","title":"Anna Lang (40)","start":1,"end":2,"allDay":true,"startDate":"2026-03-14","endDate":"2026-03-14","readOnly":true}], + "pending":[2]}""", + ), + )!! + assertEquals(2, e.items.size) + val w = e.items[0] + assertTrue(w.open) + assertTrue(w.recurring) + assertEquals("teams", w.online?.provider) + assertEquals("1730800800000", w.occurrence) + val u = e.items[1] + assertEquals("6", u.key) + assertEquals("2026-11-04", u.endDate) + assertNull(u.online) + assertEquals("birthdays", e.overlays.single().overlayId) + assertEquals(listOf(2L), e.pending) + } + + @Test + fun `event - attendees, recurrence, rights, etag, series`() { + val ev = MailPimJson.event(o(EVENT))!! + assertEquals(5L, ev.id) + assertEquals("Sabine Keller", ev.organizer?.label) + assertEquals(2, ev.attendees.size) + assertEquals(11L, ev.attendees[0].contactId) + assertTrue(ev.attendees[1].self) + val r = ev.recurrence!! + assertEquals(MRule.WEEKLY, r.rule.freq) + assertEquals(listOf("TU", "TH"), r.rule.byDay) + assertTrue(r.editable) + assertEquals(listOf("1730800800000"), r.exceptions) + assertTrue(ev.canRespond) + assertFalse(ev.canEdit) + assertEquals("\"1700-2\"", ev.etag) + assertEquals(listOf(15), ev.reminders) + assertEquals(1730800000000L, ev.series?.start) + // an all-day series with its dates; a single event without one + val allDay = MailPimJson.event(o("""{"eventId":8,"start":1,"end":2,"allDay":true,"startDate":"2026-01-05","series":{"start":1,"end":2,"allDay":true,"startDate":"2026-01-05","endDate":"2026-01-05"}}"""))!! + assertEquals("2026-01-05", allDay.series?.startDate) + assertNull(MailPimJson.event(o("""{"eventId":9,"start":1,"end":2,"series":null}"""))!!.series) + // a rule without the form's fields is read from the RRULE + val raw = MailPimJson.event(o("""{"eventId":9,"start":1,"end":2,"recurrence":{"rrule":"FREQ=MONTHLY;BYDAY=-1FR","editable":true}}"""))!! + assertEquals(-1, raw.recurrence?.rule?.bySetPos) + } + + @Test + fun `saving - the answer, the conflict's newer state, the series refusal`() { + val saved = MailPimJson.eventSaved(o("""{"event":$EVENT,"sent":{"mail":2,"internal":1}}"""))!! + assertEquals(2, saved.sentMail) + assertEquals(1, saved.sentInternal) + // MResult.Failed carries the error object + val conflict = MailPimJson.conflictEvent(o("""{"code":"conflict","current":$EVENT}""")) + assertEquals("\"1700-2\"", conflict?.etag) + assertNotNull(MailPimJson.conflictEvent(o("""{"error":{"code":"conflict","current":$EVENT}}"""))) + assertNull(MailPimJson.conflictEvent(o("""{"code":"conflict"}"""))) + val card = MailPimJson.conflictCard(o("""{"code":"conflict","current":{"id":4,"etag":"\"3\"","name":{"given":"Anna"}}}""")) + assertEquals("\"3\"", card?.etag) + } + + @Test + fun `invitation - request with day and conflicts, counter, reply`() { + val inv = MailPimJson.invitation(o(INVITATION))!! + assertEquals(MInvitation.METHOD_REQUEST, inv.method) + assertEquals("Wöchentlich (Di)", inv.recurrenceDe) + assertEquals(1, inv.counts.accepted) + assertEquals(3, inv.counts.total) + assertEquals(listOf(1L, 2L), inv.calendarChoices) + assertEquals("Zahnarzt", inv.conflicts.single().title) + assertEquals(1, inv.day.size) + val counter = MailPimJson.invitation(o("""{"id":3,"method":"counter","start":10,"end":20,"organizer":{"name":"Ich","email":"me@x.de"},"from":{"name":"Anna","email":"a@x.de"},"proposed":{"start":30,"end":40}}"""))!! + assertEquals(30L, counter.proposedStart) + assertEquals("Anna", counter.from.label) + val answer = MailPimJson.inviteAnswer(o("""{"invitation":$INVITATION,"event":null}"""))!! + assertNull(answer.event) + } + + @Test + fun `contacts - books, groups, page, card, lookup, duplicates, export`() { + val books = MailPimJson.books( + o( + """{"items":[{"id":1,"source":"local","name":"Meine Kontakte","writable":true,"visible":true,"isDefault":true,"count":58,"state":"ok"}, + {"id":2,"source":"graph","accountId":3,"name":"Firma","writable":true,"visible":false,"count":121,"state":"syncing"}], + "groups":[{"id":5,"name":"Familie","color":null,"count":6}],"prefs":{"sort":"given","show":"family_given","defaultAddressbookId":1,"birthdays":false}}""", + ), + )!! + assertEquals(2, books.items.size) + assertFalse(books.book(2)!!.visible) + assertEquals("Familie", books.groups.single().name) + assertEquals("given", books.prefs.sort) + assertFalse(books.prefs.birthdays) + val page = MailPimJson.cardPage( + o( + """{"items":[{"id":4,"addressbookId":1,"name":"Ärzte Haus","sortKey":"ARZTE HAUS","company":"Praxis","emails":["a@x.de","kaputt"],"phone":"0221", + "photo":true,"photoEtag":"e1","groups":[5],"linked":[9]}],"next":"abc","version":17,"deleted":[3]}""", + ), + )!! + val c = page.items.single() + assertEquals("A", c.letter) + assertEquals(listOf("a@x.de"), c.emails) + assertEquals(listOf(9L), c.linked) + assertEquals(17L, page.version) + assertEquals(listOf(3L), page.deleted) + val card = MailPimJson.fullCard(o(CARD))!! + assertEquals("Sabine", card.name.given) + assertEquals(2, card.emails.size) + assertTrue(card.emails[0].primary) + assertEquals("mobile", card.phones[0].type) + assertEquals("Köln", card.addresses.single().city) + assertEquals("--03-14", card.birthday) + assertEquals("e2", card.photoEtag) + assertEquals("graph", card.linked.single().source) + assertNull(MailPimJson.fullCard(o("""{"name":{"given":"x"}}"""))) + val l = MailPimJson.lookup(o("""{"card":$CARD,"person":{"userId":5,"name":"Sabine"},"recent":{"name":"S. K.","sent":3,"received":7,"lastAt":9}}""")) + assertEquals(4L, l.card?.id) + assertEquals("Sabine", l.personName) + assertEquals(7, l.received) + assertNull(MailPimJson.lookup(o("""{"card":null,"person":null,"recent":null}""")).card) + val dups = MailPimJson.duplicates(o("""{"items":[{"key":"k","reason":"email","cards":[{"id":1,"name":"A"},{"id":2,"name":"A"}]},{"key":"one","cards":[{"id":3}]}]}"""))!! + assertEquals(1, dups.size) + assertEquals("email", dups[0].reason) + val ex = MailPimJson.export(o("""{"name":"kontakte.vcf","mime":"text/vcard","data":"QkVHSU46VkNBUkQ="}"""))!! + assertEquals("BEGIN:VCARD", String(ex.bytes)) + assertEquals("kontakte.vcf", MailPimJson.export(o("""{"name":"../evil.sh","data":""}"""))!!.name) + assertNull(MailPimJson.photo(o("""{"mime":"image/svg+xml","data":"QQ=="}"""))) + assertEquals(3, MailPimJson.importAnswer(o("""{"imported":3,"updated":1,"skipped":0}"""))!!.imported) + } + + @Test + fun `live events - calendar and contacts`() { + val c = MailPimJson.calChanged(o("""{"calendar":{"kind":"changed","calendarId":2,"from":1,"to":2}}"""))!! + assertEquals(2L, c.calendarId) + assertEquals(MCalChanged.INVITATION, MailPimJson.calChanged(o("""{"kind":"invitation","invitationId":4,"messageId":8}"""))!!.kind) + assertNull(MailPimJson.calChanged(o("""{"kind":"exploded"}"""))) + val k = MailPimJson.contactsChanged(o("""{"kind":"changed","addressbookId":1,"contactIds":[4,5]}"""))!! + assertEquals(listOf(4L, 5L), k.contactIds) + assertEquals(MContactsChanged.GROUPS, MailPimJson.contactsChanged(o("""{"contacts":{"kind":"groups"}}"""))!!.kind) + } + + @Test + fun `request bodies - the event's fields, cleared ones as null, the rule, the series' scope`() { + val body = MailPimJson.eventBody( + MEventInput( + title = " Treffen ", allDay = false, start = 10, end = 20, timeZone = "Europe/Berlin", location = "", onlineSet = true, + recurrence = null, recurrenceSet = true, reminders = listOf(30, 15, 15), attendees = listOf(MAttendeeInput("a@x.de", "Anna")), sendInvites = true, + ), + scope = "occurrence", + occurrence = "1730800800000", + etag = "\"1-1\"", + ) + assertEquals("Treffen", body.get("title").asString) + assertTrue(body.get("location").isJsonNull) + assertTrue(body.get("online").isJsonNull) + assertTrue(body.get("recurrence").isJsonNull) + assertEquals("[15,30]", body.get("reminders").toString()) + assertEquals("occurrence", body.get("scope").asString) + assertEquals("\"1-1\"", body.get("etag").asString) + assertFalse(body.has("startDate")) + val allDay = MailPimJson.eventBody(MEventInput(allDay = true, start = 1, startDate = "2026-11-04", endDate = "2026-11-05", teams = true, onlineSet = true, recurrence = MRule(MRule.MONTHLY, byDay = listOf("TU"), bySetPos = 2), recurrenceSet = true)) + assertFalse(allDay.has("start")) + assertEquals("teams", allDay.getAsJsonObject("online").get("create").asString) + assertEquals(2, allDay.getAsJsonObject("recurrence").get("bySetPos").asInt) + val respond = MailPimJson.respondBody("tentative", " ", proposeStart = 5, proposeEnd = 4) + assertFalse(respond.has("comment")) + assertFalse(respond.has("proposeStart")) + } + + @Test + fun `request bodies - a contact in full, cleared fields as null, merge picks`() { + val c = MailPimJson.fullCard(o(CARD))!!.copy(company = "", emails = listOf(MCardEmail(" a@x.de ", "work"), MCardEmail("A@x.de"), MCardEmail("")), birthday = null) + val b = MailPimJson.cardBody(c, etag = "\"3\"") + assertTrue(b.get("company").isJsonNull) + assertTrue(b.get("birthday").isJsonNull) + assertEquals(1, b.getAsJsonArray("emails").size()) + assertEquals("a@x.de", b.getAsJsonArray("emails")[0].asJsonObject.get("value").asString) + assertEquals("\"3\"", b.get("etag").asString) + assertFalse(b.getAsJsonObject("name").has("display")) + val m = MailPimJson.mergeBody(listOf(1, 2, 2), 1, MMergePick(company = 2), joinNotes = true) + assertEquals(2, m.getAsJsonArray("ids").size()) + assertEquals(2, m.getAsJsonObject("pick").get("company").asInt) + assertEquals("join", m.getAsJsonObject("pick").get("note").asString) + } + + private fun push(topic: String, data: String?) = PushMessage( + seq = 1, id = 1, eventId = "e", topic = topic, priority = "normal", title = "Termin", body = "14:00", + createdAt = null, expiresAt = null, collapseKey = null, silent = false, route = null, actions = emptyList(), dataJson = data, + ) + + @Test + fun `pushes - a reminder opens its event, an invitation the invitation`() { + val r = push(MailPush.TOPIC_REMINDER, """{"calendar":{"eventId":5,"occurrence":"1730800800000"}}""") + assertEquals("event:5:1730800800000", MailPush.destination(r)) + assertEquals(5L to "1730800800000", MailPush.eventOf("event:5:1730800800000")) + assertEquals(5L to null, MailPush.eventOf(MailPush.destination(push(MailPush.TOPIC_REMINDER, """{"calendar":{"eventId":5,"occurrence":null}}"""))!!)) + assertEquals("event:5:2026-11-04", MailPush.destination(push(MailPush.TOPIC_REMINDER, """{"calendar":{"eventId":5,"occurrence":"2026-11-04"}}"""))) + assertEquals("event:5", MailPush.destination(push(MailPush.TOPIC_REMINDER, """{"calendar":{"eventId":5,"occurrence":"../x"}}"""))) + assertEquals("invitation:9", MailPush.destination(push(MailPush.TOPIC_INVITE, """{"calendar":{"invitationId":9}}"""))) + assertEquals(9L, MailPush.invitationOf("invitation:9")) + assertNull(MailPush.eventOf("message:4")) + assertTrue(MailPush.isCalendarTopic(MailPush.TOPIC_REMINDER)) + assertFalse(MailPush.isCalendarTopic(MailPush.TOPIC_MAIL)) + } + + private companion object { + const val EVENT = """{"key":"5","eventId":5,"calendarId":2,"occurrence":null,"title":"Weekly","start":1730800800000,"end":1730804400000,"allDay":false, + "timeZone":"Europe/Berlin","status":"confirmed","busy":"busy","myResponse":"needs_action","recurring":true,"attendees":2,"private":false,"reminder":15, + "description":"Agenda","descriptionHtml":null,"organizer":{"name":"Sabine Keller","email":"s@k.de","self":false}, + "attendees":[{"name":"Anna","email":"a@x.de","role":"required","response":"accepted","self":false,"contactId":11},{"name":"","email":"me@x.de","role":"optional","response":"needs_action","self":true}], + "reminders":[15],"recurrence":{"rrule":"FREQ=WEEKLY;BYDAY=TU,TH","editable":true,"freq":"weekly","interval":1,"byDay":["TU","TH"],"byMonthDay":null,"bySetPos":null, + "until":null,"count":null,"exceptions":["1730800800000"],"deleted":[]},"seriesId":null,"readOnly":false,"canRespond":true,"canEdit":false,"canDelete":true, + "categories":[],"invitationId":9,"url":null,"createdAt":1,"updatedAt":1700,"etag":"\"1700-2\"","series":{"start":1730800000000,"end":1730803600000,"allDay":false}}""" + + const val INVITATION = """{"id":9,"kind":"mail","messageId":1,"accountId":2,"method":"request","state":"open","uid":"u","title":"Sprint-Review","start":10,"end":20, + "allDay":false,"timeZone":"Europe/Berlin","location":"Raum 1","online":null,"recurrence":{"summary":{"de":"Wöchentlich (Di)","en":"Weekly (Tue)"}}, + "organizer":{"name":"Sabine","email":"s@k.de","contactId":null},"from":{"name":"Sabine","email":"s@k.de"}, + "attendees":[{"name":"Tim","email":"t@k.de","response":"accepted"},{"email":"me@x.de","response":"needs_action","self":true}], + "counts":{"accepted":1,"tentative":0,"declined":0,"needs_action":2},"myResponse":"needs_action","comment":null,"proposed":null,"eventId":5,"calendarId":2, + "calendarChoices":[1,2],"day":[{"eventId":7,"calendarId":1,"title":"Zahnarzt","start":5,"end":15}],"conflicts":[{"title":"Zahnarzt","start":5,"end":15,"calendarId":1}]}""" + + const val CARD = """{"id":4,"addressbookId":2,"source":"graph","readOnly":false,"etag":"\"3\"", + "name":{"display":"Sabine Keller","given":"Sabine","family":"Keller","middle":null}, + "emails":[{"value":"s@k.de","type":"work","primary":true},{"value":"s@gmx.de","type":"home","primary":false},{"value":"kaputt"}], + "phones":[{"value":"0171 2345678","type":"mobile"}],"company":"Keller GmbH","title":"Projektleitung", + "addresses":[{"type":"work","street":"Hansaring 12","postalCode":"50670","city":"Köln","region":"","country":""},{"type":"home","street":"","city":""}], + "birthday":"--03-14","note":"","urls":["https://k.de","ftp://x"],"photo":{"etag":"e2","mime":"image/jpeg"},"groups":[5], + "linked":[{"id":8,"addressbookId":1,"source":"graph"}],"gcUserId":null,"updatedAt":3}""" + } +} diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailPimLogicTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailPimLogicTest.kt new file mode 100644 index 00000000..303b7f6f --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailPimLogicTest.kt @@ -0,0 +1,327 @@ +package com.gatecontrol.android.mail + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +class MailPimLogicTest { + + private val berlin = ZoneId.of("Europe/Berlin") + + private fun ms(y: Int, mo: Int, d: Int, h: Int, mi: Int = 0, zone: ZoneId = berlin) = LocalDateTime.of(y, mo, d, h, mi).atZone(zone).toInstant().toEpochMilli() + + private fun slot(key: String, start: Long, end: Long, allDay: Boolean = false, sd: String? = null, ed: String? = null) = + MailPimLogic.Slot(key, key, start, end, allDay, sd, ed, "#46E0A0") + + // ─── views ────────────────────────────────────────────────────── + + @Test + fun `views - day, three days, (work) week from Monday or Sunday, the month's full weeks`() { + val tue = LocalDate.of(2026, 11, 3) + assertEquals(listOf(tue), MailPimLogic.days("day", tue, 1)) + assertEquals(listOf(tue, tue.plusDays(1), tue.plusDays(2)), MailPimLogic.days("3day", tue, 1)) + val week = MailPimLogic.days("week", tue, 1) + assertEquals(LocalDate.of(2026, 11, 2), week.first()) + assertEquals(7, week.size) + assertEquals(LocalDate.of(2026, 11, 1), MailPimLogic.days("week", tue, 0).first()) + assertEquals(5, MailPimLogic.days("week", tue, 1, workWeek = true).size) + val month = MailPimLogic.monthGrid(tue, 1) + assertEquals(LocalDate.of(2026, 10, 26), month.first().first()) + assertTrue(month.all { it.size == 7 }) + assertTrue(month.last().contains(LocalDate.of(2026, 11, 30))) + assertEquals(LocalDate.of(2026, 12, 1), MailPimLogic.step("month", tue, 1)) + assertEquals(LocalDate.of(2026, 10, 31), MailPimLogic.step("3day", tue, -1)) + } + + @Test + fun `ranges - midnight to midnight across the summer time, at most 62 days a call`() { + // the night of 25 Oct 2026 has 25 hours in Berlin + val (a, b) = MailPimLogic.range(LocalDate.of(2026, 10, 25), LocalDate.of(2026, 10, 25), berlin) + assertEquals(25L * 3600_000L, b - a) + val chunks = MailPimLogic.chunks(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 4, 30)) + assertEquals(2, chunks.size) + assertEquals(LocalDate.of(2026, 3, 3), chunks[0].second) + assertEquals(LocalDate.of(2026, 3, 4), chunks[1].first) + } + + // ─── the grid ─────────────────────────────────────────────────── + + @Test + fun `grid - overlapping events side by side, the others full width`() { + val day = LocalDate.of(2026, 11, 4) + val slots = listOf( + slot("a", ms(2026, 11, 4, 10), ms(2026, 11, 4, 11)), + slot("b", ms(2026, 11, 4, 10, 30), ms(2026, 11, 4, 12)), + slot("c", ms(2026, 11, 4, 11), ms(2026, 11, 4, 11, 30)), + slot("d", ms(2026, 11, 4, 14), ms(2026, 11, 4, 15)), + ) + val placed = MailPimLogic.layoutDay(slots, day, berlin).associateBy { it.slot.key } + assertEquals(2, placed.getValue("a").cols) + assertEquals(0, placed.getValue("a").col) + assertEquals(1, placed.getValue("b").col) + // c starts when a ends: it takes a's column again within the same group + assertEquals(0, placed.getValue("c").col) + assertEquals(2, placed.getValue("c").cols) + assertEquals(1, placed.getValue("d").cols) + assertEquals(600, placed.getValue("a").top) + assertEquals(660, placed.getValue("a").bottom) + } + + @Test + fun `grid - over midnight on both days, short ones still readable, all-day apart`() { + val night = slot("n", ms(2026, 11, 4, 22), ms(2026, 11, 5, 2)) + val short = slot("s", ms(2026, 11, 4, 9), ms(2026, 11, 4, 9, 5)) + val allDay = slot("x", ms(2026, 11, 4, 0), ms(2026, 11, 6, 0), allDay = true, sd = "2026-11-04", ed = "2026-11-05") + val first = MailPimLogic.layoutDay(listOf(night, short, allDay), LocalDate.of(2026, 11, 4), berlin).associateBy { it.slot.key } + assertEquals(22 * 60, first.getValue("n").top) + assertEquals(24 * 60, first.getValue("n").bottom) + assertEquals(MailPimLogic.MIN_MINUTES, first.getValue("s").bottom - first.getValue("s").top) + assertNull(first["x"]) + val second = MailPimLogic.layoutDay(listOf(night), LocalDate.of(2026, 11, 5), berlin).single() + assertEquals(0, second.top) + assertEquals(120, second.bottom) + // all-day: the end date counts (inclusive), the day after does not + assertTrue(MailPimLogic.coversAllDay(allDay, LocalDate.of(2026, 11, 5), berlin)) + assertFalse(MailPimLogic.coversAllDay(allDay, LocalDate.of(2026, 11, 6), berlin)) + assertEquals(listOf("x"), MailPimLogic.allDayOn(listOf(night, allDay), LocalDate.of(2026, 11, 4), berlin).map { it.key }) + } + + @Test + fun `agenda - by days, all-day first, empty days left out`() { + val list = listOf( + slot("late", ms(2026, 11, 4, 18), ms(2026, 11, 4, 19)), + slot("early", ms(2026, 11, 4, 8), ms(2026, 11, 4, 9)), + slot("day", ms(2026, 11, 4, 0), ms(2026, 11, 5, 0), allDay = true, sd = "2026-11-04", ed = "2026-11-04"), + slot("fri", ms(2026, 11, 6, 8), ms(2026, 11, 6, 9)), + ) + val agenda = MailPimLogic.agenda(list, LocalDate.of(2026, 11, 4), 5, berlin) + assertEquals(listOf(LocalDate.of(2026, 11, 4), LocalDate.of(2026, 11, 6)), agenda.map { it.first }) + assertEquals(listOf("day", "early", "late"), agenda[0].second.map { it.key }) + } + + @Test + fun `new events - the tapped half hour, or the next full hour`() { + val (a, b) = MailPimLogic.newAt(LocalDate.of(2026, 11, 4), 14 * 60 + 47, berlin) + assertEquals(ms(2026, 11, 4, 14, 30), a) + assertEquals(3600_000L, b - a) + val (n, _) = MailPimLogic.nextHour(ms(2026, 11, 4, 9, 12), berlin) + assertEquals(ms(2026, 11, 4, 10), n) + } + + // ─── time zones ───────────────────────────────────────────────── + + @Test + fun `zones - another offset is shown, the same rules under another name are not`() { + val at = ms(2026, 7, 1, 12) + assertNull(MailPimLogic.otherZone("Europe/Berlin", berlin, at)) + assertNull(MailPimLogic.otherZone("Europe/Paris", berlin, at)) + assertEquals(ZoneId.of("Europe/London"), MailPimLogic.otherZone("Europe/London", berlin, at)) + assertNull(MailPimLogic.otherZone("Mars/Base", berlin, at)) + assertEquals("GMT+2", MailPimLogic.offsetLabel(berlin, at)) + assertEquals("GMT+5:30", MailPimLogic.offsetLabel(ZoneId.of("Asia/Kolkata"), at)) + // a wall time in the spring gap moves forward + val gap = MailPimLogic.ms(LocalDate.of(2026, 3, 29), LocalTime.of(2, 30), berlin) + assertEquals(LocalTime.of(3, 30), MailPimLogic.local(gap, berlin).toLocalTime()) + } + + private fun occ(start: Long, end: Long, allDay: Boolean = false, sd: String? = null, ed: String? = null) = + MOccurrence("1:x", 1, 1, "x", "x", start, end, allDay, sd, ed, "Europe/Berlin", null, null, "confirmed", "busy", null, true, 0, false, null) + + @Test + fun `series - moving all events keeps the series' own day and its hour across summer time`() { + // the series started on a winter Monday at 10:00; a summer occurrence moves to Tuesday 11:00, 90 minutes + val series = MSeries(ms(2026, 1, 5, 10), ms(2026, 1, 5, 11), false) + val o = occ(ms(2026, 7, 6, 10), ms(2026, 7, 6, 11)) + val x = MailPimLogic.shiftSeries(series, o, false, ms(2026, 7, 7, 11), ms(2026, 7, 7, 12, 30), null, null, berlin) + assertEquals(ms(2026, 1, 6, 11), x.start) + assertEquals(ms(2026, 1, 6, 12, 30), x.end) + // unchanged: the series' own times + val same = MailPimLogic.shiftSeries(series, o, false, o.start, o.end, null, null, berlin) + assertEquals(series.start, same.start) + assertEquals(series.end, same.end) + } + + @Test + fun `series - all-day series move by days, timed from an all-day one from its midnight`() { + val series = MSeries(ms(2026, 1, 5, 0), ms(2026, 1, 6, 0), true, "2026-01-05", "2026-01-05") + val o = occ(ms(2026, 7, 6, 0), ms(2026, 7, 7, 0), true, "2026-07-06", "2026-07-06") + val x = MailPimLogic.shiftSeries(series, o, true, 0, 0, "2026-07-07", "2026-07-08", berlin) + assertEquals("2026-01-06", x.startDate) + assertEquals("2026-01-07", x.endDate) + val t = MailPimLogic.shiftSeries(series, o, false, ms(2026, 7, 6, 9), ms(2026, 7, 6, 10), null, null, berlin) + assertEquals(ms(2026, 1, 5, 9), t.start) + assertEquals(ms(2026, 1, 5, 10), t.end) + } + + @Test + fun `all-day - dates inclusive, a reversed end becomes the start`() { + assertEquals("2026-11-04" to "2026-11-04", MailPimLogic.allDayDates(LocalDate.of(2026, 11, 4), LocalDate.of(2026, 11, 2))) + val o = MOccurrence("1", 1, 1, null, "x", ms(2026, 11, 4, 0), ms(2026, 11, 6, 0), true, "2026-11-04", "2026-11-05", null, null, null, "confirmed", "busy", null, false, 0, false, null) + assertEquals(LocalDate.of(2026, 11, 4) to LocalDate.of(2026, 11, 5), MailPimLogic.datesOf(o, berlin)) + // without dates: from the ms, the end exclusive + assertEquals(LocalDate.of(2026, 11, 4) to LocalDate.of(2026, 11, 5), MailPimLogic.datesOf(o.copy(startDate = null, endDate = null), berlin)) + } + + // ─── rules ────────────────────────────────────────────────────── + + @Test + fun `rules - read from RRULE and shown in words`() { + val weekly = MailPimLogic.parseRule("RRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20261231T000000Z")!! + assertEquals(MRule.WEEKLY, weekly.freq) + assertEquals(listOf("TU", "TH"), weekly.byDay) + assertEquals("2026-12-31", weekly.until) + assertEquals("Wöchentlich am Di, Do, bis 31.12.2026", MailPimLogic.ruleText(weekly, "de")) + assertEquals("Weekly on Tue, Thu, until 12/31/2026", MailPimLogic.ruleText(weekly, "en")) + val nth = MailPimLogic.parseRule("FREQ=MONTHLY;BYDAY=2TU")!! + assertEquals(2, nth.bySetPos) + assertEquals("Monatlich am 2. Dienstag", MailPimLogic.ruleText(nth, "de")) + assertEquals("Monthly on the 2nd Tuesday", MailPimLogic.ruleText(nth, "en")) + assertEquals("Alle 2 Wochen am Mo, 10-mal", MailPimLogic.ruleText(MRule(MRule.WEEKLY, 2, listOf("MO"), count = 10), "de")) + assertEquals("Jährlich am 14. März", MailPimLogic.ruleText(MRule(MRule.YEARLY), "de", LocalDate.of(2026, 3, 14))) + assertEquals("Monatlich am 4.", MailPimLogic.ruleText(MRule(MRule.MONTHLY), "de", LocalDate.of(2026, 11, 4))) + assertEquals("Monatlich am letzten Freitag", MailPimLogic.ruleText(MRule(MRule.MONTHLY, byDay = listOf("FR"), bySetPos = -1), "de")) + assertNull(MailPimLogic.parseRule("FREQ=SECONDLY")) + assertNull(MailPimLogic.parseRule("garbage")) + } + + @Test + fun `rules - the form's choices and back`() { + val d = LocalDate.of(2026, 11, 24) // the 4th and last Tuesday + assertEquals(-1 to "TU", MailPimLogic.nthWeekday(d)) + assertEquals(2 to "TU", MailPimLogic.nthWeekday(LocalDate.of(2026, 11, 10))) + val r = MailPimLogic.ruleFor(MailPimLogic.RULE_MONTHLY_WEEKDAY, d)!! + assertEquals(listOf("TU"), r.byDay) + assertEquals(-1, r.bySetPos) + assertEquals(MailPimLogic.RULE_MONTHLY_WEEKDAY, MailPimLogic.kindOf(r)) + assertEquals(listOf("TU"), MailPimLogic.ruleFor(MRule.WEEKLY, d)!!.byDay) + assertEquals(listOf(24), MailPimLogic.ruleFor(MailPimLogic.RULE_MONTHLY_DAY, d)!!.byMonthDay) + assertNull(MailPimLogic.ruleFor(MailPimLogic.RULE_NONE, d)) + assertEquals(MailPimLogic.RULE_NONE, MailPimLogic.kindOf(null)) + } + + @Test + fun `reminders - units, and all-day ones against the midnight that starts the day`() { + assertEquals(15 to "min", MailPimLogic.reminderParts(15)) + assertEquals(2 to "h", MailPimLogic.reminderParts(120)) + assertEquals(1 to "d", MailPimLogic.reminderParts(1440)) + assertEquals(1 to "w", MailPimLogic.reminderParts(10080)) + assertEquals(0 to null, MailPimLogic.reminderParts(0)) + assertEquals(1 to 18 * 60, MailPimLogic.allDayReminder(360)) + // the server's default 1080: 06:00 the day before + assertEquals(1 to 6 * 60, MailPimLogic.allDayReminder(1080)) + assertEquals(0 to 0, MailPimLogic.allDayReminder(0)) + assertEquals(2 to 9 * 60, MailPimLogic.allDayReminder(2340)) + } + + @Test + fun `answers - accepted of the people, resources left out`() { + val list = listOf( + MAttendee("A", "a@x.de", "required", "accepted", false, null), + MAttendee("B", "b@x.de", "optional", "declined", false, null), + MAttendee("R", "r@x.de", "resource", "accepted", false, null), + ) + assertEquals(1 to 2, MailPimLogic.acceptedOf(list)) + } + + // ─── contacts ─────────────────────────────────────────────────── + + private fun sum(id: Long, name: String, key: String = name.uppercase(), book: Long = 1, phone: String? = null, emails: List = emptyList()) = + MCardSummary(id, book, name, key, MailPimLogic.letterOf(key), null, null, emails, phone, false, null, emptyList(), emptyList(), null, null) + + @Test + fun `letters - umlauts fold, the rest is a hash, hash last`() { + assertEquals("A", MailPimLogic.letterOf("ÄRZTE")) + assertEquals("O", MailPimLogic.letterOf("öztürk")) + assertEquals("U", MailPimLogic.letterOf("Ülker")) + assertEquals("S", MailPimLogic.letterOf("ßtraße")) + assertEquals("#", MailPimLogic.letterOf("1&1")) + assertEquals("#", MailPimLogic.letterOf("")) + val sections = MailPimLogic.sections(listOf(sum(1, "Zoe"), sum(2, "#hash", "#HASH"), sum(3, "Anna"))) + assertEquals(listOf("A", "Z", "#"), sections.map { it.first }) + } + + @Test + fun `search on the device - name, address and digits of the number`() { + val list = listOf(sum(1, "Anna Lang", emails = listOf("anna@keller.de")), sum(2, "Tim Brandt", phone = "0171 234 56 78")) + assertEquals(listOf(1L), MailPimLogic.search(list, "keller").map { it.id }) + assertEquals(listOf(2L), MailPimLogic.search(list, "234-56").map { it.id }) + assertEquals(2, MailPimLogic.search(list, " ").size) + } + + @Test + fun `since sync - changes replace, deletions go, sorted by key`() { + val old = listOf(sum(1, "Anna"), sum(2, "Bert"), sum(3, "Carl")) + val out = MailPimLogic.applyCards(old, listOf(sum(2, "Bernd"), sum(4, "Aaron")), setOf(3)) + assertEquals(listOf(4L, 1L, 2L), out.map { it.id }) + assertEquals("Bernd", out.last().name) + } + + @Test + fun `numbers - +49 and 0 the same, tel links`() { + assertEquals(MailPimLogic.phoneKey("+49 171 2345678"), MailPimLogic.phoneKey("0171 / 234 56 78")) + assertEquals("tel:+491712345678", MailPimLogic.telUri("+49 171 2345678")) + assertEquals("tel:02213004012", MailPimLogic.telUri("0221 300 40-12")) + assertNull(MailPimLogic.telUri("ab")) + } + + private fun card(id: Long, book: Long, given: String, emails: List, phones: List = emptyList(), company: String? = null, note: String = "", birthday: String? = null) = + MCard(id = id, addressbookId = book, name = MCardName(given = given, family = "Lang"), emails = emails.map { MCardEmail(it) }, phones = phones.map { MCardPhone(it) }, + company = company, note = note, birthday = birthday, groups = listOf(id)) + + @Test + fun `merge - addresses and numbers without doubles, single fields picked, notes joined`() { + val a = card(1, 1, "Anna", listOf("anna@x.de"), listOf("+49 171 2345678"), company = "Keller GmbH", note = "Einkauf") + val b = card(2, 1, "Anna-Maria", listOf("ANNA@x.de", "anna@gmx.de"), listOf("0171 2345678"), company = "Keller", note = "Mittwochs", birthday = "1981-03-14") + val m = MailPimLogic.merged(listOf(a, b), primary = 1) + assertEquals(listOf("anna@x.de", "anna@gmx.de"), m.emails.map { it.value }) + assertTrue(m.emails.first().primary) + assertEquals(1, m.phones.size) + assertEquals("Keller GmbH", m.company) + assertEquals("1981-03-14", m.birthday) + assertEquals("Einkauf\n\nMittwochs", m.note) + assertEquals(listOf(1L, 2L), m.groups) + assertEquals("Keller", MailPimLogic.merged(listOf(a, b), 1, MMergePick(company = 2)).company) + assertEquals("Anna-Maria", MailPimLogic.merged(listOf(a, b), 1, MMergePick(name = 2)).name.given) + assertEquals(listOf("name", "company"), MailPimLogic.conflicts(listOf(a, b))) + assertTrue(MailPimLogic.links(listOf(sum(1, "A", book = 1), sum(2, "A", book = 2)))) + assertFalse(MailPimLogic.links(listOf(sum(1, "A"), sum(2, "A")))) + } + + @Test + fun `birthdays - typed, without a year, the age to come`() { + assertEquals("1981-03-14", MailPimLogic.birthdayOf("14.03.", "1981")) + assertEquals("--03-14", MailPimLogic.birthdayOf("14.3", "")) + assertNull(MailPimLogic.birthdayOf("31.02.", "2026")) + assertNull(MailPimLogic.birthdayOf("14.03.", "81")) + assertEquals("--02-29", MailPimLogic.birthdayOf("29.02.", "")) + assertEquals(Triple(3, 14, 1981), MailPimLogic.birthdayParts("1981-03-14")) + assertEquals(Triple(3, 14, null), MailPimLogic.birthdayParts("--03-14")) + assertEquals(46, MailPimLogic.turns("1981-03-14", LocalDate.of(2026, 10, 31))) + assertEquals(45, MailPimLogic.turns("1981-03-14", LocalDate.of(2026, 3, 1))) + assertNull(MailPimLogic.turns("--03-14", LocalDate.of(2026, 3, 1))) + assertNotNull(MailPimLogic.turns("2000-02-29", LocalDate.of(2026, 3, 1))) + } + + @Test + fun `offline copy - ranges near today, file names`() { + val now = ms(2026, 11, 1, 12) + assertEquals(1L to 2L, MailPimCacheLogic.rangeOf("1-2.json")) + assertNull(MailPimCacheLogic.rangeOf("2-1.json")) + assertNull(MailPimCacheLogic.rangeOf("x.json")) + assertTrue(MailPimCacheLogic.keep(now - 10L * MailPimLogic.DAY_MS, now, now)) + assertFalse(MailPimCacheLogic.keep(now - 90L * MailPimLogic.DAY_MS, now - 40L * MailPimLogic.DAY_MS, now)) + assertFalse(MailPimCacheLogic.keep(now + 100L * MailPimLogic.DAY_MS, now + 120L * MailPimLogic.DAY_MS, now)) + val json = MailPimCacheLogic.cardsJson(listOf(sum(5, "Anna", emails = listOf("a@x.de"))), 42) + val back = MailPimJson.cardPage(json)!! + assertEquals(42L, back.version) + assertEquals("Anna", back.items.single().name) + assertEquals(listOf("a@x.de"), back.items.single().emails) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt index c34a6887..91c2ac96 100644 --- a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt +++ b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt @@ -190,6 +190,22 @@ class MainTabsTest { assertEquals(listOf(Screen.Vpn.route, Screen.Chats.route), MainTabs.visible(listOf(Screen.Vpn.route, Screen.Chats.route, Screen.Mail.route), listOf("mail"))) } + @Test + fun `calendar and contacts are pages after Mail without a tab, only with the server's features`() { + val me = com.gatecontrol.android.mail.MailJson.me(com.google.gson.JsonParser.parseString("""{"features":["mail","calendar"]}""").asJsonObject) + assertEquals(listOf("calendar"), MainTabs.mailPages(me)) + assertEquals(emptyList(), MainTabs.mailPages(null)) + assertEquals(listOf("calendar", "contacts"), MainTabs.mailPages(null, lenient = true)) + val routes = listOf(Screen.Vpn.route, Screen.Chats.route, Screen.Mail.route, Screen.Drive.route) + assertEquals(listOf("chats", "mail", "calendar", "contacts", "drive"), MainTabs.features(routes, listOf("contacts", "calendar"))) + assertEquals(listOf("chats", "drive"), MainTabs.features(listOf(Screen.Vpn.route, Screen.Chats.route, Screen.Drive.route), listOf("calendar")), "no Mail: no calendar") + // a page only: it never shows up as a tab, taking it off leaves the bar as it is + assertTrue(com.gatecontrol.android.layout.StartPages.isPageOnly("calendar")) + assertFalse(com.gatecontrol.android.layout.StartPages.isPageOnly("mail")) + assertEquals(routes, MainTabs.visible(routes, listOf("calendar"))) + assertEquals(listOf("calendar"), com.gatecontrol.android.layout.StartPages.visible(listOf("calendar", "unknown"), MainTabs.features(routes, listOf("calendar")))) + } + @Test fun `mail routes`() { assertEquals("mail/m/42", Screen.MailMessage.create(42)) @@ -203,5 +219,14 @@ class MainTabsTest { assertEquals("mail/accounts/3", Screen.MailAccount.create(3)) assertEquals("mail/sender?email=a%40b.de&name=Anna%20M%C3%BCller&account=3", Screen.MailSender.create("a@b.de", "Anna Müller", 3)) assertEquals("mail/sender?email=a%40b.de", Screen.MailSender.create("a@b.de", " ", null)) + // calendar and contacts + assertEquals("mail/event/5", Screen.MailEvent.create(5)) + assertEquals("mail/event/5?occ=2026-11-04", Screen.MailEvent.create(5, "2026-11-04")) + assertEquals("mail/event-edit?start=10&end=20", Screen.MailEventEdit.create(start = 10, end = 20)) + assertEquals("mail/event-edit?id=5&occ=1730800800000", Screen.MailEventEdit.create(id = 5, occurrence = "1730800800000")) + assertEquals("mail/contact-edit?message=7&email=a%40b.de&name=Anna%20Lang", Screen.MailContactEdit.create(message = 7, email = "a@b.de", name = "Anna Lang")) + assertEquals("mail/contact-edit", Screen.MailContactEdit.create()) + assertEquals("mail/invitation/9", Screen.MailInvitation.create(9)) + assertNull(MainTabs.pageFor(Screen.MailEvent.create(5)), "an event lies on top of the tab") } }