diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt b/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt index 356e01ec..fd895f8b 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt @@ -1,5 +1,8 @@ package com.gatecontrol.android.layout +import com.gatecontrol.android.mail.MailContactLogic +import com.gatecontrol.android.mail.MailPimRepository +import com.gatecontrol.android.mail.StartContactPin import com.gatecontrol.android.quickactions.QuickActionRepository import com.gatecontrol.android.support.CrashReports import kotlinx.coroutines.CoroutineScope @@ -26,13 +29,14 @@ import javax.inject.Singleton class StartArrangement @Inject constructor( private val quickActions: QuickActionRepository, private val tabLayout: TabLayoutRepository, + private val mail: MailPimRepository, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) - /** The blocks of the start tab (key null) and of each widget page beside it ([StartPages]). */ + /** The blocks of the start tab (key null) and of each widget page beside it ([StartPages]); Mail's favourites and calendar widget too. */ val pages: StateFlow>> = - combine(tabLayout.layout, quickActions.pins, quickActions.widgets) { layout, pins, widgets -> - StartPages.merge(layout.start, pins.map { it.key }, widgets.map { StartEntry.Widget(it.device, it.wide) }) + combine(tabLayout.layout, quickActions.pins, quickActions.widgets, mail.startPins) { layout, pins, widgets, mailPins -> + StartPages.merge(layout.start, pins.map { it.key }, widgets.map { StartEntry.Widget(it.device, it.wide) }, mailPins.extras) }.stateIn(scope, SharingStarted.Eagerly, StartPages.merge(emptyList(), emptyList(), emptyList())) /** The start tab's blocks. */ @@ -41,6 +45,9 @@ class StartArrangement @Inject constructor( val access: StateFlow = tabLayout.access + /** What Mail pins to Start on this device. */ + val mailPins: StateFlow = mail.startPins + init { // An order from the account (or arranged here): the pins take its order and widths // (the pages' markers are no blocks: decode skips them). @@ -69,7 +76,24 @@ class StartArrangement @Inject constructor( suspend fun syncFromPins(page: String? = null) { val tiles = quickActions.currentPins().map { it.key } val widgets = quickActions.currentWidgets().map { StartEntry.Widget(it.device, it.wide) } - tabLayout.updateStart { StartPages.syncPins(it, tiles, widgets, page) } + val extras = mail.currentStartPins().extras + tabLayout.updateStart { StartPages.syncPins(it, tiles, widgets, page, extras) } + } + + /** + * A favourite on Start ([on]) or off it; it joins [page] (a widget page, null: the start tab). Mail's pins are kept + * on this device like the plugins' ones. + */ + suspend fun pinContact(pin: StartContactPin, on: Boolean, page: String? = null) { + mail.updateStartPins { MailContactLogic.withPin(it, pin, on) } + syncFromPins(page) + } + + /** The calendar widget on Start ([on]) or off it. */ + suspend fun pinAgenda(on: Boolean, page: String? = null) { + mail.updateStartPins { it.copy(agenda = on) } + if (on) mail.refreshUpcomingSoon() + syncFromPins(page) } /** Asks the server for the account's order (throttled). */ diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt b/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt index ef7137ef..81c2598f 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt @@ -25,6 +25,23 @@ sealed interface StartEntry { data object Usage : StartEntry { override val key: String get() = StartLayout.USAGE } + + /** A favourite contact of Mail as a tile (Mail 1.4.0): a tap calls its main number. [id]: the GC contact. */ + data class Contact(val id: Long) : StartEntry { + override val key: String get() = StartLayout.CONTACT + id + } + + /** Mail's calendar widget: the next events of today and tomorrow (Mail 1.4.0). */ + data object Agenda : StartEntry { + override val key: String get() = StartLayout.AGENDA + } +} + +/** What Mail pins to Start on this device (Mail 1.4.0): favourite contacts as tiles, the calendar widget. */ +data class StartExtras(val contacts: List = emptyList(), val agenda: Boolean = false) { + companion object { + val NONE = StartExtras() + } } /** @@ -40,12 +57,17 @@ object StartLayout { const val WIDGET_WIDE = "widget2:" const val INFO = "info" const val USAGE = "usage" + const val CONTACT = "contact:" + const val AGENDA = "agenda" + private val CONTACT_ID = Regex("^[1-9][0-9]{0,15}$") fun encode(entry: StartEntry): String = when (entry) { is StartEntry.Tile -> TILE + entry.action is StartEntry.Widget -> (if (entry.wide) WIDGET_WIDE else WIDGET) + entry.device StartEntry.Info -> INFO StartEntry.Usage -> USAGE + is StartEntry.Contact -> CONTACT + entry.id + StartEntry.Agenda -> AGENDA } fun encodeAll(entries: List): List = entries.map(::encode) @@ -53,6 +75,8 @@ object StartLayout { fun decode(raw: String): StartEntry? = when { raw == INFO -> StartEntry.Info raw == USAGE -> StartEntry.Usage + raw == AGENDA -> StartEntry.Agenda + raw.startsWith(CONTACT) -> raw.removePrefix(CONTACT).takeIf { CONTACT_ID.matches(it) }?.toLongOrNull()?.let { StartEntry.Contact(it) } raw.startsWith(TILE) -> raw.removePrefix(TILE).takeIf { it.isNotEmpty() }?.let { StartEntry.Tile(it) } raw.startsWith(WIDGET_WIDE) -> raw.removePrefix(WIDGET_WIDE).takeIf { it.isNotEmpty() }?.let { StartEntry.Widget(it, wide = true) } raw.startsWith(WIDGET) -> raw.removePrefix(WIDGET).takeIf { it.isNotEmpty() }?.let { StartEntry.Widget(it, wide = false) } @@ -76,9 +100,16 @@ object StartLayout { * not have are skipped. [cards] false (a widget page beside Start, * [StartPages]): no Info and usage card. */ - fun merge(saved: List, tiles: List, widgets: List, cards: Boolean = true): List { + fun merge( + saved: List, + tiles: List, + widgets: List, + cards: Boolean = true, + extras: StartExtras = StartExtras.NONE, + ): List { val widgetByDevice = widgets.associateBy { it.device } val tileSet = tiles.toSet() + val contactSet = extras.contacts.toSet() val withCards = cards val orderedTiles = ArrayList() val blocks = ArrayList() @@ -88,17 +119,23 @@ object StartLayout { if (!seen.add(entry.key)) continue when (entry) { is StartEntry.Tile -> if (entry.action in tileSet) orderedTiles += entry + is StartEntry.Contact -> if (entry.id in contactSet) orderedTiles += entry // The width is the pin's: arranging and the pin sheet keep both in step. is StartEntry.Widget -> widgetByDevice[entry.device]?.let { blocks += it } + StartEntry.Agenda -> if (extras.agenda) blocks += entry StartEntry.Info, StartEntry.Usage -> if (withCards) blocks += entry } } tiles.filter { seen.add(TILE + it) }.forEach { orderedTiles += StartEntry.Tile(it) } + // Favourites after the plugins' buttons. + extras.contacts.filter { seen.add(CONTACT + it) }.forEach { orderedTiles += StartEntry.Contact(it) } for (widget in widgets) { if (!seen.add(widget.key)) continue val lastWidget = blocks.indexOfLast { it is StartEntry.Widget } blocks.add(if (lastWidget >= 0) lastWidget + 1 else 0, widget) } + // Mail's calendar widget, pinned just now: on top of the cards. + if (extras.agenda && seen.add(AGENDA)) blocks.add(0, StartEntry.Agenda) if (withCards && seen.add(INFO)) blocks += StartEntry.Info if (withCards && seen.add(USAGE)) blocks += StartEntry.Usage return orderedTiles + blocks @@ -112,7 +149,7 @@ object StartLayout { val from = entries.indexOfFirst { it.key == fromKey } val to = entries.indexOfFirst { it.key == toKey } if (from < 0 || to < 0 || from == to) return entries - if ((entries[from] is StartEntry.Tile) != (entries[to] is StartEntry.Tile)) return entries + if (entries[from].isTile != entries[to].isTile) return entries return entries.toMutableList().apply { add(to, removeAt(from)) } } @@ -121,7 +158,7 @@ object StartLayout { val from = entries.indexOfFirst { it.key == key } if (from < 0) return entries val to = from + delta - if (to !in entries.indices || (entries[to] is StartEntry.Tile) != (entries[from] is StartEntry.Tile)) return entries + if (to !in entries.indices || entries[to].isTile != entries[from].isTile) return entries return TabLayout.moveBy(entries, from, delta) } @@ -164,4 +201,7 @@ object StartLayout { /** A widget of half width (the only blocks that share a row). */ val StartEntry.isHalf: Boolean get() = this is StartEntry.Widget && !wide + + /** A block of the tiles' row: a plugin's button or a favourite contact (they move among each other only). */ + val StartEntry.isTile: Boolean get() = this is StartEntry.Tile || this is StartEntry.Contact } 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 78350ad0..8bb7e5ea 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt @@ -176,8 +176,8 @@ object StartPages { * only ever stand on Start. Every widget page of [saved] is in the map, * an empty one with no blocks. */ - fun merge(saved: List, tiles: List, widgets: List): Map> = - assign(saved, tiles, widgets, target = null) + fun merge(saved: List, tiles: List, widgets: List, extras: StartExtras = StartExtras.NONE): Map> = + assign(saved, tiles, widgets, target = null, extras) /** * The start list after pins changed (pinned, unpinned, another width): @@ -186,9 +186,9 @@ object StartPages { * where [StartLayout.merge] puts new blocks; gone blocks drop out. * Markers, function pages and unknown pages stay as they are. */ - fun syncPins(saved: List, tiles: List, widgets: List, target: String?): List { + fun syncPins(saved: List, tiles: List, widgets: List, target: String?, extras: StartExtras = StartExtras.NONE): List { val split = split(saved) - val blocks = assign(saved, tiles, widgets, target) + val blocks = assign(saved, tiles, widgets, target, extras) return join( Split( start = StartLayout.encodeAll(blocks[null].orEmpty()), @@ -199,7 +199,7 @@ object StartPages { ) } - private fun assign(saved: List, tiles: List, widgets: List, target: String?): Map> { + private fun assign(saved: List, tiles: List, widgets: List, target: String?, extras: StartExtras): Map> { val split = split(saved) val widgetPages = split.pages.filter { isWidgets(it.id) } // Which page names a block first (start before the pages, in their order). @@ -214,10 +214,15 @@ object StartPages { val newHome = target?.takeIf { t -> widgetPages.any { it.id == t } } fun pageOf(key: String): String? = if (key in home) home[key] else newHome val result = LinkedHashMap>() + fun extrasOf(page: String?) = StartExtras( + contacts = extras.contacts.filter { pageOf(StartLayout.CONTACT + it) == page }, + agenda = extras.agenda && pageOf(StartLayout.AGENDA) == page, + ) result[null] = StartLayout.merge( split.start, tiles.filter { pageOf(StartLayout.TILE + it) == null }, widgets.filter { pageOf(it.key) == null }, + extras = extrasOf(null), ) for (page in widgetPages) { result[page.id] = StartLayout.merge( @@ -225,6 +230,7 @@ object StartPages { tiles.filter { pageOf(StartLayout.TILE + it) == page.id }, widgets.filter { pageOf(it.key) == page.id }, cards = false, + extras = extrasOf(page.id), ) } return result 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 5a0cbcdb..f945b980 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt @@ -425,10 +425,47 @@ class MailApi @Inject constructor( 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) + /** + * `PATCH cards/`: `409 conflict { current }` ([MailPimJson.conflictCard]), `409 read_only`; since Mail 1.4.0 + * with `writeBack` (the linked cards the change went back to). + */ + suspend fun patchCard(id: Long, body: JsonObject): MResult = patchNulls("cards/$id", body, MailPimJson::cardSaved) + + /** `DELETE cards/`; [linkedToo] (Mail 1.4.0, `linked=delete`): the linked cards go at their provider too. */ + suspend fun deleteCard(id: Long, linkedToo: Boolean = false): MResult = + call(request = { pluginDelete(MAIL_PLUGIN, "cards/$id", (if (linkedToo) mapOf("linked" to "delete") else emptyMap()) + langQuery()) }, parse = unit) + + // ─── the GC contact as the master (Mail 1.4.0; an older server answers 404) ─── + + suspend fun mergePreview(ids: List): MResult = post("cards/merge/preview", MailPimJson.idsBody(ids), parse = MailPimJson::previewAnswer) + + suspend fun merges(limit: Int = 50): MResult> = get("cards/merges", mapOf("limit" to limit.coerceIn(1, 200).toString()), MailPimJson::merges) + + suspend fun undoMerge(id: Long, keepMaster: Boolean): MResult = + post("cards/merges/$id/undo", MailPimJson.unmergeBody(null, keepMaster), timeoutMs = 60_000L, parse = MailPimJson::unmerge) + + /** "Verknüpfung lösen": the linked cards [ids] of the GC contact [id] (null: all). */ + suspend fun unmerge(id: Long, ids: List?, keepMaster: Boolean = false): MResult = + post("cards/$id/unmerge", MailPimJson.unmergeBody(ids, keepMaster), timeoutMs = 60_000L, parse = MailPimJson::unmerge) + + /** "Übernehmen" ([mode] `replace` | `add`, null: the server's default). */ + suspend fun acceptConflict(id: Long, conflictId: Long, mode: String?): MResult = + post("cards/$id/conflicts/$conflictId/accept", MailPimJson.acceptBody(mode), parse = MailPimJson::cardAnswer) + + /** "Verwerfen": the value stays away until it changes at the provider. */ + suspend fun dismissConflict(id: Long, conflictId: Long): MResult = + post("cards/$id/conflicts/$conflictId/dismiss", JsonObject(), parse = MailPimJson::cardAnswer) + + /** Favourite and main number only (GateControl's own, never to the provider). */ + suspend fun patchOwn(id: Long, body: JsonObject): MResult = patchNulls("cards/$id", body, MailPimJson::cardSaved) + + suspend fun favorites(): MResult, JsonObject>> = get("cards/favorites") { o -> MailPimJson.favorites(o)?.let { it to o } } + + suspend fun putFavorites(ids: List): MResult> = put("cards/favorites", MailPimJson.idsBody(ids), MailPimJson::favorites) - suspend fun deleteCard(id: Long): MResult = del("cards/$id", unit) + /** The calendar widget on Start: today and the next [days] − 1 days. */ + suspend fun upcoming(days: Int = 2, limit: Int = 8): MResult> = + get("events/upcoming", mapOf("days" to days.coerceIn(1, 7).toString(), "limit" to limit.coerceIn(1, 20).toString())) { o -> MailPimJson.upcoming(o)?.let { it to o } } suspend fun cardPhoto(id: Long): MResult = get("cards/$id/photo", parse = MailPimJson::photo) @@ -446,7 +483,9 @@ class MailApi @Inject constructor( put("email", email.trim().take(320)) }, MailPimJson::cardDraft) - suspend fun duplicates(): MResult> = get("cards/duplicates", parse = MailPimJson::duplicates) + /** `GET cards/duplicates` ([details]: each card in full and the preview, Mail 1.4.0). */ + suspend fun duplicates(details: Boolean = false): MResult> = + get("cards/duplicates", if (details) mapOf("details" to "1") else emptyMap(), MailPimJson::duplicates) suspend fun merge(body: JsonObject): MResult = post("cards/merge", body, timeoutMs = 60_000L, parse = MailPimJson::cardAnswer) 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 2867a021..ad115240 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt @@ -237,6 +237,20 @@ class MailCache @Inject constructor( io { dir -> File(dir, "pim/card/$id.json").delete() } } + // ─── Start (Mail 1.4.0): favourites, the calendar widget, what is pinned ─── + + suspend fun putFavorites(raw: JsonObject) { io { write(File(it, "pim/favorites.json"), raw) } } + + suspend fun favorites(): List? = io { dir -> read(File(dir, "pim/favorites.json"))?.let(MailPimJson::favorites) } + + suspend fun putUpcoming(raw: JsonObject) { io { write(File(it, "pim/upcoming.json"), raw) } } + + suspend fun upcoming(): MUpcoming? = io { dir -> read(File(dir, "pim/upcoming.json"))?.let(MailPimJson::upcoming) } + + suspend fun putStartPins(p: MailStartPins) { io { write(File(it, "pim/start.json"), MailContactLogic.pinsJson(p)) } } + + suspend fun startPins(): MailStartPins? = io { dir -> read(File(dir, "pim/start.json"))?.let(MailContactLogic::pinsOf) } + /** 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 -> diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailContactLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailContactLogic.kt new file mode 100644 index 00000000..521013ac --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailContactLogic.kt @@ -0,0 +1,283 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.layout.StartExtras +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** A favourite pinned to Start as a tile, with a copy of what it shows (offline too). */ +data class StartContactPin(val id: Long, val name: String, val photoEtag: String? = null, val phone: String? = null) + +/** What Mail pins to Start on this device (Mail 1.4.0): favourites as tiles and the calendar widget. */ +data class MailStartPins(val contacts: List = emptyList(), val agenda: Boolean = false) { + val extras: StartExtras get() = StartExtras(contacts.map { it.id }, agenda) + + fun has(id: Long): Boolean = contacts.any { it.id == id } +} + +/** One day of the calendar widget: its events in the server's order. */ +data class AgendaDay(val day: LocalDate, val today: Boolean, val items: List) + +/** What the calendar widget shows: today and tomorrow (a day without events is left out), [more] beyond. */ +data class AgendaView(val days: List, val more: Int) { + val empty: Boolean get() = days.isEmpty() +} + +/** What a tap on a favourite's tile does: call [tel] (a `tel:` uri) or, without a number, open the contact. */ +sealed interface TileTap { + data class Call(val tel: String) : TileTap + data class Open(val id: Long) : TileTap +} + +/** + * The rules of the GC contact as the master (Mail 1.4.0, pure, unit-tested): which contacts the list shows and with + * which dots, the hints of differing values, the preview of a merge, favourites and the main number, the tiles and + * the calendar widget on Start, and what a change wrote back to a provider. + */ +object MailContactLogic { + + /** Favourites the server keeps per person. */ + const val FAVORITES_MAX = 50 + + /** Favourite tiles on Start (like the plugins' buttons). */ + const val PINS_MAX = 12 + + const val ACTION_CALL = "android.intent.action.CALL" + const val ACTION_DIAL = "android.intent.action.DIAL" + + // ─── the list ─────────────────────────────────────────────────── + + /** The list of all contacts: GC contacts and loose cards; a linked card stands under its GC contact. */ + fun listed(items: List): List = items.filter { it.masterId == null } + + /** + * The accounts a contact of the list comes from (one dot each, in their order): from `sources` (Mail 1.4.0), + * else from its own address book. Own contacts (GC address book) have no dot. + */ + fun sourceAccounts(c: MCardSummary, bookAccount: (Long) -> Long?): List { + val ids = if (c.sources.isNotEmpty()) c.sources.map { it.accountId ?: bookAccount(it.addressbookId) } else listOf(bookAccount(c.addressbookId)) + return ids.filterNotNull().distinct() + } + + /** Whether the server has the master model (Mail 1.4.0): a contact names its sources or its favourite flag. */ + fun masterModel(c: MCard): Boolean = c.sources.isNotEmpty() || c.favorite != null + + // ─── hints of differing values ────────────────────────────────── + + /** A value as one line: a number, a postal address (street, postal code and city), a text. */ + fun valueText(v: MFieldValue?): String = when (v) { + is MFieldValue.Phone -> v.phone.value + is MFieldValue.Address -> listOf(v.address.street, listOf(v.address.postalCode, v.address.city).filter { it.isNotBlank() }.joinToString(" "), v.address.country) + .filter { it.isNotBlank() }.joinToString(", ") + is MFieldValue.Text -> v.text + null -> "" + } + + /** + * What a hint names: `mobile` / `phone` (another number), `address`, `company`, `department`, `title`, `birthday`. + * A number counts as mobile by its type. + */ + fun hintKind(c: MCardConflict): String = when (c.field) { + MCardConflict.PHONES -> if ((c.type ?: (c.value as? MFieldValue.Phone)?.phone?.type) == "mobile") "mobile" else "phone" + MCardConflict.ADDRESSES -> "address" + else -> c.field + } + + /** "Zusätzlich" where it fits: a number or a postal address that would replace one (else it is added anyway). */ + fun canAdd(c: MCardConflict): Boolean = c.replaces != null && (c.field == MCardConflict.PHONES || c.field == MCardConflict.ADDRESSES) + + /** The mode of "Übernehmen" ([add]: "Zusätzlich"). */ + fun acceptMode(c: MCardConflict, add: Boolean): String = if (add || c.replaces == null) "add" else "replace" + + /** The provider's name of a source: Outlook for Microsoft, the address book's name for CardDAV, null for own contacts. */ + fun providerName(source: String, addressbook: String?): String? = when (source) { + MAddressbook.SOURCE_GRAPH -> "Outlook" + MAddressbook.SOURCE_CARDDAV -> addressbook?.takeIf { it.isNotBlank() } ?: "CardDAV" + else -> null + } + + /** Where a value comes from: "Outlook (ma@example.com)", the address book's name, or the account alone. */ + fun originLabel(s: MSource?): String { + s ?: return "" + val provider = providerName(s.source, s.addressbook) ?: s.addressbook + return when { + provider.isBlank() -> s.account.orEmpty() + s.account.isNullOrBlank() -> provider + else -> "$provider (${s.account})" + } + } + + // ─── the preview of a merge ───────────────────────────────────── + + /** + * What merging gives when the server sends no preview (before Mail 1.4.0): the GC card (else the first) keeps + * name and single fields, the lists of all are joined ([MailPimLogic.merged]). + */ + fun localPreview(cards: List): MCard? { + if (cards.size < 2) return null + val keep = cards.firstOrNull { it.source == MAddressbook.SOURCE_LOCAL } ?: cards.first() + return MailPimLogic.merged(cards, keep.id) + } + + /** + * The values of a card of a duplicate that the others lack (shown as new in its card): numbers, addresses and + * postal addresses by their comparing key. + */ + fun ownValues(card: MCard, others: List): Set { + val phones = others.flatMap { o -> o.phones.map { MailPimLogic.phoneKey(it.value) } }.toSet() + val emails = others.flatMap { o -> o.emails.map { it.value.trim().lowercase() } }.toSet() + val streets = others.flatMap { o -> o.addresses.map(::addressKey) }.toSet() + return buildSet { + card.phones.filter { MailPimLogic.phoneKey(it.value) !in phones }.forEach { add("p:" + MailPimLogic.phoneKey(it.value)) } + card.emails.filter { it.value.trim().lowercase() !in emails }.forEach { add("e:" + it.value.trim().lowercase()) } + card.addresses.filter { addressKey(it) !in streets }.forEach { add("a:" + addressKey(it)) } + } + } + + fun addressKey(a: MCardAddress): String = "${a.street.trim()}|${a.postalCode.trim()}|${a.city.trim()}".lowercase() + + // ─── favourites and the main number ───────────────────────────── + + /** + * The favourites' order after one is switched [on] or off (the end of the row), null when [on] would pass + * [FAVORITES_MAX]. + */ + fun toggled(current: List, id: Long, on: Boolean): List? = when { + on && id in current -> current + on && current.size >= FAVORITES_MAX -> null + on -> current + id + else -> current - id + } + + /** [current] with [id] one place earlier ([delta] -1) or later (+1); unchanged at the ends. */ + fun moved(current: List, id: Long, delta: Int): List { + val from = current.indexOf(id) + val to = from + delta + if (from < 0 || to !in current.indices) return current + return current.toMutableList().apply { add(to, removeAt(from)) } + } + + /** The main number: the one chosen (compared by its digits), else the first mobile, else the first. */ + fun mainPhoneOf(phones: List, chosen: String?): MCardPhone? { + val key = chosen?.let(MailPimLogic::phoneKey)?.takeIf { it.isNotEmpty() } + return key?.let { k -> phones.firstOrNull { MailPimLogic.phoneKey(it.value) == k } } + ?: phones.firstOrNull { it.type == "mobile" } ?: phones.firstOrNull() + } + + /** Whether [p] is the contact's main number (the server's choice, else the rule of [mainPhoneOf]). */ + fun isMain(c: MCard, p: MCardPhone): Boolean { + val main = c.mainPhone?.takeIf { m -> c.phones.any { MailPimLogic.phoneKey(it.value) == MailPimLogic.phoneKey(m.value) } } ?: mainPhoneOf(c.phones, null) + main ?: return false + val k = MailPimLogic.phoneKey(main.value) + return if (k.isNotEmpty()) MailPimLogic.phoneKey(p.value) == k else p.value == main.value + } + + /** "Als Hauptnummer" is offered for every number but the main one, and only with more than one. */ + fun canChooseMain(c: MCard, p: MCardPhone): Boolean = c.favorite != null && c.phones.size > 1 && !isMain(c, p) + + // ─── Start: favourite tiles ───────────────────────────────────── + + /** A pin from a favourite as the server lists it. */ + fun pinOf(f: MFavorite): StartContactPin = StartContactPin(f.id, f.name, f.photoEtag, f.phone?.value) + + /** A pin from the contact itself (pinning from its page). */ + fun pinOf(c: MCard): StartContactPin = StartContactPin(c.id, c.label, c.photoEtag, (c.mainPhone ?: mainPhoneOf(c.phones, null))?.value) + + /** + * The pins after the favourites came: a pin takes the favourite's name, photo and number; a contact that is no + * favourite any more leaves Start. The order stays. + */ + fun syncedPins(pins: MailStartPins, favorites: List): MailStartPins { + val byId = favorites.associateBy { it.id } + return pins.copy(contacts = pins.contacts.mapNotNull { p -> byId[p.id]?.let(::pinOf) }) + } + + /** [pins] with [pin] on Start (at the end; at most [PINS_MAX]) or off it. */ + fun withPin(pins: MailStartPins, pin: StartContactPin, on: Boolean): MailStartPins = when { + !on -> pins.copy(contacts = pins.contacts.filter { it.id != pin.id }) + pins.has(pin.id) -> pins.copy(contacts = pins.contacts.map { if (it.id == pin.id) pin else it }) + else -> pins.copy(contacts = (pins.contacts + pin).take(PINS_MAX)) + } + + /** A tap on a favourite's tile: its main number, else (none) the contact. */ + fun tap(pin: StartContactPin): TileTap = pin.phone?.let(MailPimLogic::telUri)?.let { TileTap.Call(it) } ?: TileTap.Open(pin.id) + + /** + * How a tile calls: straight away (`ACTION_CALL`) only while the app holds the permission to place calls (the + * person allowed it), else the dialer with the number (`ACTION_DIAL`, no permission needed). + */ + fun callAction(callPermission: Boolean): String = if (callPermission) ACTION_CALL else ACTION_DIAL + + /** The pins as kept in the offline copy (read without reflection). */ + fun pinsJson(p: MailStartPins): JsonObject = JsonObject().apply { + addProperty("agenda", p.agenda) + add("contacts", JsonArray().apply { + p.contacts.forEach { c -> + add(JsonObject().apply { + addProperty("id", c.id) + addProperty("name", c.name) + c.photoEtag?.let { addProperty("photoEtag", it) } + c.phone?.let { addProperty("phone", it) } + }) + } + }) + } + + fun pinsOf(o: JsonObject?): MailStartPins { + o ?: return MailStartPins() + val agenda = runCatching { o.get("agenda")?.takeIf { it.isJsonPrimitive }?.asBoolean }.getOrNull() ?: false + val contacts = o.get("contacts")?.takeIf { it.isJsonArray }?.asJsonArray?.mapNotNull { e -> + val x = e.takeIf { it.isJsonObject }?.asJsonObject ?: return@mapNotNull null + fun str(k: String) = x.get(k)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() } + val id = str("id")?.toLongOrNull()?.takeIf { it > 0 } ?: return@mapNotNull null + StartContactPin(id, str("name").orEmpty().take(200), str("photoEtag")?.takeIf { it.isNotEmpty() && it.length <= 200 }, str("phone")?.takeIf { it.isNotBlank() }?.take(64)) + }.orEmpty().distinctBy { it.id }.take(PINS_MAX) + return MailStartPins(contacts, agenda) + } + + // ─── Start: the calendar widget ───────────────────────────────── + + /** + * What the widget shows of [u] at [now] in [zone]: events not over yet, by their day (today and tomorrow of + * [now]; an all-day event still running counts for today), the ones of a day in the server's order. An offline + * copy from yesterday thus shows what is left of it. + */ + fun agenda(u: MUpcoming, now: Long, zone: ZoneId): AgendaView { + val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + val tomorrow = today.plusDays(1) + val left = u.items.filter { it.end > now || (it.allDay && it.end >= now) } + val byDay = LinkedHashMap>() + for (x in left) { + val day = MailPimLogic.date(x.day) ?: continue + val shown = if (day.isBefore(today)) today else day + if (shown.isAfter(tomorrow)) continue + byDay.getOrPut(shown) { ArrayList() } += x + } + val days = byDay.entries.sortedBy { it.key }.map { (d, items) -> + AgendaDay(d, d == today, items.sortedWith(compareBy({ !it.allDay }, { it.start }))) + } + return AgendaView(days, u.more) + } + + /** When the widget asks again by itself: the next midnight in [zone] (a new day, new events). */ + fun nextDay(now: Long, zone: ZoneId): Long = + Instant.ofEpochMilli(now).atZone(zone).toLocalDate().plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + + /** Whether the widget's copy is of another day than [now] (it is asked for again). */ + fun stale(u: MUpcoming, now: Long, zone: ZoneId): Boolean = MailPimLogic.date(u.today) != Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + + // ─── a change written back ────────────────────────────────────── + + /** + * The providers a change went back to ([ok]) or not ([failed]), by name (Outlook, the CardDAV address book), each + * once. [book] gives an address book's source and name. + */ + fun writeBack(list: List, book: (Long) -> MAddressbook?): Pair, List> { + fun name(w: MWriteBack): String? = book(w.addressbookId)?.let { providerName(it.source, it.name) ?: it.name } + val ok = list.filter { it.ok }.mapNotNull(::name).distinct() + val failed = list.filter { !it.ok }.mapNotNull(::name).distinct() + return ok to failed + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt index c5ecd93d..5f07d277 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailContactModels.kt @@ -67,8 +67,19 @@ data class MCardSummary( val linked: List, val gcUserId: Long?, val updatedAt: Long?, + /** Since Mail 1.4.0 (null before: the star is not offered). */ + val favorite: Boolean? = null, + /** Mail 1.4.0: open conflicts of this GC contact with its linked cards (the dot in the list). */ + val conflicts: Int = 0, + /** Mail 1.4.0: a linked card (a list of one account's address book): the GC contact that stands for it. */ + val masterId: Long? = null, + /** Mail 1.4.0: the address books this contact and its linked cards come from (the dots in the list). */ + val sources: List = emptyList(), ) +/** Where a contact of the list comes from (Mail 1.4.0, `CardSummary.sources`). */ +data class MSourceRef(val addressbookId: Long, val source: String, val accountId: Long?) + /** `GET cards`. */ data class MCardPage(val items: List, val next: String?, val version: Long, val deleted: List) @@ -124,6 +135,17 @@ data class MCard( val linked: List = emptyList(), val gcUserId: Long? = null, val updatedAt: Long? = null, + /** Since Mail 1.4.0 (null before: no star, no main number). */ + val favorite: Boolean? = null, + /** Mail 1.4.0: the main number (chosen, else the first mobile, else the first). */ + val mainPhone: MCardPhone? = null, + val mainPhoneChosen: Boolean = false, + /** Mail 1.4.0: a linked card shows this GC contact instead. */ + val masterId: Long? = null, + /** Mail 1.4.0: values of linked cards that differ from this contact's. */ + val conflicts: List = emptyList(), + /** Mail 1.4.0: the card itself first, then its linked cards. Empty before 1.4.0. */ + val sources: List = emptyList(), ) { val label: String get() = name.display.ifBlank { listOf(name.given, name.family).filter { it.isNotBlank() }.joinToString(" ") } .ifBlank { company ?: emails.firstOrNull()?.value ?: "" } @@ -147,7 +169,147 @@ data class MActivity(val mails: List, val events: List) 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 MDuplicate( + val key: String, + /** email | phone | name */ + val reason: String, + val cards: List, + /** Mail 1.4.0: the server merges certain ones by itself (null: an older server). */ + val certain: Boolean? = null, + /** Mail 1.4.0 (`details=1`): each card in full with where it comes from. */ + val details: List = emptyList(), + /** Mail 1.4.0 (`details=1`): what merging gives. */ + val preview: MPreview? = null, +) + +/** One card of a duplicate in full and its address book / account. */ +data class MDuplicateCard(val card: MCard, val source: MSource?) + +/** A card of a contact (Mail 1.4.0 `Source`): the card's id and the address book (and account) it is in. */ +data class MSource( + val id: Long, + val addressbookId: Long, + val addressbook: String, + /** local | graph | carddav */ + val source: String, + val accountId: Long?, + /** The account's address. */ + val account: String?, +) { + val local: Boolean get() = source == MAddressbook.SOURCE_LOCAL +} + +/** A value of a contact's field: a number, a postal address or a text (company, department, title, birthday). */ +sealed interface MFieldValue { + data class Phone(val phone: MCardPhone) : MFieldValue + data class Address(val address: MCardAddress) : MFieldValue + data class Text(val text: String) : MFieldValue +} + +/** + * A value of a linked card that differs from the GC contact's (Mail 1.4.0 `Conflict`): another mobile number, another + * company … [replaces]: the contact's value it would replace (null: it would be added). + */ +data class MCardConflict( + val id: Long, + /** phones | addresses | company | department | title | birthday */ + val field: String, + val type: String?, + val value: MFieldValue, + val replaces: MFieldValue?, + val source: MSource?, + val at: Long?, +) { + companion object { + const val PHONES = "phones" + const val ADDRESSES = "addresses" + const val COMPANY = "company" + const val DEPARTMENT = "department" + const val TITLE = "title" + const val BIRTHDAY = "birthday" + val FIELDS = setOf(PHONES, ADDRESSES, COMPANY, DEPARTMENT, TITLE, BIRTHDAY) + } +} + +/** What merging cards gives (Mail 1.4.0 `Preview`): the fields of the GC contact, its cards, a single value that loses. */ +data class MPreview( + /** The GC contact that stays (null: a new one is made). */ + val id: Long?, + val card: MCard, + val photoFrom: Long?, + val sources: List, + /** A single field that loses (another company …): a conflict at the contact afterwards. */ + val losers: List, +) + +data class MPreviewLoser(val field: String, val value: String, val source: MSource?) + +/** A merge in the protocol (Mail 1.4.0 `Merge`, "Automatisch zusammengeführt"). */ +data class MMerge( + val id: Long, + val masterId: Long, + /** auto | manual | migrated */ + val mode: String, + /** The GC contact was made by the merge (undoing removes it unless kept). */ + val created: Boolean, + /** email | phone | name | link | null */ + val reason: String?, + val at: Long, + val undoneAt: Long?, + val cards: List, +) { + val automatic: Boolean get() = mode == AUTO || mode == MIGRATED + + companion object { + const val AUTO = "auto" + const val MANUAL = "manual" + const val MIGRATED = "migrated" + } +} + +data class MMergeCard(val id: Long, val name: String, val addressbookId: Long, val addressbook: String, val source: String, val removed: Boolean) + +/** `POST cards/merges//undo`, `POST cards//unmerge`. */ +data class MUnmerge(val card: MCard?, val unlinked: List, val restored: List) + +/** A linked card the server wrote a change back to (Mail 1.4.0 `writeBack`). */ +data class MWriteBack(val id: Long, val addressbookId: Long, val ok: Boolean, val code: String?) + +/** `PATCH cards/`: the contact and where the change went back to (empty before Mail 1.4.0). */ +data class MCardSaved(val card: MCard, val writeBack: List) + +/** A favourite (Mail 1.4.0 `GET cards/favorites`): the tile on Start. */ +data class MFavorite( + val id: Long, + val name: String, + val photoEtag: String?, + /** The main number (null: none; a tap opens the contact). */ + val phone: MCardPhone?, + val email: String?, +) + +/** An event of the calendar widget on Start (Mail 1.4.0 `GET events/upcoming`). */ +data class MUpcomingItem( + val key: String, + val eventId: Long?, + val calendarId: Long?, + val overlayId: String?, + val color: String?, + val title: String, + val start: Long, + val end: Long, + val allDay: Boolean, + /** The day it is shown on (`YYYY-MM-DD`, the person's time zone). */ + val day: String, + val location: String?, + val tentative: Boolean, +) { + /** The occurrence of a series (`:`), null for a single event. */ + val occurrence: String? get() = eventId?.let { e -> key.takeIf { it.startsWith("$e:") }?.substringAfter(':')?.takeIf { it.isNotEmpty() } } +} + +/** `GET events/upcoming`. */ +data class MUpcoming(val timeZone: String?, val today: String, val items: List, val more: Int) data class MGroupMember(val id: Long, val contactId: Long?, val name: String, val email: String?) @@ -163,12 +325,19 @@ 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()) { +/** + * Realtime event `contacts`: `changed` (an address book, [contactIds] when known), `addressbooks`, `groups`; since + * Mail 1.4.0 `merged` ([masterId] stands for [contactIds]), `conflicts` and `favorites`. + */ +data class MContactsChanged(val kind: String, val addressbookId: Long? = null, val contactIds: List = emptyList(), val masterId: Long? = null) { companion object { const val CHANGED = "changed" const val ADDRESSBOOKS = "addressbooks" const val GROUPS = "groups" + const val MERGED = "merged" + const val CONFLICTS = "conflicts" + const val FAVORITES = "favorites" + val KINDS = setOf(CHANGED, ADDRESSBOOKS, GROUPS, MERGED, CONFLICTS, FAVORITES) } } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt index d0d7cf5b..2a72c964 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt @@ -446,6 +446,12 @@ object MailPimJson { linked = o.arr("linked")?.longs().orEmpty(), gcUserId = o.id("gcUserId"), updatedAt = o.long("updatedAt"), + favorite = o.bool("favorite"), + conflicts = o.int("conflicts")?.coerceAtLeast(0) ?: 0, + masterId = o.id("masterId"), + sources = o.arr("sources")?.objects()?.mapNotNull { x -> + MSourceRef(x.id("addressbookId") ?: return@mapNotNull null, x.str("source") ?: MAddressbook.SOURCE_LOCAL, x.id("accountId")) + }.orEmpty(), ) } @@ -476,22 +482,11 @@ object MailPimJson { 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(), + phones = o.arr("phones")?.objects()?.mapNotNull(::phone).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(), + addresses = o.arr("addresses")?.objects()?.mapNotNull(::address).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(), @@ -503,6 +498,159 @@ object MailPimJson { }.orEmpty(), gcUserId = o.id("gcUserId"), updatedAt = o.long("updatedAt"), + favorite = o.bool("favorite"), + mainPhone = o.obj("mainPhone")?.let(::phone), + mainPhoneChosen = o.bool("mainPhoneChosen") ?: false, + masterId = o.id("masterId"), + conflicts = o.arr("conflicts")?.objects()?.mapNotNull(::conflict).orEmpty(), + sources = o.arr("sources")?.objects()?.mapNotNull(::source).orEmpty(), + ) + } + + private fun phone(p: JsonObject): MCardPhone? = + MCardPhone(p.text("value") ?: return null, p.str("type")?.takeIf { it in MCard.PHONE_TYPES } ?: "other") + + private fun address(a: JsonObject): MCardAddress? = 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(), + ).takeIf { !it.empty } + + /** A card of a contact (Mail 1.4.0 `Source`). */ + fun source(o: JsonObject): MSource? { + val id = o.id("id") ?: return null + return MSource( + id = id, + addressbookId = o.id("addressbookId") ?: 0L, + addressbook = o.str("addressbook")?.trim().orEmpty(), + source = o.str("source")?.takeIf { it in setOf(MAddressbook.SOURCE_LOCAL, MAddressbook.SOURCE_GRAPH, MAddressbook.SOURCE_CARDDAV) } ?: MAddressbook.SOURCE_LOCAL, + accountId = o.id("accountId"), + account = o.text("account"), + ) + } + + /** A value of [field] in its form: a number, a postal address or a text. */ + private fun fieldValue(field: String, e: JsonElement?): MFieldValue? { + e ?: return null + if (e is JsonNull) return null + return when (field) { + MCardConflict.PHONES -> e.takeIf { it.isJsonObject }?.asJsonObject?.let(::phone)?.let { MFieldValue.Phone(it) } + MCardConflict.ADDRESSES -> e.takeIf { it.isJsonObject }?.asJsonObject?.let(::address)?.let { MFieldValue.Address(it) } + MCardConflict.BIRTHDAY -> e.takeIf { it.isJsonPrimitive }?.asString?.takeIf { BIRTHDAY.matches(it) }?.let { MFieldValue.Text(it) } + else -> e.takeIf { it.isJsonPrimitive }?.asString?.trim()?.takeIf { it.isNotEmpty() }?.let { MFieldValue.Text(it.take(500)) } + } + } + + /** A conflict of a GC contact (Mail 1.4.0); an unknown field or an unreadable value leaves it out. */ + fun conflict(o: JsonObject): MCardConflict? { + val id = o.id("id") ?: return null + val field = o.str("field")?.takeIf { it in MCardConflict.FIELDS } ?: return null + val value = fieldValue(field, o.el("value")) ?: return null + return MCardConflict(id, field, o.str("type"), value, fieldValue(field, o.el("replaces")), o.obj("source")?.let(::source), o.long("at")) + } + + /** `PATCH cards/` → the contact and `writeBack` (Mail 1.4.0; empty before). */ + fun cardSaved(o: JsonObject): MCardSaved? { + val card = cardAnswer(o) ?: return null + return MCardSaved(card, o.arr("writeBack")?.objects()?.mapNotNull { w -> + MWriteBack(w.id("id") ?: return@mapNotNull null, w.id("addressbookId") ?: 0L, w.bool("ok") ?: false, w.str("code")) + }.orEmpty()) + } + + /** A `Preview` (Mail 1.4.0): the Card fields of the result and where they come from. */ + fun preview(o: JsonObject): MPreview? { + val c = card(o) ?: return null + if (c.label.isBlank() && c.emails.isEmpty() && c.phones.isEmpty()) return null + return MPreview( + id = o.id("id"), + card = c.copy(id = o.id("id") ?: 0L, mainPhone = o.obj("mainPhone")?.let(::phone)), + photoFrom = o.id("photoFrom"), + sources = o.arr("sources")?.objects()?.mapNotNull(::source).orEmpty(), + losers = o.arr("conflicts")?.objects()?.mapNotNull { x -> + val field = x.str("field") ?: return@mapNotNull null + val v = x.el("value")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + MPreviewLoser(field, v.take(500), x.obj("source")?.let(::source)) + }.orEmpty(), + ) + } + + /** `POST cards/merge/preview`. */ + fun previewAnswer(o: JsonObject): MPreview? = o.obj("preview")?.let(::preview) + + /** A merge of the protocol (Mail 1.4.0). */ + fun merge(o: JsonObject): MMerge? { + val id = o.id("id") ?: return null + val master = o.id("masterId") ?: return null + return MMerge( + id = id, + masterId = master, + mode = o.str("mode")?.takeIf { it in setOf(MMerge.AUTO, MMerge.MANUAL, MMerge.MIGRATED) } ?: MMerge.MANUAL, + created = o.bool("created") ?: false, + reason = o.str("reason"), + at = o.long("at") ?: 0L, + undoneAt = o.long("undoneAt"), + cards = o.arr("cards")?.objects()?.mapNotNull { c -> + MMergeCard( + id = c.id("id") ?: return@mapNotNull null, + name = c.str("name")?.trim().orEmpty(), + addressbookId = c.id("addressbookId") ?: 0L, + addressbook = c.str("addressbook")?.trim().orEmpty(), + source = c.str("source") ?: MAddressbook.SOURCE_LOCAL, + removed = c.bool("removed") ?: false, + ) + }.orEmpty(), + ) + } + + /** `GET cards/merges`. */ + fun merges(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull(::merge) + + /** `POST cards/merges//undo`, `POST cards//unmerge` (the contact may be gone: `card: null`). */ + fun unmerge(o: JsonObject): MUnmerge? { + if (!o.has("unlinked") && !o.has("card")) return null + return MUnmerge(o.obj("card")?.let(::card)?.takeIf { it.id > 0 }, o.arr("unlinked")?.longs().orEmpty(), o.arr("restored")?.longs().orEmpty()) + } + + /** `GET cards/favorites` / `PUT cards/favorites` (by their position). */ + fun favorites(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { f -> + MFavorite( + id = f.id("id") ?: return@mapNotNull null, + name = f.str("name")?.trim().orEmpty(), + photoEtag = f.str("photoEtag")?.takeIf { it.isNotEmpty() && it.length <= 200 && f.bool("photo") != false }, + phone = f.obj("phone")?.let(::phone) ?: f.text("phone")?.let { MCardPhone(it, "other") }, + email = f.text("email")?.takeIf { it.contains('@') }, + ) + } + + /** `GET events/upcoming` (Mail 1.4.0, the calendar widget on Start). */ + fun upcoming(o: JsonObject): MUpcoming? { + val items = o.arr("items") ?: return null + val today = o.date("today") ?: return null + return MUpcoming( + timeZone = o.zone("timeZone"), + today = today, + items = items.objects().mapNotNull { x -> + val start = x.long("start") ?: return@mapNotNull null + val key = x.str("key")?.takeIf { it.isNotEmpty() && it.length <= 120 } ?: return@mapNotNull null + MUpcomingItem( + key = key, + eventId = x.id("eventId"), + calendarId = x.id("calendarId"), + overlayId = x.text("overlayId"), + color = x.color("color"), + title = x.str("title")?.trim().orEmpty(), + start = start, + end = (x.long("end") ?: start).coerceAtLeast(start), + allDay = x.bool("allDay") ?: false, + day = x.date("day") ?: x.date("startDate") ?: today, + location = x.text("location"), + tentative = x.bool("tentative") ?: false, + ) + }, + more = o.int("more")?.coerceAtLeast(0) ?: 0, ) } @@ -543,7 +691,17 @@ object MailPimJson { 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) + MDuplicate( + key = d.str("key") ?: cards.joinToString("-") { it.id.toString() }, + reason = d.str("reason") ?: "name", + cards = cards, + certain = d.bool("certain"), + details = d.arr("details")?.objects()?.mapNotNull { x -> + val c = x.obj("card")?.let(::card)?.takeIf { it.id > 0 } ?: return@mapNotNull null + MDuplicateCard(c, x.obj("source")?.let(::source)) + }.orEmpty(), + preview = d.obj("preview")?.let(::preview), + ) } fun importAnswer(o: JsonObject): MImport? { @@ -581,8 +739,8 @@ object MailPimJson { 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()) + val kind = root.str("kind")?.takeIf { it in MContactsChanged.KINDS } ?: return null + return MContactsChanged(kind, root.id("addressbookId"), root.arr("contactIds")?.longs()?.take(50).orEmpty(), root.id("masterId")) } // ─── request bodies ───────────────────────────────────────────── @@ -735,9 +893,10 @@ object MailPimJson { fun bookPatch(visible: Boolean): JsonObject = JsonObject().apply { addProperty("visible", visible) } - fun mergeBody(ids: List, primaryId: Long, pick: MMergePick?, joinNotes: Boolean): JsonObject = JsonObject().apply { + /** `POST cards/merge`; [primaryId] null: the server picks (Mail 1.4.0, as its preview shows; older ones need it). */ + 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) + primaryId?.let { addProperty("primaryId", it) } val p = JsonObject().apply { pick?.name?.let { addProperty("name", it) } pick?.company?.let { addProperty("company", it) } @@ -749,6 +908,23 @@ object MailPimJson { if (p.size() > 0) add("pick", p) } + fun idsBody(ids: List): JsonObject = JsonObject().apply { add("ids", JsonArray().apply { ids.distinct().forEach { add(it) } }) } + + /** `PATCH cards/` of GateControl's own fields only (never to the provider): favourite, main number (null: none chosen). */ + fun ownBody(favorite: Boolean? = null, mainPhone: String? = null, clearMainPhone: Boolean = false): JsonObject = JsonObject().apply { + favorite?.let { addProperty("favorite", it) } + if (clearMainPhone) add("mainPhone", JsonNull.INSTANCE) else mainPhone?.let { addProperty("mainPhone", it) } + } + + /** `POST cards//conflicts//accept`: `replace` or `add` (null: the server's default). */ + fun acceptBody(mode: String?): JsonObject = JsonObject().apply { mode?.let { addProperty("mode", it) } } + + /** `POST cards//unmerge` ([ids] null: all links) and `POST cards/merges//undo`. */ + fun unmergeBody(ids: List?, keepMaster: Boolean): JsonObject = JsonObject().apply { + ids?.let { add("ids", JsonArray().apply { it.distinct().forEach { id -> add(id) } }) } + if (keepMaster) addProperty("keepMaster", true) + } + fun importBody(vcf: String, addressbookId: Long?): JsonObject = JsonObject().apply { addressbookId?.let { addProperty("addressbookId", it) } addProperty("vcf", vcf) diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt index 431f2736..b666e8d8 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimLogic.kt @@ -592,6 +592,21 @@ object MailPimCacheLogic { add("linked", com.google.gson.JsonArray().apply { c.linked.forEach { add(it) } }) c.gcUserId?.let { addProperty("gcUserId", it) } c.updatedAt?.let { addProperty("updatedAt", it) } + // Mail 1.4.0 + c.favorite?.let { addProperty("favorite", it) } + if (c.conflicts > 0) addProperty("conflicts", c.conflicts) + c.masterId?.let { addProperty("masterId", it) } + if (c.sources.isNotEmpty()) { + add("sources", com.google.gson.JsonArray().apply { + c.sources.forEach { x -> + add(com.google.gson.JsonObject().apply { + addProperty("addressbookId", x.addressbookId) + addProperty("source", x.source) + x.accountId?.let { addProperty("accountId", it) } + }) + } + }) + } }) } }) diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt index 2a3eb468..ab23e7dc 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt @@ -2,6 +2,7 @@ package com.gatecontrol.android.mail import com.gatecontrol.android.support.CrashReports import com.google.gson.JsonElement +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -57,12 +58,41 @@ class MailPimRepository @Inject constructor( private val _relogins = MutableStateFlow>(emptySet()) val relogins: StateFlow> = _relogins.asStateFlow() + /** The favourites (Mail 1.4.0, by their position); null: not known yet. */ + private val _favorites = MutableStateFlow?>(null) + val favorites: StateFlow?> = _favorites.asStateFlow() + + /** The calendar widget's events (Mail 1.4.0); null: not known yet. */ + private val _upcoming = MutableStateFlow(null) + val upcoming: StateFlow = _upcoming.asStateFlow() + + /** Whether the server has favourites / the widget's events (false: older than Mail 1.4.0 or switched off; null: not asked yet). */ + private val _favoritesSupported = MutableStateFlow(null) + val favoritesSupported: StateFlow = _favoritesSupported.asStateFlow() + private val _upcomingSupported = MutableStateFlow(null) + val upcomingSupported: StateFlow = _upcomingSupported.asStateFlow() + + /** What Mail pins to Start on this device (favourite tiles, the calendar widget). */ + private val _startPins = MutableStateFlow(MailStartPins()) + val startPins: StateFlow = _startPins.asStateFlow() + private val pinsLoaded = CompletableDeferred() + private val pinsMutex = Mutex() + private var cardsVersion = 0L private val cardsMutex = Mutex() private var calJob: Job? = null private var booksJob: Job? = null init { + scope.launch { + try { + cache.startPins()?.let { _startPins.value = it } + cache.favorites()?.let { if (_favorites.value == null) _favorites.value = it } + cache.upcoming()?.let { if (_upcoming.value == null) _upcoming.value = it } + } finally { + pinsLoaded.complete(Unit) + } + } 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 } @@ -250,6 +280,89 @@ class MailPimRepository @Inject constructor( scope.launch { cache.forgetCard(id) } } + // ─── Start: favourites and the calendar widget (Mail 1.4.0) ───── + + /** Whether [r] says the server has no such path (older than Mail 1.4.0) or the function is off. */ + private fun missing(r: MResult<*>): Boolean = r is MResult.Failed && (r.http == 404 || r.code == "feature_off" || r.code == "not_found") + + /** `GET cards/favorites` (the copy when offline); the tiles on Start follow (a contact no favourite any more leaves). */ + suspend fun refreshFavorites(): MResult> = when (val r = api.favorites()) { + is MResult.Ok -> { + _favorites.value = r.value.first + _favoritesSupported.value = true + cache.putFavorites(r.value.second) + updateStartPins { MailContactLogic.syncedPins(it, r.value.first) } + MResult.Ok(r.value.first) + } + MResult.Offline -> { + if (_favorites.value == null) cache.favorites()?.let { _favorites.value = it } + r.notOk() + } + else -> { + if (missing(r)) _favoritesSupported.value = false + r.notOk() + } + } + + private var favJob: Job? = null + + fun refreshFavoritesSoon() { + if (favJob?.isActive == true) return + favJob = scope.launch { refreshFavorites() } + } + + /** A favourite switched here (shown at once; the server's list follows). */ + fun putFavorite(c: MCard) { + val on = c.favorite ?: return + _cards.update { l -> l.map { if (it.id == c.id) it.copy(favorite = on) else it } } + refreshFavoritesSoon() + } + + /** `GET events/upcoming` (the copy when offline). */ + suspend fun refreshUpcoming(): MResult = when (val r = api.upcoming()) { + is MResult.Ok -> { + _upcoming.value = r.value.first + _upcomingSupported.value = true + cache.putUpcoming(r.value.second) + MResult.Ok(r.value.first) + } + MResult.Offline -> { + if (_upcoming.value == null) cache.upcoming()?.let { _upcoming.value = it } + r.notOk() + } + else -> { + if (missing(r)) _upcomingSupported.value = false + r.notOk() + } + } + + private var upcomingJob: Job? = null + + fun refreshUpcomingSoon() { + if (upcomingJob?.isActive == true) return + upcomingJob = scope.launch { refreshUpcoming() } + } + + /** The pins as stored (waits for the copy to be read). */ + suspend fun currentStartPins(): MailStartPins { + pinsLoaded.await() + return _startPins.value + } + + /** Changes what Mail pins to Start; kept in the offline copy. */ + suspend fun updateStartPins(change: (MailStartPins) -> MailStartPins): MailStartPins { + pinsLoaded.await() + return pinsMutex.withLock { + val before = _startPins.value + val next = change(before) + if (next != before) { + _startPins.value = next + cache.putStartPins(next) + } + next + } + } + // ─── Microsoft ────────────────────────────────────────────────── /** The person goes to sign in again for [accountId] (the hint about the company's consent follows if it stays). */ @@ -261,7 +374,10 @@ class MailPimRepository @Inject constructor( fun onPluginEvent(type: String, data: JsonElement?): Boolean { when (type) { "calendar" -> { - val c = MailPimJson.calChanged(data) ?: return true + val c = MailPimJson.calChanged(data) + // the widget on Start: any change of the calendars may move its events + if (_upcoming.value != null || _startPins.value.agenda) refreshUpcomingSoon() + c ?: return true _calChanged.tryEmit(c) if (c.kind == MCalChanged.CALENDARS || c.kind == MCalChanged.CHANGED) refreshCalendarsSoon() return true @@ -269,8 +385,18 @@ class MailPimRepository @Inject constructor( "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() + when (c.kind) { + MContactsChanged.CHANGED -> if (_cards.value.isNotEmpty()) syncCardsSoon() + // Mail 1.4.0: cards became one GC contact (the list), the favourites (the tiles), a hint (the contact's page) + MContactsChanged.MERGED -> if (_cards.value.isNotEmpty()) syncCardsSoon() + MContactsChanged.FAVORITES -> Unit + MContactsChanged.CONFLICTS -> if (_cards.value.isNotEmpty()) syncCardsSoon() + else -> refreshBooksSoon() + } + if (_favorites.value != null && (c.kind != MContactsChanged.CONFLICTS)) { + val favIds = _favorites.value.orEmpty().map { it.id }.toSet() + if (c.kind != MContactsChanged.CHANGED || c.contactIds.isEmpty() || c.contactIds.any { it in favIds }) refreshFavoritesSoon() + } return true } } @@ -290,5 +416,10 @@ class MailPimRepository @Inject constructor( _cards.value = emptyList() cardsVersion = 0L _relogins.value = emptySet() + _favorites.value = null + _upcoming.value = null + _favoritesSupported.value = null + _upcomingSupported.value = null + _startPins.value = MailStartPins() } } 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 a5085bcf..a2732525 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -440,7 +440,23 @@ fun AppNavigation( onSettings = { openSettingsPane(SettingsPane.Pages) }, ) - CompositionLocalProvider(LocalGcWindowLayout provides layout, LocalPageSnack provides showSnack) { + // Mail's favourites and calendar widget on Start (Mail 1.4.0): the contact, the event, the calendar + val mailStartNav = remember(navController) { + com.gatecontrol.android.ui.mail.start.MailStartNav( + contact = { id -> navController.navigate(Screen.MailContact.create(id)) { launchSingleTop = true } }, + event = { id, occ -> navController.navigate(Screen.MailEvent.create(id, occ)) { launchSingleTop = true } }, + calendar = { + if (StartPages.CALENDAR in pinnedNow) showStart(StartPageRequest(StartPages.CALENDAR)) + else navController.navigate(Screen.MailCalendar.route) { launchSingleTop = true } + }, + ) + } + + CompositionLocalProvider( + LocalGcWindowLayout provides layout, + LocalPageSnack provides showSnack, + com.gatecontrol.android.ui.mail.start.LocalMailStartNav provides mailStartNav, + ) { Scaffold( containerColor = MaterialTheme.colorScheme.background, snackbarHost = { SnackbarHost(snackbarHostState) }, 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 8d884334..e7f8ffd2 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -232,6 +232,9 @@ internal fun NavGraphBuilder.mailRoutes( ) } } + composable(Screen.MailCalendar.route) { + GcContentWidth { MailHomeScreen(nav.nav, initialArea = MailDrawerFoot.CALENDAR) } + } composable(Screen.MailContactDuplicates.route) { GcContentWidth { MailDuplicatesScreen(onBack = back, onContact = nav.nav.contact) } } 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 4f17ec70..b51aca74 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -242,6 +242,8 @@ sealed class Screen(val route: String) { "id" to id?.toString(), "message" to message?.toString(), "email" to email, "name" to name?.take(200), "group" to group?.toString(), ) } + /** Mail's calendar on its own (the calendar widget on Start, while the calendar is no page beside Start). */ + data object MailCalendar : Screen("mail/calendar") /** "Dubletten": contacts that may be one person. */ data object MailContactDuplicates : Screen("mail/contacts/duplicates") /** A group of contacts: its members, "Mail an alle". */ 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 index bcb23826..d0d7e56c 100644 --- 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -23,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState 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 @@ -34,6 +36,8 @@ 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.semantics.contentDescription +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 @@ -143,3 +147,70 @@ private fun sourceLabel(source: String): String? = when (source) { MContact.SOURCE_RECENT -> stringResource(R.string.pim_source_recent) else -> null } + +/** "Auch in Outlook geändert" / "Nicht bei geändert" after saving a GC contact (Mail 1.4.0 `writeBack`). */ +internal fun writeBackToast(context: android.content.Context, ok: List, failed: List) { + val parts = listOfNotNull( + ok.takeIf { it.isNotEmpty() }?.let { context.getString(R.string.con_writeback_ok, it.joinToString(", ")) }, + failed.takeIf { it.isNotEmpty() }?.let { context.getString(R.string.con_writeback_failed, it.joinToString(", ")) }, + ) + if (parts.isNotEmpty()) android.widget.Toast.makeText(context, parts.joinToString("\n"), android.widget.Toast.LENGTH_SHORT).show() +} + +/** + * Delete a contact: a GC contact with linked cards (Mail 1.4.0) asks whether they go at their provider too (off by + * default: they stay there and are no longer linked). + */ +@Composable +internal fun DeleteContactDialog(c: com.gatecontrol.android.mail.MCard, onDelete: (linkedToo: Boolean) -> Unit, onDismiss: () -> Unit) { + val links = c.sources.filter { it.id != c.id } + val ask = com.gatecontrol.android.mail.MailContactLogic.masterModel(c) && c.linked.isNotEmpty() && c.source == "local" + var linkedToo by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableStateOf(false) } + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.con_delete_title, c.label)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(if (c.source == "local") R.string.con_delete_text else R.string.con_delete_text_account)) + if (ask) { + val names = links.map { com.gatecontrol.android.mail.MailContactLogic.originLabel(it) }.filter { it.isNotBlank() }.distinct() + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(RoundedCornerShape(10.dp)) + .toggleable(value = linkedToo, role = Role.Checkbox, onValueChange = { linkedToo = it }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.compose.material3.Checkbox(checked = linkedToo, onCheckedChange = null) + Text( + stringResource(R.string.con_delete_linked_too, names.joinToString(", ").ifEmpty { c.linked.size.toString() }), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + } + } + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { onDelete(ask && linkedToo) }) { + Text(stringResource(R.string.mail_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} + +/** The dots of the accounts a contact comes from (one each; own contacts none). */ +@Composable +internal fun SourceDots(me: MMe?, accountIds: List) { + if (accountIds.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) { + accountIds.take(4).forEach { a -> + val color = sourceColor(me, a) ?: return@forEach + val label = accountLabel(me, a) + Box( + Modifier.size(8.dp).clip(CircleShape).background(color) + .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier), + ) + } + } +} 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 index dff3be58..ca8b9c29 100644 --- 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 @@ -107,7 +107,9 @@ fun MailContactEditScreen(onClose: () -> Unit, onSaved: (Long) -> Unit, viewMode var deleting by remember { mutableStateOf(false) } val c = state.card LaunchedEffect(state.done) { - state.done?.let { if (it > 0) onSaved(it) else onClose() } + val done = state.done ?: return@LaunchedEffect + state.writeBack?.let { (ok, failed) -> writeBackToast(context, ok, failed) } + if (done > 0) onSaved(done) else onClose() } LaunchedEffect(state.message) { state.message?.let { @@ -299,13 +301,7 @@ fun MailContactEditScreen(onClose: () -> Unit, onSaved: (Long) -> Unit, viewMode } } 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)) } }, - ) + DeleteContactDialog(c, onDelete = { linkedToo -> deleting = false; viewModel.delete(linkedToo) }, onDismiss = { deleting = false }) } state.conflict?.let { AlertDialog( 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 index 8d869b30..7ccd7cb2 100644 --- 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 @@ -9,6 +9,9 @@ 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.MCardSaved +import com.gatecontrol.android.mail.notOk +import com.gatecontrol.android.mail.MailContactLogic import com.gatecontrol.android.mail.MMe import com.gatecontrol.android.mail.MResult import com.gatecontrol.android.mail.MailPimJson @@ -46,6 +49,8 @@ data class ContactEditState( val message: String? = null, /** Saved: the contact's id (the screen closes). */ val done: Long? = null, + /** Mail 1.4.0: the providers the change went back to, and the ones it did not reach (by name). */ + val writeBack: Pair, List>? = null, ) /** @@ -189,17 +194,28 @@ class MailContactEditViewModel @Inject constructor( _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)) + // the whole form: what was removed stays removed (Mail 1.3.6); a value taken over from a linked card goes + // back to it (Mail 1.4.0, `writeBack`) + val r: MResult = if (cardId == null) { + when (val c = api.createCard(MailPimJson.cardBody(form(s), addressbookId = s.addressbookId))) { + is MResult.Ok -> MResult.Ok(MCardSaved(c.value, emptyList())) + is MResult.Failed -> c + else -> c.notOk() + } + } else { + api.patchCard(cardId, MailPimJson.cardBody(form(s), etag = etag)) + } when (r) { is MResult.Ok -> { + val card = r.value.card 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) + is PhotoChange.Set -> api.putCardPhoto(card.id, "image/jpeg", p.bytes) + PhotoChange.Remove -> if (card.photo) api.deleteCardPhoto(card.id) null -> Unit } pim.syncCardsSoon() - _state.update { it.copy(saving = false, done = r.value.id) } + val wb = MailContactLogic.writeBack(r.value.writeBack) { b -> pim.books.value?.book(b) }.takeIf { it.first.isNotEmpty() || it.second.isNotEmpty() } + _state.update { it.copy(saving = false, done = card.id, writeBack = wb) } } is MResult.Failed -> { val current = if (r.code == "conflict") MailPimJson.conflictCard(r.error) else null @@ -224,10 +240,11 @@ class MailContactEditViewModel @Inject constructor( start(current) } - fun delete() { + /** Deletes the contact; [linkedToo] (a GC contact with links, Mail 1.4.0): its linked cards at their provider too. */ + fun delete(linkedToo: Boolean = false) { val cardId = id ?: return viewModelScope.launch { - when (val r = api.deleteCard(cardId)) { + when (val r = api.deleteCard(cardId, linkedToo)) { is MResult.Ok -> { pim.forgetCard(cardId) _state.update { it.copy(done = 0L) } 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 index ff48c1c1..453d46c6 100644 --- 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 @@ -3,6 +3,7 @@ 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.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -107,25 +108,59 @@ fun MailContactScreen(nav: ContactNav, viewModel: MailContactViewModel = hiltVie LaunchedEffect(state.gone) { if (state.gone) nav.back() } LaunchedEffect(state.message) { state.message?.let { - toast(context, mailText(context, it)) + toast( + context, + when (it) { + "main_phone" -> context.getString(R.string.con_main_phone_set) + "unlinked" -> context.getString(R.string.con_unlinked) + "pinned" -> context.getString(R.string.fav_pinned) + "unpinned" -> context.getString(R.string.fav_unpinned) + "fav_limit" -> context.getString(R.string.fav_limit, com.gatecontrol.android.mail.MailContactLogic.FAVORITES_MAX) + else -> mailText(context, it) + }, + ) viewModel.clearMessage() } } + val startPins by viewModel.startPins.collectAsStateWithLifecycle() + var unlinking by remember { mutableStateOf(null) } 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) { + // Mail 1.4.0: the star (a favourite; GateControl's own, also for read-only sources) + if (c != null && c.favorite != null) { + val on = c.favorite == true + GcIconButton( + if (on) GcIcons.StarFilled else GcIcons.Star, + contentDescription = stringResource(if (on) R.string.fav_remove else R.string.fav_add), + onClick = viewModel::toggleFavorite, + enabled = !offline, + tint = if (on) extra.warn else MaterialTheme.colorScheme.onBackground, + ) + } + val pinnable = c != null && c.favorite == true + if (c != null && (!c.readOnly || pinnable)) { 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 }, - ) + if (pinnable) { + val pinned = startPins.has(c.id) + DropdownMenuItem( + text = { Text(stringResource(if (pinned) R.string.fav_unpin else R.string.fav_pin)) }, + leadingIcon = { Icon(GcIcons.Pin, contentDescription = null, modifier = Modifier.size(20.dp)) }, + onClick = { menu = false; viewModel.pinToStart(!pinned) }, + ) + } + if (!c.readOnly) { + 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 }, + ) + } } } } @@ -141,21 +176,36 @@ fun MailContactScreen(nav: ContactNav, viewModel: MailContactViewModel = hiltVie 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()) { + if (c.sources.size > 1) { + // Mail 1.4.0: the GC contact with the cards linked to it ("verknüpft mit Outlook (…)") + val linked = c.sources.filter { it.id != c.id }.map { com.gatecontrol.android.mail.MailContactLogic.originLabel(it) }.filter { it.isNotBlank() }.distinct() 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) + SourceDots(me, c.sources.mapNotNull { it.accountId }.distinct()) + Text(stringResource(R.string.con_linked_with, linked.joinToString(", ")), style = MaterialTheme.typography.bodySmall, color = extra.muted, textAlign = TextAlign.Center) + } + } else { + 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) + } } } } + // Mail 1.4.0: a linked card has another value — take it over or let it go (online only) + if (!offline) { + c.conflicts.forEach { x -> ConflictBand(x, busy = state.busy, onAccept = { add -> viewModel.accept(x, add) }, onDismiss = { viewModel.dismiss(x) }) } + } 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 + // a chosen main number is called straight away (Mail 1.4.0) + val main = c.mainPhone?.takeIf { c.mainPhoneChosen } + if (c.phones.size == 1) call(context, c.phones[0].value) else if (main != null) call(context, main.value) else pickCall = true } ActionTile(GcIcons.Edit, stringResource(R.string.con_edit), enabled = !c.readOnly && !offline, modifier = Modifier.weight(1f)) { nav.edit(c.id) } } @@ -164,7 +214,16 @@ fun MailContactScreen(nav: ContactNav, viewModel: MailContactViewModel = hiltVie } 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.phones.forEach { p -> + val main = c.favorite != null && c.phones.size > 1 && com.gatecontrol.android.mail.MailContactLogic.isMain(c, p) + val choose = !offline && com.gatecontrol.android.mail.MailContactLogic.canChooseMain(c, p) + Field( + typeName(p.type) + if (main) " · " + stringResource(R.string.con_main_phone) else "", + p.value, + link = true, + menu = if (choose) listOf(stringResource(R.string.con_main_phone_choose) to { viewModel.setMainPhone(p) }) else emptyList(), + ) { 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', ' '))) } @@ -186,6 +245,29 @@ fun MailContactScreen(nav: ContactNav, viewModel: MailContactViewModel = hiltVie } } } + // Mail 1.4.0: where the contact comes from, each linked card with "Verknüpfung lösen" + if (c.sources.size > 1) { + GcSectionLabel(stringResource(R.string.con_sources), Modifier.padding(horizontal = 4.dp)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + c.sources.forEach { src -> + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SourceDots(me, listOfNotNull(src.accountId)) + Column(Modifier.weight(1f)) { + val label = com.gatecontrol.android.mail.MailContactLogic.originLabel(src).ifBlank { src.addressbook } + Text(if (src.local) stringResource(R.string.con_source_gc) else label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(src.addressbook, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (src.id != c.id) { + TextButton(onClick = { unlinking = src }, enabled = !offline && !state.busy) { Text(stringResource(R.string.con_unlink)) } + } + } + } + } + } state.activity?.let { a -> if (a.mails.isNotEmpty()) { GcSectionLabel(stringResource(R.string.con_last_mails), Modifier.padding(horizontal = 4.dp)) @@ -227,12 +309,15 @@ fun MailContactScreen(nav: ContactNav, viewModel: MailContactViewModel = hiltVie 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) { + DeleteContactDialog(c, onDelete = { linkedToo -> deleting = false; viewModel.delete(linkedToo) }, onDismiss = { deleting = false }) + } + unlinking?.let { src -> 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)) } }, + onDismissRequest = { unlinking = null }, + title = { Text(stringResource(R.string.con_unlink_title)) }, + text = { Text(stringResource(R.string.con_unlink_text, com.gatecontrol.android.mail.MailContactLogic.originLabel(src).ifBlank { src.addressbook })) }, + confirmButton = { TextButton(onClick = { unlinking = null; viewModel.unlink(src) }) { Text(stringResource(R.string.con_unlink), fontWeight = FontWeight.Bold) } }, + dismissButton = { TextButton(onClick = { unlinking = null }) { Text(stringResource(R.string.mail_cancel)) } }, ) } } @@ -255,20 +340,77 @@ private fun ActionTile(icon: ImageVector, label: String, enabled: Boolean, modif } } +/** A field of the contact; [menu] (label → action) opens on a long press ("Als Hauptnummer"). */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable -private fun Field(label: String, value: String, link: Boolean = false, action: ImageVector? = null, onClick: (() -> Unit)? = null) { +private fun Field(label: String, value: String, link: Boolean = false, action: ImageVector? = null, menu: List Unit>> = emptyList(), 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) } + var open by remember { mutableStateOf(false) } + Box { + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp) + .then( + when { + menu.isNotEmpty() -> Modifier.combinedClickable( + role = Role.Button, + onLongClickLabel = menu.first().first, + onLongClick = { open = true }, + onClick = { onClick?.invoke() }, + ) + 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) + if (menu.isNotEmpty()) { + Text(value, style = MaterialTheme.typography.bodyLarge, color = if (link) extra.accentText else MaterialTheme.colorScheme.onSurface) + } else { + 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)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + menu.forEach { (text, run) -> DropdownMenuItem(text = { Text(text) }, onClick = { open = false; run() }) } + } + } +} + +/** + * A linked card has another value (Mail 1.4.0): "Outlook (…) hat eine andere Mobilnummer: 0171 … (bisher 0170 …)", + * "Übernehmen" (replaces), "Zusätzlich" where it fits (adds), "Verwerfen" (the value stays away). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ConflictBand(x: com.gatecontrol.android.mail.MCardConflict, busy: Boolean, onAccept: (add: Boolean) -> Unit, onDismiss: () -> Unit) { + val logic = com.gatecontrol.android.mail.MailContactLogic + val what = stringResource( + when (logic.hintKind(x)) { + "mobile" -> R.string.con_hint_mobile + "phone" -> R.string.con_hint_phone + "address" -> R.string.con_hint_address + "company" -> R.string.con_hint_company + "department" -> R.string.con_hint_department + "title" -> R.string.con_hint_title + else -> R.string.con_hint_birthday + }, + ) + val origin = logic.originLabel(x.source).ifBlank { stringResource(R.string.con_hint_linked) } + val text = stringResource(R.string.con_hint, origin, what, logic.valueText(x.value)) + + (x.replaces?.let { " " + stringResource(R.string.con_hint_before, logic.valueText(it)) } ?: "") + com.gatecontrol.android.ui.components.GcBanner(tone = com.gatecontrol.android.ui.components.GcTone.Warn, icon = GcIcons.Info) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text, style = MaterialTheme.typography.bodySmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { onAccept(false) }, enabled = !busy) { Text(stringResource(R.string.con_hint_accept), fontWeight = FontWeight.Bold) } + if (logic.canAdd(x)) TextButton(onClick = { onAccept(true) }, enabled = !busy) { Text(stringResource(R.string.con_hint_add)) } + TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.con_hint_dismiss)) } + } } - if (action != null) Icon(action, contentDescription = null, tint = extra.muted, modifier = Modifier.size(20.dp)) } } 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 index 8ba63451..ac0c8470 100644 --- 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 @@ -6,7 +6,16 @@ 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.layout.StartArrangement import com.gatecontrol.android.mail.MCard +import com.gatecontrol.android.mail.MCardConflict +import com.gatecontrol.android.mail.MCardPhone +import com.gatecontrol.android.mail.MContactsChanged +import com.gatecontrol.android.mail.MSource +import com.gatecontrol.android.mail.MailContactLogic +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailStartPins +import com.gatecontrol.android.mail.StartContactPin import com.gatecontrol.android.mail.MMe import com.gatecontrol.android.mail.MResult import com.gatecontrol.android.mail.MailRepository @@ -27,12 +36,15 @@ data class ContactState( val error: String? = null, val message: String? = null, val gone: Boolean = false, + /** A hint or a link being answered (its buttons wait). */ + val busy: Boolean = false, ) /** One contact (mockup "Kontakt"): its fields, its last mails and shared events, delete. */ @HiltViewModel class MailContactViewModel @Inject constructor( private val repository: MailRepository, + private val arrangement: StartArrangement, saved: SavedStateHandle, ) : ViewModel() { @@ -43,16 +55,30 @@ class MailContactViewModel @Inject constructor( val calendars: StateFlow = pim.calendars val offline: StateFlow = pim.offline - val id: Long = saved.get(Screen.MailContact.ARG_ID)?.toLongOrNull() ?: 0L + /** The contact shown: the route's, or the GC contact that stands for it (Mail 1.4.0, a linked card). */ + var id: Long = saved.get(Screen.MailContact.ARG_ID)?.toLongOrNull() ?: 0L + private set private val _state = MutableStateFlow(ContactState()) val state: StateFlow = _state.asStateFlow() + /** What Mail pins to Start (the favourite's tile). */ + val startPins: StateFlow = pim.startPins + 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) } + pim.contactsChanged.collect { c -> + when { + // merged into a GC contact: that one is shown from now on + c.kind == MContactsChanged.MERGED && id in c.contactIds && c.masterId != null && c.masterId != id -> { + id = c.masterId + load(quiet = true) + } + c.contactIds.isEmpty() || id in c.contactIds -> load(quiet = true) + } + } } } @@ -64,18 +90,134 @@ class MailContactViewModel @Inject constructor( 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) } + is MResult.Ok -> { + val master = r.value.masterId + if (master != null && master != id) { + // a linked card: its GC contact is the contact (Mail 1.4.0) + id = master + load(quiet) + return@launch + } + _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() { + private fun shown(card: MCard) { + _state.update { it.copy(card = card, busy = false) } + pim.putFavorite(card) + } + + private fun failed(r: MResult<*>) { + val code = (r as? MResult.Failed)?.code + _state.update { it.copy(busy = false, message = if (code == "limit") "fav_limit" else MailHomeViewModel.code(r)) } + } + + /** The star: a favourite or not (GateControl's own, never to the provider). */ + fun toggleFavorite() { + val c = _state.value.card ?: return + val on = c.favorite != true + _state.update { it.copy(card = c.copy(favorite = on)) } + viewModelScope.launch { + when (val r = api.patchOwn(c.id, MailPimJson.ownBody(favorite = on))) { + is MResult.Ok -> { + shown(r.value.card) + // no favourite any more: its tile leaves Start + if (!on) arrangement.pinContact(StartContactPin(c.id, c.label), on = false) + } + else -> { + _state.update { it.copy(card = c) } + failed(r) + } + } + } + } + + /** "Als Hauptnummer": the number a tile on Start calls. */ + fun setMainPhone(p: MCardPhone) { + val c = _state.value.card ?: return + viewModelScope.launch { + when (val r = api.patchOwn(c.id, MailPimJson.ownBody(mainPhone = p.value))) { + is MResult.Ok -> { + shown(r.value.card) + _state.update { it.copy(message = "main_phone") } + } + else -> failed(r) + } + } + } + + /** "Übernehmen" ([add]: "Zusätzlich") of a differing value. */ + fun accept(x: MCardConflict, add: Boolean) { + val c = _state.value.card ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.acceptConflict(c.id, x.id, MailContactLogic.acceptMode(x, add))) { + is MResult.Ok -> shown(r.value) + else -> { + failed(r) + load(quiet = true) + } + } + } + } + + /** "Verwerfen": the value stays away until it changes at the provider. */ + fun dismiss(x: MCardConflict) { + val c = _state.value.card ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.dismissConflict(c.id, x.id)) { + is MResult.Ok -> shown(r.value) + else -> { + failed(r) + load(quiet = true) + } + } + } + } + + /** "Verknüpfung lösen": the linked card [source] stands on its own again (nothing changes at the provider). */ + fun unlink(source: MSource) { + val c = _state.value.card ?: return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.unmerge(c.id, listOf(source.id))) { + is MResult.Ok -> { + pim.syncCardsSoon() + val card = r.value.card + if (card == null) { + pim.forgetCard(c.id) + _state.update { it.copy(busy = false, gone = true) } + } else { + _state.update { it.copy(card = card, busy = false, message = "unlinked") } + } + } + else -> failed(r) + } + } + } + + /** The favourite's tile on Start ([on]) or off it. */ + fun pinToStart(on: Boolean) { + val c = _state.value.card ?: return + viewModelScope.launch { + arrangement.pinContact(MailContactLogic.pinOf(c), on) + _state.update { it.copy(message = if (on) "pinned" else "unpinned") } + } + } + + /** Deletes the contact; [linkedToo] (a GC contact with links): its linked cards at their provider too. */ + fun delete(linkedToo: Boolean = false) { viewModelScope.launch { - when (val r = api.deleteCard(id)) { + when (val r = api.deleteCard(id, linkedToo)) { is MResult.Ok -> { pim.forgetCard(id) + arrangement.pinContact(StartContactPin(id, ""), on = false) + if (linkedToo) pim.syncCardsSoon() _state.update { it.copy(gone = true) } } else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } 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 index 0898d1d1..9510fd24 100644 --- 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 @@ -120,7 +120,14 @@ fun MailContactsContent(nav: ContactsNav, viewModel: MailContactsViewModel) { 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)) + toast( + context, + when { + parts[0] == "import" && parts.size == 4 -> context.getString(R.string.con_imported, parts[1].toInt(), parts[2].toInt(), parts[3].toInt()) + m == "fav_limit" -> context.getString(R.string.fav_limit, com.gatecontrol.android.mail.MailContactLogic.FAVORITES_MAX) + else -> mailText(context, m) + }, + ) viewModel.clearMessage() } val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> @@ -202,7 +209,7 @@ fun MailContactsContent(nav: ContactsNav, viewModel: MailContactsViewModel) { 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) + ContactsList(shown, me, books, state, nav, viewModel, offline) } } if (state.tab == ContactsState.TAB_CONTACTS && !offline) { @@ -290,7 +297,7 @@ private fun TabsRow(tab: String, groups: Int, onTab: (String) -> Unit) { @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContactsList(shown: List, me: MMe?, books: com.gatecontrol.android.mail.MBooks?, state: ContactsState, nav: ContactsNav, viewModel: MailContactsViewModel) { +private fun ContactsList(shown: List, me: MMe?, books: com.gatecontrol.android.mail.MBooks?, state: ContactsState, nav: ContactsNav, viewModel: MailContactsViewModel, offline: Boolean) { val extra = GateControlTheme.extraColors val context = mailContext() val sections = remember(shown) { MailPimLogic.sections(shown) } @@ -333,8 +340,7 @@ private fun ContactsList(shown: List, me: MMe?, books: com.gatecon 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) } + ContactRow(c, me, books, onStar = if (offline) null else ({ viewModel.toggleFavorite(c) })) { nav.contact(c.masterId ?: c.id) } } } } @@ -359,23 +365,49 @@ private fun ContactsList(shown: List, me: MMe?, books: com.gatecon } } -/** A contact in the list (mockup): photo or initials, name, position · company or the first address, the source's dot. */ +/** + * A contact in the list (mockup): photo or initials, name, position · company or the first address, the dots of the + * accounts it comes from (Mail 1.4.0: its linked cards' too), a quiet dot when a linked card has another value, and + * the favourite's star ([onStar] null: none, an older server). + */ @Composable -internal fun ContactRow(c: MCardSummary, source: Color?, sourceName: String?, onClick: () -> Unit) { +internal fun ContactRow(c: MCardSummary, me: MMe?, books: com.gatecontrol.android.mail.MBooks?, onStar: (() -> Unit)? = null, 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), + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = if (onStar != null) 20.dp else 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) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + c.name.ifBlank { c.emails.firstOrNull() ?: "–" }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (c.conflicts > 0) { + val hint = pluralStringResource(R.plurals.con_conflicts_n, c.conflicts, c.conflicts) + Box(Modifier.size(7.dp).clip(RoundedCornerShape(99.dp)).background(extra.warn).semantics { contentDescription = hint }) + } + } 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 } }) + SourceDots(me, com.gatecontrol.android.mail.MailContactLogic.sourceAccounts(c) { b -> books?.book(b)?.accountId }) + if (onStar != null && c.favorite != null) { + val on = c.favorite == true + GcIconButton( + if (on) GcIcons.StarFilled else GcIcons.Star, + contentDescription = stringResource(if (on) R.string.fav_remove else R.string.fav_add), + onClick = onStar, + tint = if (on) extra.warn else extra.faint, + size = 36.dp, + iconSize = 18.dp, + ) } } } 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 index 23c71a72..cf39c65d 100644 --- 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 @@ -7,6 +7,8 @@ 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.MailContactLogic +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 @@ -69,10 +71,11 @@ class MailContactsViewModel @Inject constructor( /** The list shown: filtered by address book or group, searched on the device. */ val shown: StateFlow> = combine(pim.cards, _state) { cards, s -> + // the GC contacts stand for their linked cards (Mail 1.4.0): an account's address book shows the ones linked from it 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 + s.filter.group != null -> MailContactLogic.listed(cards).filter { s.filter.group in it.groups } + s.filter.book != null -> cards.filter { c -> c.addressbookId == s.filter.book || (c.masterId == null && c.sources.any { it.addressbookId == s.filter.book }) } + else -> MailContactLogic.listed(cards) } if (s.query.isBlank()) list else (s.remote?.takeIf { cards.isEmpty() } ?: MailPimLogic.search(list, s.query)) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -107,7 +110,8 @@ class MailContactsViewModel @Inject constructor( private fun refreshDuplicates() { viewModelScope.launch { - api.duplicates().valueOrNull()?.let { d -> _state.update { it.copy(duplicates = d.size) } } + // the certain ones the server merges by itself (Mail 1.4.0): only the ones to look at count + api.duplicates().valueOrNull()?.let { d -> _state.update { it.copy(duplicates = d.count { x -> x.certain != true }) } } } } @@ -183,4 +187,16 @@ class MailContactsViewModel @Inject constructor( fun clearMessage() = _state.update { it.copy(message = null) } fun relogin(accountId: Long) = pim.markRelogin(accountId) + + /** The star (Mail 1.4.0): a favourite or not; shown at once. */ + fun toggleFavorite(c: MCardSummary) { + val on = c.favorite != true + viewModelScope.launch { + when (val r = api.patchOwn(c.id, MailPimJson.ownBody(favorite = on))) { + is MResult.Ok -> pim.putFavorite(r.value.card) + is MResult.Failed -> _state.update { it.copy(message = if (r.code == "limit") "fav_limit" else r.code) } + else -> _state.update { it.copy(message = MailHomeViewModel.code(r)) } + } + } + } } 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 index 5c9f7fd9..37e9097e 100644 --- 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 @@ -1,5 +1,7 @@ package com.gatecontrol.android.ui.mail.contacts +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 @@ -12,27 +14,28 @@ 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.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.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -43,22 +46,25 @@ 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.MMerge +import com.gatecontrol.android.mail.MPreview import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSource +import com.gatecontrol.android.mail.MailContactLogic 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.GcSectionLabel 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.mailTime import com.gatecontrol.android.ui.mail.toast import com.gatecontrol.android.ui.theme.GateControlTheme import dagger.hilt.android.lifecycle.HiltViewModel @@ -70,17 +76,24 @@ import kotlinx.coroutines.launch import javax.inject.Inject data class DuplicatesState( + /** The cases to look at (Mail 1.4.0: the uncertain ones; the server merged the certain ones). */ val items: List = emptyList(), + /** Mail 1.4.0: the automatic merges ("Automatisch zusammengeführt"); null: an older server. */ + val merges: List? = null, + /** Before Mail 1.4.0: the full cards of a case, loaded here (the server sends no details). */ + val cards: Map> = emptyMap(), 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, + /** A case or a merge being answered. */ + val busy: String? = null, val message: String? = null, ) -/** "Dubletten" (contract D4): the possible duplicates, merging (with the fields to keep) and "Keine Dublette". */ +/** + * "Dubletten" (Mail 1.4.0 Nachtrag §6): the cases to confirm — each card with where it comes from and its numbers, + * addresses, postal address and photo, "So wird der Kontakt", "Zusammenführen" and "Keine Dublette" — and above them + * the automatic merges with "Rückgängig". An older server: its cases with the cards loaded here and a preview made here. + */ @HiltViewModel class MailDuplicatesViewModel @Inject constructor(private val repository: MailRepository) : ViewModel() { private val pim = repository.pim @@ -98,52 +111,74 @@ class MailDuplicatesViewModel @Inject constructor(private val repository: MailRe 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) } + when (val r = api.duplicates(details = true)) { + is MResult.Ok -> { + val items = r.value.filter { it.certain != true } + _state.update { it.copy(items = items, loading = false) } + // the protocol of the automatic merges (an older server has none: 404, nothing shown) + api.merges().valueOrNull()?.let { m -> _state.update { it.copy(merges = m.filter { x -> x.automatic && x.undoneAt == null }) } } + // an older server: the cards of the cases from here + items.filter { it.details.isEmpty() }.take(30).forEach { d -> + val cards = d.cards.mapNotNull { c -> pim.card(c.id).valueOrNull() } + _state.update { s -> s.copy(cards = s.cards + (d.key to cards)) } + } + } 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) } + /** "Zusammenführen": one GC contact (Mail 1.4.0, as previewed); an older server keeps the GC card (else the first). */ + fun merge(d: MDuplicate) { + if (_state.value.busy != null) return + _state.update { it.copy(busy = d.key) } viewModelScope.launch { - when (val r = api.merge(MailPimJson.mergeBody(d.cards.map { it.id }, primary, pick, joinNotes = true))) { + val modern = d.certain != null + val cards = cardsOf(d) + val primary = if (modern) null else (cards.firstOrNull { it.source == "local" } ?: cards.firstOrNull())?.id ?: d.cards.first().id + when (val r = api.merge(MailPimJson.mergeBody(d.cards.map { it.id }, primary, null, joinNotes = !modern))) { is MResult.Ok -> { - _state.update { s -> s.copy(busy = false, merging = null, items = s.items - d, message = "merged") } + _state.update { s -> s.copy(busy = null, items = s.items - d, message = "merged") } pim.syncCards(full = true) } - else -> _state.update { it.copy(busy = false, message = MailHomeViewModel.code(r)) } + else -> _state.update { it.copy(busy = null, message = MailHomeViewModel.code(r)) } } } } - /** "Keine Dublette": the pair is not shown again. */ + /** "Keine Dublette": the pair is not shown again (and never merged by itself). */ fun ignore(d: MDuplicate) { + if (_state.value.busy != null) return + _state.update { it.copy(busy = d.key) } 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)) } + is MResult.Ok -> _state.update { s -> s.copy(busy = null, items = s.items - d) } + else -> _state.update { it.copy(busy = null, message = MailHomeViewModel.code(r)) } } } } + /** "Rückgängig" of an automatic merge: the cards stand on their own again (nothing changes at the provider). */ + fun undo(m: MMerge) { + if (_state.value.busy != null) return + _state.update { it.copy(busy = "m${m.id}") } + viewModelScope.launch { + when (val r = api.undoMerge(m.id, keepMaster = false)) { + is MResult.Ok -> { + _state.update { s -> s.copy(busy = null, merges = s.merges?.filter { it.id != m.id }, message = "undone") } + pim.syncCards(full = true) + } + else -> _state.update { it.copy(busy = null, message = MailHomeViewModel.code(r)) } + } + } + } + + /** The full cards of a case: the server's details, else the ones loaded here. */ + fun cardsOf(d: MDuplicate, s: DuplicatesState = _state.value): List = d.details.map { it.card }.ifEmpty { s.cards[d.key].orEmpty() } + 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() @@ -151,9 +186,18 @@ fun MailDuplicatesScreen(onBack: () -> Unit, onContact: (Long) -> Unit, viewMode val books by viewModel.books.collectAsStateWithLifecycle() val context = mailContext() val extra = GateControlTheme.extraColors + var autoOpen by rememberSaveable { mutableStateOf(false) } + var undoing by remember { mutableStateOf(null) } LaunchedEffect(state.message) { state.message?.let { - toast(context, if (it == "merged") context.getString(R.string.con_merged) else mailText(context, it)) + toast( + context, + when (it) { + "merged" -> context.getString(R.string.con_merged) + "undone" -> context.getString(R.string.dup_undone) + else -> mailText(context, it) + }, + ) viewModel.clearMessage() } } @@ -162,116 +206,217 @@ fun MailDuplicatesScreen(onBack: () -> Unit, onContact: (Long) -> Unit, viewMode 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)) + val merges = state.merges.orEmpty() + if (merges.isNotEmpty()) { + item(key = "auto") { + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(role = Role.Button) { autoOpen = !autoOpen }.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Check, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(18.dp)) + Text( + stringResource(R.string.dup_auto, merges.size), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f).semantics { heading() }, + ) + Icon(if (autoOpen) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + } + if (autoOpen) { + merges.forEach { m -> MergeRow(m, books, busy = state.busy == "m${m.id}", onOpen = { onContact(m.masterId) }, onUndo = { undoing = m }) } + } } } } + if (state.items.isEmpty()) { + item(key = "none") { ErrorBox(stringResource(R.string.con_no_duplicates), onRetry = null) } + } else { + item(key = "h") { + GcSectionLabel(pluralStringResource(R.plurals.dup_open, state.items.size, state.items.size), Modifier.padding(start = 4.dp, top = 4.dp)) + } + } + items(state.items, key = { it.key }) { d -> + DuplicateCase( + d = d, + cards = viewModel.cardsOf(d, state), + me = me, + busy = state.busy == d.key, + onContact = onContact, + onMerge = { viewModel.merge(d) }, + onIgnore = { viewModel.ignore(d) }, + ) + } } } } - state.merging?.let { d -> MergeDialog(d, state.cards, state.busy, onMerge = viewModel::merge, onDismiss = viewModel::close) } + undoing?.let { m -> + AlertDialog( + onDismissRequest = { undoing = null }, + title = { Text(stringResource(R.string.dup_undo_title)) }, + text = { Text(stringResource(if (m.created) R.string.dup_undo_text_created else R.string.dup_undo_text)) }, + confirmButton = { TextButton(onClick = { undoing = null; viewModel.undo(m) }) { Text(stringResource(R.string.dup_undo), fontWeight = FontWeight.Bold) } }, + dismissButton = { TextButton(onClick = { undoing = null }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } } +/** An automatic merge: the names it joined, from where, when; "Rückgängig". */ @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 - }, -) +private fun MergeRow(m: MMerge, books: MBooks?, busy: Boolean, onOpen: () -> Unit, onUndo: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button, onClick = onOpen).padding(start = 14.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + m.cards.map { it.name }.filter { it.isNotBlank() }.distinct().joinToString(" · ").ifBlank { "–" }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val from = m.cards.map { c -> MailContactLogic.providerName(c.source, c.addressbook) ?: c.addressbook.ifBlank { books?.book(c.addressbookId)?.name.orEmpty() } } + .filter { it.isNotBlank() }.distinct() + Text((from + mailTime(m.at)).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (busy) CircularProgressIndicator(Modifier.size(20.dp)) else TextButton(onClick = onUndo) { Text(stringResource(R.string.dup_undo)) } + } +} /** - * The merge preview: which contact stays, per differing field which value; contacts of different address books are - * linked ("beim Anbieter bleibt beides"). + * One case to confirm: why (same number …), each card with where it comes from and what it holds, "So wird der + * Kontakt", "Zusammenführen" and "Keine Dublette". */ @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()) } +private fun DuplicateCase( + d: MDuplicate, + cards: List, + me: MMe?, + busy: Boolean, + onContact: (Long) -> Unit, + onMerge: () -> Unit, + onIgnore: () -> Unit, +) { 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 + val sources: Map = remember(d) { d.details.associate { it.card.id to it.source } } + GcCard(contentPadding = PaddingValues(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(reasonText(d.reason), style = MaterialTheme.typography.labelMedium, color = extra.warn, modifier = Modifier.padding(horizontal = 14.dp)) + if (cards.size < 2) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp)) } + } else { + cards.forEach { c -> + DuplicateCard(c, sources[c.id], me, own = MailContactLogic.ownValues(c, cards.filter { it.id != c.id }), onClick = { onContact(c.id) }) + } + val preview: MPreview? = d.preview + val result = preview?.card ?: MailContactLogic.localPreview(cards) + if (result != null) { + val photoCard = preview?.photoFrom?.let { p -> cards.firstOrNull { it.id == p } } ?: cards.firstOrNull { it.photo } + PreviewCard(result, photoCard, preview) } - 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) } + } + Row(Modifier.padding(horizontal = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcPrimaryButton(stringResource(R.string.con_merge), onClick = onMerge, enabled = !busy && cards.size >= 2, modifier = Modifier.weight(1f)) + GcOutlineButton(stringResource(R.string.con_not_duplicate), onClick = onIgnore, enabled = !busy, modifier = Modifier.weight(1f)) + } + } +} + +/** A card of a case: photo (small), name, address book and account, its numbers, addresses and postal addresses; what only it has stands out. */ +@Composable +private fun DuplicateCard(c: MCard, source: MSource?, me: MMe?, own: Set, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Column( + Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ContactAvatar(c.label, c.emails.firstOrNull()?.value, c.id.takeIf { c.photo }, c.photoEtag, size = 36.dp) + Column(Modifier.weight(1f)) { + Text(c.label.ifBlank { "–" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val origin = when { + source != null && source.local -> stringResource(R.string.con_source_gc) + " · " + source.addressbook + source != null -> listOf(MailContactLogic.originLabel(source), source.addressbook).filter { it.isNotBlank() }.distinct().joinToString(" · ") + c.source == "local" -> stringResource(R.string.con_source_gc) + else -> MailContactLogic.providerName(c.source, null).orEmpty() } - 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) - } - } - } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SourceDots(me, listOfNotNull(source?.accountId)) + if (origin.isNotBlank()) Text(origin, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) } - 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)) } }, - ) + } + val job = listOfNotNull(c.title, c.company).joinToString(" · ") + if (job.isNotEmpty()) Line(job, false) + c.phones.forEach { p -> Line(typeName(p.type) + " " + p.value, "p:" + MailPimLogic.phoneKey(p.value) in own) } + c.emails.forEach { e -> Line(e.value, "e:" + e.value.trim().lowercase() in own) } + c.addresses.forEach { a -> Line(addressLine(a), "a:" + MailContactLogic.addressKey(a) in own) } + if (c.phones.isEmpty() && c.emails.isEmpty() && c.addresses.isEmpty()) Line(stringResource(R.string.dup_nothing), false) + } } +private fun addressLine(a: com.gatecontrol.android.mail.MCardAddress): String = + listOf(a.street, listOf(a.postalCode, a.city).filter { it.isNotBlank() }.joinToString(" ")).filter { it.isNotBlank() }.joinToString(", ") + @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 - }, -) +private fun Line(text: String, own: Boolean) { + val extra = GateControlTheme.extraColors + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = if (own) extra.accentText else MaterialTheme.colorScheme.onSurface, + fontWeight = if (own) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 46.dp), + ) +} +/** "So wird der Kontakt": the name, the main number, every number, address and postal address, what is left as a hint. */ @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, +private fun PreviewCard(c: MCard, photoCard: MCard?, preview: MPreview?) { + val extra = GateControlTheme.extraColors + Column( + Modifier.padding(horizontal = 10.dp).fillMaxWidth().background(extra.accentBg.copy(alpha = 0.45f), MaterialTheme.shapes.small).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - RadioButton(selected = selected, onClick = null) - Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.dup_result), style = MaterialTheme.typography.labelMedium, color = extra.accentText, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ContactAvatar(c.label, c.emails.firstOrNull()?.value, photoCard?.id?.takeIf { photoCard.photo }, photoCard?.photoEtag, size = 36.dp) + Column(Modifier.weight(1f)) { + Text(c.label.ifBlank { "–" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val job = listOfNotNull(c.title, c.department, c.company).joinToString(" · ") + if (job.isNotEmpty()) Text(job, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + val main = c.mainPhone ?: MailContactLogic.mainPhoneOf(c.phones, null) + c.phones.forEach { p -> + val isMain = main != null && c.phones.size > 1 && MailPimLogic.phoneKey(p.value) == MailPimLogic.phoneKey(main.value) + Line(typeName(p.type) + " " + p.value + if (isMain) " · " + stringResource(R.string.con_main_phone) else "", false) + } + c.emails.forEach { e -> Line(e.value, false) } + c.addresses.forEach { a -> Line(addressLine(a), false) } + c.birthday?.let { b -> MailPimLogic.birthdayParts(b)?.let { (m, d, y) -> Line(stringResource(R.string.con_birthday) + " " + "%02d.%02d.".format(d, m) + (y?.toString() ?: ""), false) } } + preview?.losers?.forEach { l -> + Text( + stringResource(R.string.dup_loser, l.value, MailContactLogic.originLabel(l.source)), + style = MaterialTheme.typography.bodySmall, + color = extra.warn, + modifier = Modifier.padding(start = 46.dp), + ) + } } } + +@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 + }, +) 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 index afb8c60d..0573e38f 100644 --- 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 @@ -236,7 +236,7 @@ private fun AddMemberDialog(cards: List, onContact: (Long) -> Unit 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 (q.isNotBlank()) found.forEach { c -> ContactRow(c, null, null) { onContact(c.masterId ?: c.id) } } if (typed != null) TextButton(onClick = { onAddress(typed) }) { Text(stringResource(R.string.con_add_address_member, typed.email)) } } }, diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt new file mode 100644 index 00000000..bf9f99f0 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/start/MailStart.kt @@ -0,0 +1,438 @@ +package com.gatecontrol.android.ui.mail.start + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.PaddingValues +import androidx.compose.foundation.layout.Row +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +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.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +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.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.layout.StartArrangement +import com.gatecontrol.android.mail.AgendaView +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MFavorite +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MUpcoming +import com.gatecontrol.android.mail.MUpcomingItem +import com.gatecontrol.android.mail.MailContactLogic +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.mail.MailStartPins +import com.gatecontrol.android.mail.StartContactPin +import com.gatecontrol.android.mail.TileTap +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.mail.calendar.calColor +import com.gatecontrol.android.ui.mail.calendar.clock +import com.gatecontrol.android.ui.mail.contacts.initialsColor +import com.gatecontrol.android.ui.mail.contacts.rememberCardPhoto +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.time.ZoneId +import javax.inject.Inject + +/** Where Mail's blocks on Start lead (given by the navigation; null: they show nothing). */ +class MailStartNav( + val contact: (Long) -> Unit, + val event: (eventId: Long, occurrence: String?) -> Unit, + /** The calendar (its page beside Start, else Mail's calendar). */ + val calendar: () -> Unit, +) + +val LocalMailStartNav = staticCompositionLocalOf { null } + +/** + * Mail's blocks on Start (Mail 1.4.0): favourites as tiles and the calendar widget, pinned on this device like the + * plugins' buttons and widgets. Hidden while the server has no favourites / no such events (older than 1.4.0, the + * function off). + */ +@HiltViewModel +class MailStartViewModel @Inject constructor( + repository: MailRepository, + private val arrangement: StartArrangement, +) : ViewModel() { + private val pim = repository.pim + val me: StateFlow = repository.me + val pins: StateFlow = pim.startPins + val favorites: StateFlow?> = pim.favorites + val favoritesSupported: StateFlow = pim.favoritesSupported + val upcoming: StateFlow = pim.upcoming + val upcomingSupported: StateFlow = pim.upcomingSupported + val calendars: StateFlow = pim.calendars + + fun refreshFavorites() = pim.refreshFavoritesSoon() + + fun refreshUpcoming() = pim.refreshUpcomingSoon() + + fun pinContact(f: MFavorite, on: Boolean, page: String? = null) { + viewModelScope.launch { arrangement.pinContact(MailContactLogic.pinOf(f), on, page) } + } + + fun pinAgenda(on: Boolean, page: String? = null) { + viewModelScope.launch { arrangement.pinAgenda(on, page) } + } +} + +/** Whether Mail's calendar widget can be offered: the calendar is on and the server has its events (or not asked yet). */ +internal fun agendaAvailable(me: MMe?, supported: Boolean?): Boolean = me?.has(MMe.FEATURE_CALENDAR) == true && supported != false + +/** Whether favourites can be offered: the contacts are on and the server has favourites. */ +internal fun favoritesAvailable(me: MMe?, supported: Boolean?): Boolean = me?.has(MMe.FEATURE_CONTACTS) == true && supported != false + +// ─── favourite tiles ──────────────────────────────────────────────── + +/** Calls [tel]: straight away while the app may place calls, else the dialer with the number. */ +internal fun dial(context: Context, tel: String) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED + val intent = Intent(MailContactLogic.callAction(granted), tel.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // no phone app: nothing to call with + } catch (_: SecurityException) { + context.startActivity(Intent(MailContactLogic.ACTION_DIAL, tel.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } +} + +private val TileShape = RoundedCornerShape(18.dp) + +/** + * A favourite on Start: the photo or the initials as the tile, the name below (like the plugins' buttons, [width]). + * A tap calls the main number (without one it opens the contact), a long press opens the contact. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ContactTile(pin: StartContactPin, width: Dp) { + val nav = LocalMailStartNav.current + val context = LocalContext.current + val extra = GateControlTheme.extraColors + val photo = rememberCardPhoto(pin.id.takeIf { pin.photoEtag != null }, pin.photoEtag) + val tap = MailContactLogic.tap(pin) + Column( + Modifier + .width(width) + .clip(RoundedCornerShape(12.dp)) + .combinedClickable( + role = Role.Button, + onClickLabel = stringResource(if (tap is TileTap.Call) R.string.con_call else R.string.fav_open), + onLongClickLabel = stringResource(R.string.fav_open), + onLongClick = { nav?.contact?.invoke(pin.id) }, + onClick = { + when (tap) { + is TileTap.Call -> dial(context, tap.tel) + is TileTap.Open -> nav?.contact?.invoke(tap.id) + } + }, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier.size(width).clip(TileShape).background(initialsColor(pin.name)).border(1.dp, extra.border2, TileShape), + contentAlignment = Alignment.Center, + ) { + if (photo != null) { + Image(photo, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(width)) + } else { + Text(MailLogic.initials(pin.name.ifBlank { null }, "?"), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = (width.value * 0.32f).sp) + } + if (tap is TileTap.Call) { + Box( + Modifier.align(Alignment.BottomEnd).padding(4.dp).size(18.dp).clip(RoundedCornerShape(99.dp)).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon(GcIcons.Phone, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(11.dp)) + } + } + } + val labelStyle = MaterialTheme.typography.labelSmall + BasicText( + pin.name.ifBlank { "–" }, + style = labelStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + hyphens = Hyphens.Auto, + lineBreak = LineBreak.Paragraph, + lineHeight = labelStyle.fontSize * 1.15f, + ), + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased(minFontSize = 9.sp, maxFontSize = labelStyle.fontSize, stepSize = 0.5.sp), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** The favourites pinned to a page of Start that can be shown (the server has favourites). */ +@Composable +fun rememberStartContacts(viewModel: MailStartViewModel = hiltViewModel()): Map { + val pins by viewModel.pins.collectAsStateWithLifecycle() + val supported by viewModel.favoritesSupported.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + LaunchedEffect(pins.contacts.isNotEmpty()) { if (pins.contacts.isNotEmpty()) viewModel.refreshFavorites() } + // Without Mail's contacts (switched off, an older server) the tiles are not shown; `me` unknown yet: the copy. + if (supported == false || (me != null && me?.has(MMe.FEATURE_CONTACTS) != true)) return emptyMap() + return pins.contacts.associateBy { it.id } +} + +// ─── the calendar widget ──────────────────────────────────────────── + +/** + * The calendar widget on Start (Mail 1.4.0): the next events of today and tomorrow — time, title, the calendar's + * colour; a tap opens the event, a tap on the head the calendar, a long press offers taking it off Start. Asked for + * again when Start shows, at midnight and with each change of the calendars (live); offline the last copy. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MailAgendaCard(modifier: Modifier = Modifier, page: String? = null, viewModel: MailStartViewModel = hiltViewModel()) { + val upcoming by viewModel.upcoming.collectAsStateWithLifecycle() + val supported by viewModel.upcomingSupported.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val nav = LocalMailStartNav.current + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val zone = remember { ZoneId.systemDefault() } + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + var menu by remember { mutableStateOf(false) } + + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (true) { + now = System.currentTimeMillis() + viewModel.refreshUpcoming() + // again at midnight (a new day); in between each minute the ones over drop out + val next = MailContactLogic.nextDay(now, zone) + var t = System.currentTimeMillis() + while (t < next) { + delay(minOf(60_000L, next - t + 1_000L)) + t = System.currentTimeMillis() + now = t + } + } + } + } + // Without Mail's calendar (switched off, an older server) the widget is not shown; `me` unknown yet: the copy. + if (supported == false || (me != null && !agendaAvailable(me, supported))) return + val view: AgendaView? = remember(upcoming, now) { upcoming?.let { MailContactLogic.agenda(it, now, zone) } } + + GcCard(modifier = modifier, contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.Top) { + Box { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .combinedClickable( + role = Role.Button, + onClickLabel = stringResource(R.string.agenda_open_calendar), + onLongClickLabel = stringResource(R.string.agenda_menu), + onLongClick = { menu = true }, + onClick = { nav?.calendar?.invoke() }, + ) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Calendar, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(18.dp)) + Text( + stringResource(R.string.agenda_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f).semantics { heading() }, + ) + Icon(GcIcons.ChevronRight, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.agenda_remove)) }, + leadingIcon = { Icon(GcIcons.Trash, contentDescription = null, modifier = Modifier.size(20.dp)) }, + onClick = { menu = false; viewModel.pinAgenda(false, page) }, + ) + } + } + when { + view == null -> Text( + stringResource(R.string.agenda_loading), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + view.empty -> Text( + stringResource(R.string.agenda_none), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + else -> view.days.forEach { d -> + Text( + stringResource(if (d.today) R.string.agenda_today else R.string.agenda_tomorrow), + style = MaterialTheme.typography.labelMedium, + color = extra.muted, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 6.dp, bottom = 2.dp), + ) + d.items.forEach { x -> + AgendaRow(x, cals, zone, locale) { + val e = x.eventId + if (e != null) nav?.event?.invoke(e, x.occurrence) else nav?.calendar?.invoke() + } + } + } + } + if (view != null && view.more > 0) { + Text( + pluralStringResource(R.plurals.agenda_more, view.more, view.more), + style = MaterialTheme.typography.bodySmall, + color = extra.accentText, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { nav?.calendar?.invoke() }.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + } +} + +@Composable +private fun AgendaRow(x: MUpcomingItem, cals: MCalendars?, zone: ZoneId, locale: java.util.Locale, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val color = calColor(x.color ?: x.calendarId?.let { cals?.calendar(it)?.color }, extra.accentText) + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + if (x.allDay) stringResource(R.string.agenda_all_day) else clock(x.start, zone, locale), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + modifier = Modifier.width(64.dp), + ) + Box(Modifier.width(3.dp).height(28.dp).clip(RoundedCornerShape(2.dp)).background(if (x.tentative) color.copy(alpha = 0.45f) else color)) + Column(Modifier.weight(1f)) { + Text(x.title.ifBlank { stringResource(R.string.agenda_no_title) }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + x.location?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } +} + +// ─── "Schnellaktionen und Widgets": what Mail can put on Start ────── + +/** + * Mail's part of the list of what can go on Start: the calendar widget and each favourite as a tile, each with a + * switch (they join [page], a widget page beside Start, null: Start). Nothing while Mail offers neither. + */ +@Composable +fun MailStartChooser(page: String?, modifier: Modifier = Modifier, viewModel: MailStartViewModel = hiltViewModel()) { + val me by viewModel.me.collectAsStateWithLifecycle() + val pins by viewModel.pins.collectAsStateWithLifecycle() + val favorites by viewModel.favorites.collectAsStateWithLifecycle() + val favSupported by viewModel.favoritesSupported.collectAsStateWithLifecycle() + val agendaSupported by viewModel.upcomingSupported.collectAsStateWithLifecycle() + val extra = GateControlTheme.extraColors + LaunchedEffect(Unit) { + viewModel.refreshFavorites() + viewModel.refreshUpcoming() + } + val agenda = agendaAvailable(me, agendaSupported) && agendaSupported == true + val favs = favoritesAvailable(me, favSupported) && favSupported == true + if (!agenda && !favs) return + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + com.gatecontrol.android.ui.components.GcSectionLabel(stringResource(R.string.mail_tab), Modifier.padding(start = 4.dp, top = 4.dp)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + if (agenda) { + com.gatecontrol.android.ui.components.GcSwitchRow( + label = stringResource(R.string.agenda_title), + description = stringResource(R.string.agenda_pin_desc), + checked = pins.agenda, + onCheckedChange = { viewModel.pinAgenda(it, page) }, + icon = GcIcons.Calendar, + ) + } + if (favs) { + val list = favorites.orEmpty() + if (list.isEmpty()) { + Text( + stringResource(R.string.fav_none_hint), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + list.forEach { f -> + com.gatecontrol.android.ui.components.GcSwitchRow( + label = f.name.ifBlank { f.email ?: "–" }, + description = f.phone?.value ?: stringResource(R.string.fav_no_phone), + checked = pins.has(f.id), + onCheckedChange = { viewModel.pinContact(f, it, page) }, + icon = GcIcons.User, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt index 744524f0..5a6da326 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt @@ -133,6 +133,10 @@ fun QuickActionsScreen( modifier = Modifier.padding(horizontal = 16.dp), ) } + // Mail (1.4.0): the calendar widget and favourites as tiles + item(key = "mail") { + com.gatecontrol.android.ui.mail.start.MailStartChooser(page, Modifier.padding(horizontal = 16.dp)) + } val notice = when (load) { QuickActionsLoad.Unsupported -> R.string.quick_actions_unsupported QuickActionsLoad.NotConfigured -> R.string.quick_actions_not_configured @@ -352,13 +356,17 @@ fun StartTiles( val running by viewModel.running.collectAsStateWithLifecycle() val extra = GateControlTheme.extraColors val hasWidgets = widgetPins.any { it.resolve(devices) != null } - val order = pages[page].orEmpty().filterIsInstance().map { it.action } + val tileEntries = pages[page].orEmpty().filter { it is StartEntry.Tile || it is StartEntry.Contact } + val order = tileEntries.filterIsInstance().map { it.action } val settingsLabel = stringResource(R.string.start_tile_settings) // Only this page's buttons (a button on another page beside Start is not shown here). val here = order.toSet() val shown = StartLayout.ordered(pins.filter { it.key in here }, order) { it.key } + // Mail's favourites pinned here (Mail 1.4.0), among the buttons in the arranged order. + val contacts = com.gatecontrol.android.ui.mail.start.rememberStartContacts() + val contactsHere = tileEntries.any { it is StartEntry.Contact && it.id in contacts } - when (val row = QuickActionsRow.of(shown, actions, hasWidgets || page != null)) { + when (val row = QuickActionsRow.of(shown, actions, hasWidgets || page != null || contactsHere)) { QuickActionsRow.Hidden -> Unit QuickActionsRow.Choose -> Text( stringResource(R.string.start_edit_hint), @@ -367,7 +375,7 @@ fun StartTiles( textAlign = TextAlign.Center, modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), ) - is QuickActionsRow.Tiles -> if (row.items.isNotEmpty()) BoxWithConstraints(modifier.fillMaxWidth()) { + is QuickActionsRow.Tiles -> if (row.items.isNotEmpty() || contactsHere) BoxWithConstraints(modifier.fillMaxWidth()) { // In whole pixels, rounded down: the row lays the gaps out in whole // pixels, and a width rounded up pushes the last tile into the next row. val density = LocalDensity.current @@ -377,7 +385,13 @@ fun StartTiles( verticalArrangement = Arrangement.spacedBy(12.dp), maxItemsInEachRow = TILES_PER_ROW, ) { - row.items.forEach { item -> + val byKey = row.items.associateBy { it.key } + tileEntries.forEach { entry -> + if (entry is StartEntry.Contact) { + contacts[entry.id]?.let { com.gatecontrol.android.ui.mail.start.ContactTile(it, tile) } + return@forEach + } + val item = byKey[(entry as StartEntry.Tile).action] ?: return@forEach val door = longPress(item, onOpenDoor) QuickActionTile( item = item, @@ -462,6 +476,7 @@ fun StartCards( Spacer(Modifier.weight(1f)) } first is StartEntry.Widget -> WidgetFor(first, Modifier.fillMaxWidth(), fill = false) + first == StartEntry.Agenda -> com.gatecontrol.android.ui.mail.start.MailAgendaCard(page = page) first == StartEntry.Info -> info() first == StartEntry.Usage -> usage() } diff --git a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt index a737abc1..08c114f2 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt @@ -176,6 +176,14 @@ class QuickActionsViewModel @Inject constructor( } /** "−" on a tile while arranging: the button leaves the start tab. */ + /** What Mail pins to Start (favourite tiles, the calendar widget; Mail 1.4.0). */ + val mailPins: StateFlow = arrangement.mailPins + + /** Takes the favourite [id] off Start. */ + fun unpinContact(id: Long) { + viewModelScope.launch { arrangement.pinContact(com.gatecontrol.android.mail.StartContactPin(id, ""), on = false) } + } + fun unpinTile(key: String) { viewModelScope.launch { repository.removePin(key) diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt index e60e54f5..caecbd66 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt @@ -126,8 +126,9 @@ fun StartArrangeScreen( if (StartLayout.canMove(list, key, 1)) add(CustomAccessibilityAction(downLabel) { working = StartLayout.moveBy(list, key, 1); true }) } - val tiles = list.filterIsInstance() - val cards = list.filter { it !is StartEntry.Tile } + val tiles = list.filter { it is StartEntry.Tile || it is StartEntry.Contact } + val cards = list.filter { it !is StartEntry.Tile && it !is StartEntry.Contact } + val mailPins by viewModel.mailPins.collectAsStateWithLifecycle() val resolved = widgetPins.mapNotNull { pin -> pin.resolve(devices)?.let { pin.device to it } }.toMap() Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { @@ -149,16 +150,21 @@ fun StartArrangeScreen( GcSectionLabel(stringResource(R.string.arrange_tiles), Modifier.padding(start = 4.dp)) } items(tiles, key = { it.key }, span = { GridItemSpan(2) }) { tile -> - val item = pins.firstOrNull { it.key == tile.action } - val title = item?.title ?: tile.action + val contact = (tile as? StartEntry.Contact)?.let { c -> mailPins.contacts.firstOrNull { it.id == c.id } } + val item = (tile as? StartEntry.Tile)?.let { t -> pins.firstOrNull { it.key == t.action } } + val title = contact?.name ?: item?.title ?: (tile as? StartEntry.Tile)?.action ?: "" ReorderableItem(reorderState, key = tile.key) { dragging -> TileBlock( title = title, - icon = remember(item?.displayIcon) { QuickActionIcons.vector(item?.displayIcon) }, + icon = if (tile is StartEntry.Contact) GcIcons.User else remember(item?.displayIcon) { QuickActionIcons.vector(item?.displayIcon) }, dragging = dragging, onRemove = { working = list.filter { it.key != tile.key } - viewModel.unpinTile(tile.action) + when (tile) { + is StartEntry.Contact -> viewModel.unpinContact(tile.id) + is StartEntry.Tile -> viewModel.unpinTile(tile.action) + else -> Unit + } }, removeLabel = stringResource(R.string.arrange_remove, title), modifier = Modifier @@ -191,6 +197,7 @@ fun StartArrangeScreen( Triple(device?.title ?: card.device, remember(device?.icon) { QuickActionIcons.vector(device?.icon) }, true) } StartEntry.Info -> Triple(stringResource(R.string.arrange_info), GcIcons.Sliders, false) + StartEntry.Agenda -> Triple(stringResource(R.string.agenda_title), GcIcons.Calendar, true) else -> Triple(stringResource(R.string.arrange_usage), GcIcons.Activity, false) } ReorderableItem(reorderState, key = card.key) { dragging -> 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 dadcaf9c..693a2ef2 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 @@ -543,7 +543,7 @@ private fun WidgetPage( } else { StartTiles(onOpenDoor = onOpenDoor, page = id, viewModel = viewModel) StartCards( - cards = entries.filter { it !is StartEntry.Tile }, + cards = entries.filter { it !is StartEntry.Tile && it !is StartEntry.Contact }, info = {}, usage = {}, onOpenDoor = onOpenDoor, diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt index df29b26c..7bab6e22 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt @@ -568,7 +568,7 @@ fun VpnScreen( // [withUsage] false: the usage card stands in the other pane. val cardsSection: @Composable ColumnScope.(withUsage: Boolean) -> Unit = { withUsage -> com.gatecontrol.android.ui.quickactions.StartCards( - cards = startEntries.filter { it !is StartEntry.Tile && (withUsage || it != StartEntry.Usage) }, + cards = startEntries.filter { it !is StartEntry.Tile && it !is StartEntry.Contact && (withUsage || it != StartEntry.Usage) }, info = infoCard, usage = usageCard, onOpenDoor = onOpenDoor, diff --git a/app/src/main/res/values-de/strings_pim.xml b/app/src/main/res/values-de/strings_pim.xml index 4ebec0b7..959f41bc 100644 --- a/app/src/main/res/values-de/strings_pim.xml +++ b/app/src/main/res/values-de/strings_pim.xml @@ -199,7 +199,6 @@ Nachname Firma Position - Name E-Mail-Adresse hinzufügen Telefonnummer hinzufügen Anschrift hinzufügen @@ -220,9 +219,6 @@ 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 diff --git a/app/src/main/res/values-de/strings_pim2.xml b/app/src/main/res/values-de/strings_pim2.xml new file mode 100644 index 00000000..cb198104 --- /dev/null +++ b/app/src/main/res/values-de/strings_pim2.xml @@ -0,0 +1,71 @@ + + + + Als Favorit markieren + Aus den Favoriten entfernen + Höchstens %1$d Favoriten + Kontakt öffnen + Auf Start anheften + Von Start entfernen + Auf Start angeheftet + Von Start entfernt + Markiere Kontakte mit dem Stern als Favoriten, um sie auf Start zu legen. + Keine Nummer: ein Tipp öffnet den Kontakt + + %d abweichender Wert + %d abweichende Werte + + Hauptnummer + Als Hauptnummer + Hauptnummer geändert + Quellen + Meine Kontakte + Verknüpfung lösen + Verknüpfung lösen? + Der Kontakt aus %1$s erscheint wieder einzeln. Beim Anbieter ändert sich nichts. + Verknüpfung gelöst + Auch in %1$s geändert + Nicht bei %1$s geändert + Verknüpfte Kontakte bei %1$s auch löschen + %1$s hat %2$s: %3$s + (bisher %1$s) + Ein verknüpfter Kontakt + eine andere Mobilnummer + eine andere Nummer + eine andere Anschrift + eine andere Firma + eine andere Abteilung + eine andere Position + einen anderen Geburtstag + Übernehmen + Zusätzlich + Verwerfen + Automatisch zusammengeführt (%1$d) + + %d Fall zum Prüfen + %d Fälle zum Prüfen + + Rückgängig + Zusammenführen rückgängig machen? + Die Kontakte erscheinen wieder einzeln. Bei den Anbietern ändert sich nichts. + Die Kontakte erscheinen wieder einzeln, und der zusammengeführte Kontakt wird entfernt, samt deiner Änderungen daran. Bei den Anbietern ändert sich nichts. + Zusammenführen rückgängig gemacht + So wird der Kontakt + Bleibt als Hinweis: %1$s (%2$s) + Keine Nummern oder Adressen + Termine + Die nächsten Termine heute und morgen + Kalender öffnen + Optionen + Von Start entfernen + Termine werden geladen … + Heute und morgen keine weiteren Termine + Heute + Morgen + ganztägig + (ohne Titel) + + %d weiterer Termin + %d weitere Termine + + diff --git a/app/src/main/res/values/strings_pim.xml b/app/src/main/res/values/strings_pim.xml index 8a1ce7e2..e8ec9f13 100644 --- a/app/src/main/res/values/strings_pim.xml +++ b/app/src/main/res/values/strings_pim.xml @@ -199,7 +199,6 @@ Last name Company Position - Name Add email address Add phone number Add address @@ -220,9 +219,6 @@ 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 diff --git a/app/src/main/res/values/strings_pim2.xml b/app/src/main/res/values/strings_pim2.xml new file mode 100644 index 00000000..7f9dbd81 --- /dev/null +++ b/app/src/main/res/values/strings_pim2.xml @@ -0,0 +1,71 @@ + + + + Mark as favourite + Remove from favourites + At most %1$d favourites + Open contact + Pin to Start + Remove from Start + Pinned to Start + Removed from Start + Mark contacts with the star as favourites to put them on Start. + No number: a tap opens the contact + + %d different value + %d different values + + main number + Set as main number + Main number changed + Sources + My contacts + Unlink + Unlink? + The contact from %1$s will be shown on its own again. Nothing changes at the provider. + Unlinked + Also changed in %1$s + Not changed in %1$s + Also delete the linked contacts at %1$s + %1$s has %2$s: %3$s + (so far %1$s) + A linked contact + a different mobile number + a different number + a different address + a different company + a different department + a different position + a different birthday + Take over + Add + Dismiss + Merged automatically (%1$d) + + %d case to check + %d cases to check + + Undo + Undo merging? + The contacts are shown on their own again. Nothing changes at the providers. + The contacts are shown on their own again and the merged contact goes, with your own changes to it. Nothing changes at the providers. + Merging undone + This is the contact + Stays as a hint: %1$s (%2$s) + No numbers or addresses + Events + The next events of today and tomorrow + Open calendar + Options + Remove from Start + Loading events … + No more events today and tomorrow + Today + Tomorrow + all day + (no title) + + %d more event + %d more events + + diff --git a/app/src/test/java/com/gatecontrol/android/layout/StartLayoutTest.kt b/app/src/test/java/com/gatecontrol/android/layout/StartLayoutTest.kt index f630e77e..2a05e7ae 100644 --- a/app/src/test/java/com/gatecontrol/android/layout/StartLayoutTest.kt +++ b/app/src/test/java/com/gatecontrol/android/layout/StartLayoutTest.kt @@ -111,4 +111,37 @@ class StartLayoutTest { assertEquals(listOf("c", "a", "b", "d"), StartLayout.ordered(items, listOf("c", "a", "x")) { it }) assertEquals(items, StartLayout.ordered(items, emptyList()) { it }) } + + @Test + fun `Mail's favourites are tiles after the buttons, its calendar widget tops the cards`() { + val extras = StartExtras(contacts = listOf(5, 6), agenda = true) + val raw = StartLayout.encodeAll(listOf(StartEntry.Contact(5), StartEntry.Agenda)) + assertEquals(listOf("contact:5", "agenda"), raw) + assertEquals(listOf(StartEntry.Contact(5), StartEntry.Agenda), raw.map { StartLayout.decode(it) }) + assertTrue(raw.all { TabLayout.isValidEntry(it) }) + assertNull(StartLayout.decode("contact:")) + assertNull(StartLayout.decode("contact:0")) + assertNull(StartLayout.decode("contact:x")) + + val fresh = StartLayout.merge(emptyList(), listOf(door), listOf(widget(car)), extras = extras) + assertEquals(listOf("tile:$door", "contact:5", "contact:6", "agenda", "widget:$car", "info", "usage"), fresh.map { it.key }) + // the stored order wins; a contact no longer pinned (or another device's) is skipped + val saved = listOf("contact:6", "tile:$door", "contact:9", "widget:$car", "agenda", "info", "usage") + assertEquals( + listOf("contact:6", "tile:$door", "contact:5", "widget:$car", "agenda", "info", "usage"), + StartLayout.merge(saved, listOf(door), listOf(widget(car)), extras = extras).map { it.key }, + ) + // nothing pinned by Mail: the stored entries do nothing + assertEquals(listOf("tile:$door", "widget:$car", "info", "usage"), StartLayout.merge(saved, listOf(door), listOf(widget(car))).map { it.key }) + } + + @Test + fun `favourites move among the tiles, the calendar widget among the cards`() { + val entries = StartLayout.merge(emptyList(), listOf(door), listOf(widget(car)), extras = StartExtras(listOf(5), agenda = true)) + assertEquals(listOf("contact:5", "tile:$door"), StartLayout.move(entries, "contact:5", "tile:$door").take(2).map { it.key }) + assertSame(entries, StartLayout.move(entries, "contact:5", "agenda")) + assertFalse(StartLayout.canMove(entries, "contact:5", 1)) + assertTrue(StartLayout.canMove(entries, "agenda", 1)) + assertEquals(listOf(listOf(StartEntry.Agenda)), StartLayout.rows(listOf(StartEntry.Agenda))) + } } diff --git a/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt b/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt index 24a47af7..5959f902 100644 --- a/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt +++ b/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt @@ -200,4 +200,15 @@ class StartPagesTest { assertEquals(emptyList(), StartPages.pages(gone)) assertTrue("widget:other/phone" in StartPages.entriesOf(gone, null)) } + + @Test + fun `Mail's pins stand where they were put, a new one joins the page it was pinned from`() { + val extras = StartExtras(contacts = listOf(5, 6), agenda = true) + val saved = listOf("tile:$door", "info", "usage", "page:w2", "agenda", "contact:6") + val pages = StartPages.merge(saved, listOf(door), emptyList(), extras) + assertEquals(listOf("tile:$door", "contact:5", "info", "usage"), pages[null]!!.map { it.key }) + assertEquals(listOf("contact:6", "agenda"), pages["w2"]!!.map { it.key }) + val synced = StartPages.syncPins(saved, listOf(door), emptyList(), target = "w2", extras = StartExtras(listOf(6, 7), agenda = false)) + assertEquals(listOf("tile:$door", "info", "usage", "page:w2", "contact:6", "contact:7"), synced) + } } diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt new file mode 100644 index 00000000..a0e6d56d --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailContactLogicTest.kt @@ -0,0 +1,352 @@ +package com.gatecontrol.android.mail + +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 +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +/** Mail 1.4.0 on the app's side: the JSON of the master model and the rules of [MailContactLogic]. */ +class MailContactLogicTest { + + private val berlin = ZoneId.of("Europe/Berlin") + + private fun o(s: String) = JsonParser.parseString(s).asJsonObject + + private fun ms(y: Int, mo: Int, d: Int, h: Int, mi: Int = 0) = LocalDateTime.of(y, mo, d, h, mi).atZone(berlin).toInstant().toEpochMilli() + + private val outlook = MSource(7, 3, "Kontakte", MAddressbook.SOURCE_GRAPH, 2, "ma@outlook.com") + + private fun conflict(field: String, value: MFieldValue, replaces: MFieldValue?, type: String? = null) = + MCardConflict(1, field, type, value, replaces, outlook, 0) + + private fun sum(id: Long, master: Long? = null, sources: List = emptyList(), book: Long = 1) = MCardSummary( + id = id, addressbookId = book, name = "N$id", sortKey = "N$id", letter = "N", company = null, title = null, emails = emptyList(), phone = null, + photo = false, photoEtag = null, groups = emptyList(), linked = emptyList(), gcUserId = null, updatedAt = null, masterId = master, sources = sources, + ) + + // ─── JSON ─────────────────────────────────────────────────────── + + @Test + fun `a summary of Mail 1_4 - favourite, main number, conflicts, sources, an older one has none`() { + val s = MailPimJson.cardSummary( + o( + """{"id":5,"addressbookId":1,"name":"Sabrina","phone":"+49 171 1","favorite":true,"mainPhone":"+49 171 1","conflicts":2,"masterId":null, + "sources":[{"addressbookId":1,"source":"local","accountId":null},{"addressbookId":3,"source":"graph","accountId":2},{"source":"graph"}]}""", + ), + )!! + assertEquals(true, s.favorite) + assertEquals(2, s.conflicts) + assertNull(s.masterId) + assertEquals(listOf(MSourceRef(1, "local", null), MSourceRef(3, "graph", 2)), s.sources) + val old = MailPimJson.cardSummary(o("""{"id":6,"name":"Alt"}"""))!! + assertNull(old.favorite) + assertEquals(0, old.conflicts) + assertTrue(old.sources.isEmpty()) + // the offline copy keeps the new fields + val back = MailPimJson.cardPage(MailPimCacheLogic.cardsJson(listOf(s, old), 9))!!.items + assertEquals(listOf(s, old), back) + } + + @Test + fun `a card of Mail 1_4 - conflicts in their form, sources, main number, unknown fields and bad values left out`() { + val c = MailPimJson.fullCard( + o( + """{"id":5,"addressbookId":1,"source":"local","name":{"given":"Peter","family":"Muster"},"phones":[{"value":"0170 1","type":"mobile"},{"value":"0171 2","type":"mobile"}], + "favorite":false,"mainPhone":{"value":"0171 2","type":"mobile"},"mainPhoneChosen":true,"masterId":null, + "conflicts":[ + {"id":11,"field":"phones","type":"mobile","value":{"value":"0172 3","type":"mobile"},"replaces":{"value":"0170 1","type":"mobile"},"source":{"id":7,"addressbookId":3,"addressbook":"Kontakte","source":"graph","accountId":2,"account":"ma@outlook.com"},"at":1}, + {"id":12,"field":"company","value":"ACME","replaces":"Muster GmbH","source":{"id":7}}, + {"id":13,"field":"addresses","value":{"street":"Weg 1","postalCode":"1","city":"X"},"replaces":null}, + {"id":14,"field":"name","value":"Pit"}, + {"id":15,"field":"birthday","value":"gestern"} + ], + "sources":[{"id":5,"addressbookId":1,"addressbook":"Meine Kontakte","source":"local"},{"id":7,"addressbookId":3,"addressbook":"Kontakte","source":"graph","accountId":2,"account":"ma@outlook.com"}]}""", + ), + )!! + assertEquals(false, c.favorite) + assertEquals(MCardPhone("0171 2", "mobile"), c.mainPhone) + assertTrue(c.mainPhoneChosen) + assertEquals(listOf(11L, 12L, 13L), c.conflicts.map { it.id }) + assertEquals(MFieldValue.Phone(MCardPhone("0172 3", "mobile")), c.conflicts[0].value) + assertEquals(MFieldValue.Phone(MCardPhone("0170 1", "mobile")), c.conflicts[0].replaces) + assertEquals("ma@outlook.com", c.conflicts[0].source?.account) + assertEquals(MFieldValue.Text("ACME"), c.conflicts[1].value) + assertNull(c.conflicts[2].replaces) + assertEquals(listOf(5L, 7L), c.sources.map { it.id }) + assertTrue(c.sources[0].local) + assertTrue(MailContactLogic.masterModel(c)) + assertFalse(MailContactLogic.masterModel(MailPimJson.fullCard(o("""{"id":1,"name":{"given":"Alt"}}"""))!!)) + } + + @Test + fun `PATCH answer with writeBack, an older server answers the card alone`() { + val saved = MailPimJson.cardSaved(o("""{"card":{"id":5,"name":{"given":"A"}},"writeBack":[{"id":7,"addressbookId":3,"ok":true},{"id":8,"addressbookId":4,"ok":false,"code":"read_only"},{"ok":true}]}"""))!! + assertEquals(5L, saved.card.id) + assertEquals(listOf(MWriteBack(7, 3, true, null), MWriteBack(8, 4, false, "read_only")), saved.writeBack) + assertTrue(MailPimJson.cardSaved(o("""{"card":{"id":5,"name":{"given":"A"}}}"""))!!.writeBack.isEmpty()) + assertNull(MailPimJson.cardSaved(o("""{"ok":true}"""))) + } + + @Test + fun `duplicates with details and preview, merges, favourites, the widget's events`() { + val d = MailPimJson.duplicates( + o( + """{"items":[{"key":"5-7","reason":"phone","certain":false,"cards":[{"id":5,"name":"Schatzimausi"},{"id":7,"name":"Sabrina Martin"}], + "details":[{"card":{"id":5,"name":{"display":"Schatzimausi"},"phones":[{"value":"0171 1","type":"mobile"}]},"source":{"id":5,"addressbookId":1,"addressbook":"Meine Kontakte","source":"local"}}, + {"card":{"id":7,"name":{"given":"Sabrina","family":"Martin"},"emails":[{"value":"s@m.de"}]},"source":{"id":7,"addressbookId":3,"addressbook":"Kontakte","source":"graph","accountId":2,"account":"ma@outlook.com"}}], + "preview":{"id":5,"name":{"display":"Schatzimausi"},"phones":[{"value":"0171 1","type":"mobile"}],"emails":[{"value":"s@m.de"}],"mainPhone":{"value":"0171 1","type":"mobile"},"photoFrom":7, + "sources":[{"id":5,"source":"local"},{"id":7,"source":"graph"}],"conflicts":[{"field":"company","value":"ACME","source":{"id":7,"source":"graph"}}]}}, + {"key":"8-9","reason":"email","cards":[{"id":8},{"id":9}]}]}""", + ), + )!! + assertEquals(false, d[0].certain) + assertEquals(listOf(5L, 7L), d[0].details.map { it.card.id }) + val p = d[0].preview!! + assertEquals(5L, p.id) + assertEquals(7L, p.photoFrom) + assertEquals(listOf("s@m.de"), p.card.emails.map { it.value }) + assertEquals("ACME", p.losers.single().value) + // an older server: no certain flag, no details + assertNull(d[1].certain) + assertTrue(d[1].details.isEmpty()) + + val merges = MailPimJson.merges(o("""{"items":[{"id":1,"masterId":5,"mode":"auto","created":true,"reason":"phone","at":10,"undoneAt":null, + "cards":[{"id":5,"name":"A","addressbookId":1,"addressbook":"Meine Kontakte","source":"local","removed":false}]},{"id":2,"mode":"auto"}]}"""))!! + assertEquals(1, merges.size) + assertTrue(merges[0].automatic && merges[0].created) + assertEquals(MailPimJson.unmerge(o("""{"card":null,"unlinked":[7],"restored":[]}"""))!!.unlinked, listOf(7L)) + + val favs = MailPimJson.favorites(o("""{"items":[{"id":5,"name":"Anna","photo":true,"photoEtag":"e1","phone":{"value":"0171","type":"mobile"},"email":"a@x.de"},{"id":6,"name":"Ben","photo":false,"photoEtag":"x","phone":null}]}"""))!! + assertEquals(MFavorite(5, "Anna", "e1", MCardPhone("0171", "mobile"), "a@x.de"), favs[0]) + assertNull(favs[1].photoEtag) + assertNull(favs[1].phone) + + val u = MailPimJson.upcoming(o("""{"timeZone":"Europe/Berlin","today":"2026-10-31","more":3,"items":[ + {"key":"9:20261031T080000Z","eventId":9,"calendarId":2,"color":"#ff0000","title":"Daily","start":1,"end":2,"allDay":false,"day":"2026-10-31","tentative":true}, + {"key":"birthdays:4:2026","eventId":null,"overlayId":"birthdays","title":"Anna","start":1,"end":2,"allDay":true,"day":"2026-11-01"}, + {"key":"bad"}]}"""))!! + assertEquals(3, u.more) + assertEquals(2, u.items.size) + assertEquals("20261031T080000Z", u.items[0].occurrence) + assertEquals("#FF0000", u.items[0].color) + assertNull(u.items[1].occurrence) + assertNull(MailPimJson.upcoming(o("""{"items":[]}"""))) + } + + @Test + fun `live events of Mail 1_4 - merged with its master, conflicts, favourites`() { + val m = MailPimJson.contactsChanged(o("""{"kind":"merged","masterId":5,"contactIds":[5,7],"auto":true}"""))!! + assertEquals(MContactsChanged.MERGED, m.kind) + assertEquals(5L, m.masterId) + assertEquals(MContactsChanged.FAVORITES, MailPimJson.contactsChanged(o("""{"kind":"favorites"}"""))!!.kind) + assertEquals(listOf(5L), MailPimJson.contactsChanged(o("""{"kind":"conflicts","contactIds":[5]}"""))!!.contactIds) + } + + @Test + fun `request bodies - own fields, accept, unmerge, merge without primaryId`() { + assertEquals("""{"favorite":true}""", MailPimJson.ownBody(favorite = true).toString()) + assertEquals("""{"mainPhone":"0171 2"}""", MailPimJson.ownBody(mainPhone = "0171 2").toString()) + assertEquals("""{"mainPhone":null}""", com.google.gson.GsonBuilder().serializeNulls().create().toJson(MailPimJson.ownBody(clearMainPhone = true))) + assertEquals("""{"mode":"add"}""", MailPimJson.acceptBody("add").toString()) + assertEquals("""{"ids":[7],"keepMaster":true}""", MailPimJson.unmergeBody(listOf(7, 7), keepMaster = true).toString()) + assertEquals("{}", MailPimJson.unmergeBody(null, keepMaster = false).toString()) + assertFalse(MailPimJson.mergeBody(listOf(5, 7), null, null, joinNotes = false).has("primaryId")) + assertEquals(5, MailPimJson.mergeBody(listOf(5, 7), 5, null, joinNotes = true).get("primaryId").asInt) + } + + // ─── the list ─────────────────────────────────────────────────── + + @Test + fun `the list shows GC contacts and loose cards, the dots of every account`() { + val list = listOf(sum(1), sum(2, master = 1), sum(3)) + assertEquals(listOf(1L, 3L), MailContactLogic.listed(list).map { it.id }) + val books = mapOf(1L to null, 3L to 2L, 4L to 9L) + val master = sum(1, sources = listOf(MSourceRef(1, "local", null), MSourceRef(3, "graph", 2), MSourceRef(4, "carddav", null), MSourceRef(5, "graph", 2))) + assertEquals(listOf(2L, 9L), MailContactLogic.sourceAccounts(master) { books[it] }) + // an older server: its own address book's account + assertEquals(listOf(9L), MailContactLogic.sourceAccounts(sum(8, book = 4)) { books[it] }) + assertTrue(MailContactLogic.sourceAccounts(sum(8, book = 1)) { books[it] }.isEmpty()) + } + + // ─── hints ────────────────────────────────────────────────────── + + @Test + fun `hints - what differs, from where, take over replaces, add only where it fits`() { + val mobile = conflict(MCardConflict.PHONES, MFieldValue.Phone(MCardPhone("0171 2", "mobile")), MFieldValue.Phone(MCardPhone("0170 1", "mobile")), "mobile") + assertEquals("mobile", MailContactLogic.hintKind(mobile)) + assertEquals("0171 2", MailContactLogic.valueText(mobile.value)) + assertEquals("0170 1", MailContactLogic.valueText(mobile.replaces)) + assertEquals("Outlook (ma@outlook.com)", MailContactLogic.originLabel(mobile.source)) + assertTrue(MailContactLogic.canAdd(mobile)) + assertEquals("replace", MailContactLogic.acceptMode(mobile, add = false)) + assertEquals("add", MailContactLogic.acceptMode(mobile, add = true)) + + val work = conflict(MCardConflict.PHONES, MFieldValue.Phone(MCardPhone("030 1", "work")), null) + assertEquals("phone", MailContactLogic.hintKind(work)) + assertFalse(MailContactLogic.canAdd(work)) + assertEquals("add", MailContactLogic.acceptMode(work, add = false)) + + val company = conflict(MCardConflict.COMPANY, MFieldValue.Text("ACME"), MFieldValue.Text("Muster GmbH")) + assertEquals("company", MailContactLogic.hintKind(company)) + assertFalse(MailContactLogic.canAdd(company)) + assertEquals("replace", MailContactLogic.acceptMode(company, add = false)) + + val addr = MFieldValue.Address(MCardAddress(street = "Weg 1", postalCode = "12345", city = "Ort", country = "")) + assertEquals("Weg 1, 12345 Ort", MailContactLogic.valueText(addr)) + assertEquals("iCloud (me@icloud.com)", MailContactLogic.originLabel(MSource(1, 1, "iCloud", MAddressbook.SOURCE_CARDDAV, 4, "me@icloud.com"))) + assertEquals("Meine Kontakte", MailContactLogic.originLabel(MSource(1, 1, "Meine Kontakte", MAddressbook.SOURCE_LOCAL, null, null))) + assertEquals("", MailContactLogic.originLabel(null)) + } + + // ─── the preview ──────────────────────────────────────────────── + + @Test + fun `preview of an older server - the GC card keeps its name, the lists are joined, what only one card has`() { + val gc = MCard(id = 1, source = "local", name = MCardName(given = "Sabrina"), phones = listOf(MCardPhone("+49 171 1111111", "mobile"))) + val acc = MCard( + id = 2, source = "graph", name = MCardName(given = "Sabrina", family = "Martin"), phones = listOf(MCardPhone("0171 1111111", "mobile"), MCardPhone("030 22", "work")), + emails = listOf(MCardEmail("s@m.de")), addresses = listOf(MCardAddress(street = "Weg 1", city = "Ort")), + ) + val p = MailContactLogic.localPreview(listOf(acc, gc))!! + assertEquals("Sabrina", p.name.given) + assertEquals("", p.name.family) + assertEquals(listOf("+49 171 1111111", "030 22"), p.phones.map { it.value }) + assertEquals(listOf("s@m.de"), p.emails.map { it.value }) + assertEquals(1, p.addresses.size) + assertNull(MailContactLogic.localPreview(listOf(gc))) + + val own = MailContactLogic.ownValues(acc, listOf(gc)) + assertTrue("p:" + MailPimLogic.phoneKey("030 22") in own) + assertFalse("p:" + MailPimLogic.phoneKey("0171 1111111") in own) + assertTrue("e:s@m.de" in own) + assertTrue("a:" + MailContactLogic.addressKey(acc.addresses[0]) in own) + assertTrue(MailContactLogic.ownValues(gc, listOf(acc)).isEmpty()) + } + + // ─── favourites, the main number ──────────────────────────────── + + @Test + fun `favourites - on at the end, off out, at most 50, moving`() { + assertEquals(listOf(1L, 2L, 3L), MailContactLogic.toggled(listOf(1, 2), 3, on = true)) + assertEquals(listOf(1L, 2L), MailContactLogic.toggled(listOf(1, 2), 2, on = true)) + assertEquals(listOf(1L), MailContactLogic.toggled(listOf(1, 2), 2, on = false)) + assertNull(MailContactLogic.toggled((1L..50L).toList(), 51, on = true)) + assertEquals(listOf(2L, 1L, 3L), MailContactLogic.moved(listOf(1, 2, 3), 2, -1)) + assertEquals(listOf(1L, 2L, 3L), MailContactLogic.moved(listOf(1, 2, 3), 3, 1)) + } + + @Test + fun `main number - chosen by its digits, else the first mobile, else the first`() { + val phones = listOf(MCardPhone("030 1234567", "work"), MCardPhone("0171 9876543", "mobile"), MCardPhone("0172 5555555", "mobile")) + assertEquals("0171 9876543", MailContactLogic.mainPhoneOf(phones, null)?.value) + assertEquals("0172 5555555", MailContactLogic.mainPhoneOf(phones, "+49 172 5555555")?.value) + assertEquals("0171 9876543", MailContactLogic.mainPhoneOf(phones, "0999 0")?.value) + assertEquals("030 1", MailContactLogic.mainPhoneOf(listOf(MCardPhone("030 1", "work")), null)?.value) + assertNull(MailContactLogic.mainPhoneOf(emptyList(), null)) + + val c = MCard(id = 1, phones = phones, favorite = false, mainPhone = MCardPhone("+49 172 5555555", "mobile")) + assertTrue(MailContactLogic.isMain(c, phones[2])) + assertFalse(MailContactLogic.isMain(c, phones[1])) + assertTrue(MailContactLogic.canChooseMain(c, phones[0])) + assertFalse(MailContactLogic.canChooseMain(c, phones[2])) + // an older server: no main number to choose, the rule decides + val old = c.copy(favorite = null, mainPhone = null) + assertTrue(MailContactLogic.isMain(old, phones[1])) + assertFalse(MailContactLogic.canChooseMain(old, phones[0])) + assertFalse(MailContactLogic.canChooseMain(c.copy(phones = listOf(phones[0])), phones[0])) + } + + // ─── Start ────────────────────────────────────────────────────── + + @Test + fun `tiles - follow the favourites, leave when no favourite, a tap calls or opens`() { + val pins = MailStartPins(listOf(StartContactPin(5, "Anna alt", null, "0170"), StartContactPin(6, "Ben", null, null)), agenda = true) + val synced = MailContactLogic.syncedPins(pins, listOf(MFavorite(5, "Anna", "e", MCardPhone("0171 2", "mobile"), null), MFavorite(9, "Neu", null, null, null))) + assertEquals(listOf(StartContactPin(5, "Anna", "e", "0171 2")), synced.contacts) + assertTrue(synced.agenda) + assertEquals(TileTap.Call("tel:01712"), MailContactLogic.tap(synced.contacts[0])) + assertEquals(TileTap.Open(6), MailContactLogic.tap(pins.contacts[1])) + assertEquals(TileTap.Call("tel:+4917112"), MailContactLogic.tap(StartContactPin(1, "x", null, "+49 171 12"))) + + val more = MailContactLogic.withPin(pins, StartContactPin(7, "C"), on = true) + assertEquals(listOf(5L, 6L, 7L), more.extras.contacts) + assertEquals(listOf(6L), MailContactLogic.withPin(pins, StartContactPin(5, ""), on = false).extras.contacts) + assertEquals("Anna neu", MailContactLogic.withPin(pins, StartContactPin(5, "Anna neu"), on = true).contacts[0].name) + val full = MailStartPins((1L..12L).map { StartContactPin(it, "$it") }) + assertEquals(12, MailContactLogic.withPin(full, StartContactPin(13, "x"), on = true).contacts.size) + } + + @Test + fun `calling - straight away only with the permission, else the dialer`() { + assertEquals(MailContactLogic.ACTION_DIAL, MailContactLogic.callAction(callPermission = false)) + assertEquals(MailContactLogic.ACTION_CALL, MailContactLogic.callAction(callPermission = true)) + assertEquals("android.intent.action.DIAL", MailContactLogic.ACTION_DIAL) + assertEquals("android.intent.action.CALL", MailContactLogic.ACTION_CALL) + } + + @Test + fun `pins are kept without reflection, broken entries left out`() { + val pins = MailStartPins(listOf(StartContactPin(5, "Anna", "e1", "0171"), StartContactPin(6, "Ben")), agenda = true) + assertEquals(pins, MailContactLogic.pinsOf(MailContactLogic.pinsJson(pins))) + val odd = MailContactLogic.pinsOf(o("""{"agenda":"x","contacts":[{"id":"7","name":"C"},{"id":-1},{"name":"x"},3,{"id":7,"name":"dup"}]}""")) + assertEquals(listOf(StartContactPin(7, "C")), odd.contacts) + assertFalse(odd.agenda) + assertEquals(MailStartPins(), MailContactLogic.pinsOf(null)) + } + + @Test + fun `the widget - today and tomorrow, over ones gone, all-day first, an old copy shows what is left`() { + val now = ms(2026, 10, 31, 12) + fun item(key: String, start: Long, end: Long, day: String, allDay: Boolean = false) = + MUpcomingItem(key, 1, 1, null, null, key, start, end, allDay, day, null, false) + val u = MUpcoming( + "Europe/Berlin", "2026-10-31", + listOf( + item("over", ms(2026, 10, 31, 9), ms(2026, 10, 31, 10), "2026-10-31"), + item("running", ms(2026, 10, 31, 11), ms(2026, 10, 31, 13), "2026-10-31"), + item("later", ms(2026, 10, 31, 15), ms(2026, 10, 31, 16), "2026-10-31"), + item("holiday", ms(2026, 10, 31, 0), ms(2026, 11, 1, 0), "2026-10-31", allDay = true), + item("tomorrow", ms(2026, 11, 1, 9), ms(2026, 11, 1, 10), "2026-11-01"), + item("far", ms(2026, 11, 2, 9), ms(2026, 11, 2, 10), "2026-11-02"), + ), + more = 2, + ) + val v = MailContactLogic.agenda(u, now, berlin) + assertEquals(listOf(LocalDate.of(2026, 10, 31), LocalDate.of(2026, 11, 1)), v.days.map { it.day }) + assertTrue(v.days[0].today) + assertEquals(listOf("holiday", "running", "later"), v.days[0].items.map { it.key }) + assertEquals(listOf("tomorrow"), v.days[1].items.map { it.key }) + assertEquals(2, v.more) + + // offline, the next morning: yesterday's are over, tomorrow's became today's + val next = MailContactLogic.agenda(u, ms(2026, 11, 1, 8), berlin) + assertEquals(listOf(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 2)), next.days.map { it.day }) + assertTrue(next.days[0].today) + assertEquals(listOf("tomorrow"), next.days[0].items.map { it.key }) + assertTrue(MailContactLogic.stale(u, ms(2026, 11, 1, 8), berlin)) + assertFalse(MailContactLogic.stale(u, now, berlin)) + + assertTrue(MailContactLogic.agenda(u.copy(items = emptyList(), more = 0), now, berlin).empty) + assertEquals(ms(2026, 11, 1, 0), MailContactLogic.nextDay(now, berlin)) + } + + @Test + fun `written back - by provider name, each once, the failed ones apart`() { + val books = mapOf( + 3L to MAddressbook(3, "graph", 2, "Kontakte", true, true, false, 0, "ok", null, null), + 4L to MAddressbook(4, "carddav", 5, "iCloud", false, true, false, 0, "ok", null, null), + ) + val (ok, failed) = MailContactLogic.writeBack( + listOf(MWriteBack(7, 3, true, null), MWriteBack(8, 3, true, null), MWriteBack(9, 4, false, "read_only"), MWriteBack(10, 99, true, null)), + ) { books[it] } + assertEquals(listOf("Outlook"), ok) + assertEquals(listOf("iCloud"), failed) + assertNotNull(MailContactLogic.writeBack(emptyList()) { null }) + } +}