Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Map<String?, List<StartEntry>>> =
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. */
Expand All @@ -41,6 +45,9 @@ class StartArrangement @Inject constructor(

val access: StateFlow<LayoutAccess> = tabLayout.access

/** What Mail pins to Start on this device. */
val mailPins: StateFlow<com.gatecontrol.android.mail.MailStartPins> = 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).
Expand Down Expand Up @@ -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). */
Expand Down
46 changes: 43 additions & 3 deletions app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> = emptyList(), val agenda: Boolean = false) {
companion object {
val NONE = StartExtras()
}
}

/**
Expand All @@ -40,19 +57,26 @@ 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<StartEntry>): List<String> = entries.map(::encode)

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) }
Expand All @@ -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<String>, tiles: List<String>, widgets: List<StartEntry.Widget>, cards: Boolean = true): List<StartEntry> {
fun merge(
saved: List<String>,
tiles: List<String>,
widgets: List<StartEntry.Widget>,
cards: Boolean = true,
extras: StartExtras = StartExtras.NONE,
): List<StartEntry> {
val widgetByDevice = widgets.associateBy { it.device }
val tileSet = tiles.toSet()
val contactSet = extras.contacts.toSet()
val withCards = cards
val orderedTiles = ArrayList<StartEntry>()
val blocks = ArrayList<StartEntry>()
Expand All @@ -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
Expand All @@ -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)) }
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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
}
16 changes: 11 additions & 5 deletions app/src/main/java/com/gatecontrol/android/layout/StartPages.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, tiles: List<String>, widgets: List<StartEntry.Widget>): Map<String?, List<StartEntry>> =
assign(saved, tiles, widgets, target = null)
fun merge(saved: List<String>, tiles: List<String>, widgets: List<StartEntry.Widget>, extras: StartExtras = StartExtras.NONE): Map<String?, List<StartEntry>> =
assign(saved, tiles, widgets, target = null, extras)

/**
* The start list after pins changed (pinned, unpinned, another width):
Expand All @@ -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<String>, tiles: List<String>, widgets: List<StartEntry.Widget>, target: String?): List<String> {
fun syncPins(saved: List<String>, tiles: List<String>, widgets: List<StartEntry.Widget>, target: String?, extras: StartExtras = StartExtras.NONE): List<String> {
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()),
Expand All @@ -199,7 +199,7 @@ object StartPages {
)
}

private fun assign(saved: List<String>, tiles: List<String>, widgets: List<StartEntry.Widget>, target: String?): Map<String?, List<StartEntry>> {
private fun assign(saved: List<String>, tiles: List<String>, widgets: List<StartEntry.Widget>, target: String?, extras: StartExtras): Map<String?, List<StartEntry>> {
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).
Expand All @@ -214,17 +214,23 @@ 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<String?, List<StartEntry>>()
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(
page.entries,
tiles.filter { pageOf(StartLayout.TILE + it) == page.id },
widgets.filter { pageOf(it.key) == page.id },
cards = false,
extras = extrasOf(page.id),
)
}
return result
Expand Down
47 changes: 43 additions & 4 deletions app/src/main/java/com/gatecontrol/android/mail/MailApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -425,10 +425,47 @@ class MailApi @Inject constructor(

suspend fun createCard(body: JsonObject): MResult<MCard> = post("cards", body, parse = MailPimJson::cardAnswer)

/** `PATCH cards/<id>`: `409 conflict { current }` ([MailPimJson.conflictCard]), `409 read_only`. */
suspend fun patchCard(id: Long, body: JsonObject): MResult<MCard> = patchNulls("cards/$id", body, MailPimJson::cardAnswer)
/**
* `PATCH cards/<id>`: `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<MCardSaved> = patchNulls("cards/$id", body, MailPimJson::cardSaved)

/** `DELETE cards/<id>`; [linkedToo] (Mail 1.4.0, `linked=delete`): the linked cards go at their provider too. */
suspend fun deleteCard(id: Long, linkedToo: Boolean = false): MResult<Unit> =
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<Long>): MResult<MPreview> = post("cards/merge/preview", MailPimJson.idsBody(ids), parse = MailPimJson::previewAnswer)

suspend fun merges(limit: Int = 50): MResult<List<MMerge>> = get("cards/merges", mapOf("limit" to limit.coerceIn(1, 200).toString()), MailPimJson::merges)

suspend fun undoMerge(id: Long, keepMaster: Boolean): MResult<MUnmerge> =
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<Long>?, keepMaster: Boolean = false): MResult<MUnmerge> =
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<MCard> =
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<MCard> =
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<MCardSaved> = patchNulls("cards/$id", body, MailPimJson::cardSaved)

suspend fun favorites(): MResult<Pair<List<MFavorite>, JsonObject>> = get("cards/favorites") { o -> MailPimJson.favorites(o)?.let { it to o } }

suspend fun putFavorites(ids: List<Long>): MResult<List<MFavorite>> = put("cards/favorites", MailPimJson.idsBody(ids), MailPimJson::favorites)

suspend fun deleteCard(id: Long): MResult<Unit> = 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<Pair<MUpcoming, JsonObject>> =
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<MPhoto> = get("cards/$id/photo", parse = MailPimJson::photo)

Expand All @@ -446,7 +483,9 @@ class MailApi @Inject constructor(
put("email", email.trim().take(320))
}, MailPimJson::cardDraft)

suspend fun duplicates(): MResult<List<MDuplicate>> = 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<List<MDuplicate>> =
get("cards/duplicates", if (details) mapOf("details" to "1") else emptyMap(), MailPimJson::duplicates)

suspend fun merge(body: JsonObject): MResult<MCard> = post("cards/merge", body, timeoutMs = 60_000L, parse = MailPimJson::cardAnswer)

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/gatecontrol/android/mail/MailCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<MFavorite>? = 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 ->
Expand Down
Loading
Loading