From 2d992b36a3ed92157b33dfc6207a6bba2efc8a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:42:02 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(drive):=20=C3=9Cbersicht=20|=20Dateien?= =?UTF-8?q?=20|=20Fotos=20=E2=80=93=20neue=20=C3=9Cbersicht,=20Fotos=20nur?= =?UTF-8?q?=20noch=20Bilder=20(Drive=201.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drive-Tab mit drei Seiten (Übersicht | Dateien | Fotos), Drive öffnet immer auf der Übersicht; die gewählte Seite wird nicht mehr gespeichert. - Übersicht nach Mockup: Suche über alles, „Fotos gesichert · vor N Min.“ (aus dem Sicherungsstatus) und Speicher, Zu erledigen (Fristen, Fotos prüfen, Mail-Anhänge zum Ablegen), Zuletzt geöffnet, Erinnerungen („Heute vor 2 Jahren“), Personen mit „Wer ist das?“, Orte, Neu in Familie. Leere Abschnitte erscheinen nicht. - GET overview (Feature `overview`); ohne das Feature baut die App die Übersicht aus deadlines, photos/memories, people, photos/places und photos/classes. Änderungen aus dem Änderungsprotokoll laden sie gedrosselt (höchstens alle 15 s) neu. - Kleine Orte-Karte ohne MapView im Lazy-Item: Die Liste zeigt das Standbild (PhotoMapStills); gezeichnet wird es einmal außerhalb der Liste in fester Größe, OsmFitter zoomt nie bei 0 × 0. - Fotos: Personenleiste, Orte-Karte, Erinnerungen, Suchfeld und Hinweisband „N zu prüfen“ fallen weg (der Zähler steht am Chip „Prüfen“); oben Karte, Personen und Suche als Symbole. - Tests: Abschnitte ein und aus, Aufbau ohne overview, Labels, Tabs; Robolectric: Scrollen der Übersicht hängt nicht, kein Zoom bei 0 × 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/drive/DriveApi.kt | 3 + .../gatecontrol/android/drive/DriveLogic.kt | 26 +- .../gatecontrol/android/drive/DriveModels.kt | 2 + .../android/drive/DriveOverview.kt | 349 +++++++++++ .../android/navigation/AppNavigation.kt | 3 + .../android/ui/drive/DriveComponents.kt | 8 - .../android/ui/drive/DriveHomeScreen.kt | 113 ++-- .../android/ui/drive/DriveOverviewUi.kt | 588 ++++++++++++++++++ .../ui/drive/DriveOverviewViewModel.kt | 156 +++++ .../android/ui/drive/DrivePhotosUi.kt | 132 +--- .../android/ui/drive/DrivePhotosViewModels.kt | 7 +- .../android/ui/drive/DrivePlacesUi.kt | 126 +--- .../android/ui/drive/DrivePlacesViewModels.kt | 51 -- .../main/res/values-de/strings_drive12.xml | 54 ++ app/src/main/res/values/strings_drive12.xml | 54 ++ .../android/drive/DriveLogicTabsTest.kt | 42 +- .../android/drive/DriveOverviewTest.kt | 234 +++++++ .../ui/drive/DriveOverviewScrollTest.kt | 165 +++++ .../ui/drive/DrivePhotosSelectionJumpTest.kt | 18 +- .../android/ui/drive/PhotoMapPrefetchTest.kt | 42 +- 20 files changed, 1755 insertions(+), 418 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewUi.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewViewModel.kt create mode 100644 app/src/main/res/values-de/strings_drive12.xml create mode 100644 app/src/main/res/values/strings_drive12.xml create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/ui/drive/DriveOverviewScrollTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt index c894a16a..658a9985 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -438,6 +438,9 @@ class DriveApi @Inject constructor( suspend fun insightApply(id: Long, name: Boolean, folder: Boolean): DResult = post("nodes/$id/insight/apply", DriveJson.applyBody(name, folder), DriveJson::insightApplied) + /** Drive 1.18 (`overview`): everything the overview shows, in one call. */ + suspend fun overview(): DResult = get("overview", parse = DriveOverviewJson::overview) + /** The deadlines of the files I read, [from]..[to] (`YYYY-MM-DD`; default today to a year on). */ suspend fun deadlines(from: String? = null, to: String? = null): DResult> { val q = buildMap { diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt index ae6b0b19..ac9aa496 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt @@ -308,22 +308,26 @@ internal object DriveLogic { /** The doc kind of a file in a list (a small symbol): only when the AI is done. */ fun docKind(node: DNode): String? = node.insight?.takeIf { it.status == "ready" && !node.isDir }?.kind - // ─── the Files and Photos tabs ─────────────────────────────────── + // ─── the pages of the Drive tab ────────────────────────────────── - /** The Drive tab's pages: the files, then (feature `photos`) the photos; swiping goes from one to the other. */ - const val TAB_FILES = 0 - const val TAB_PHOTOS = 1 + /** + * The Drive tab's pages (Drive 1.18): the overview, the files, then (feature `photos`) the photos. Drive always + * opens on the overview (the page is not kept); a folder or a photo opened from elsewhere has a page of its own. + */ + enum class Tab { OVERVIEW, FILES, PHOTOS } + + fun tabs(hasPhotos: Boolean): List = if (hasPhotos) listOf(Tab.OVERVIEW, Tab.FILES, Tab.PHOTOS) else listOf(Tab.OVERVIEW, Tab.FILES) - fun tabCount(hasPhotos: Boolean): Int = if (hasPhotos) 2 else 1 + fun tabCount(hasPhotos: Boolean): Int = tabs(hasPhotos).size - /** The page to show for the saved choice "photos" ([photos]); without the Photos tab always the files. */ - fun tabOf(photos: Boolean, hasPhotos: Boolean): Int = if (photos && hasPhotos) TAB_PHOTOS else TAB_FILES + /** The page of [tab]; the photos without the Photos tab: the files. */ + fun pageOf(tab: Tab, hasPhotos: Boolean): Int = tabs(hasPhotos).indexOf(tab).takeIf { it >= 0 } ?: tabs(hasPhotos).indexOf(Tab.FILES) - /** Whether [page] is the photos (what is saved as the tab's choice). */ - fun isPhotosTab(page: Int, hasPhotos: Boolean): Boolean = hasPhotos && page == TAB_PHOTOS + /** The tab at [page] (a page beyond the last: the last). */ + fun tabAt(page: Int, hasPhotos: Boolean): Tab = tabs(hasPhotos).let { it[page.coerceIn(0, it.size - 1)] } - /** Swiping between the pages: only when there are two, not while photos are ticked (that bar belongs to the page). */ - fun tabSwipe(hasPhotos: Boolean, selecting: Boolean): Boolean = hasPhotos && !selecting + /** Swiping between the pages: not while items are ticked (that bar belongs to the page). */ + fun tabSwipe(selecting: Boolean): Boolean = !selecting // ─── sorting a folder ─────────────────────────────────────────── diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt index 2aac7411..fbf99d9d 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -118,6 +118,8 @@ data class DMe( const val FEATURE_PHOTO_PEOPLE = "photoPeople" /** Drive 1.17: deadlines into a calendar of the mail plugin too (`deadlines/calendars`, `deadlines/targets`). */ const val FEATURE_DEADLINE_MAIL_CALENDAR = "deadlineMailCalendar" + /** Drive 1.18: the overview of Drive in one call (`GET overview`); without it the app builds it from the rest. */ + const val FEATURE_OVERVIEW = "overview" } } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt new file mode 100644 index 00000000..52e59701 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt @@ -0,0 +1,349 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.drive.DriveJson.arr +import com.gatecontrol.android.drive.DriveJson.int +import com.gatecontrol.android.drive.DriveJson.long +import com.gatecontrol.android.drive.DriveJson.obj +import com.gatecontrol.android.drive.DriveJson.str +import com.google.gson.JsonObject +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +// ─── Drive 1.18: the overview (feature `overview`, `GET overview`) ─────────── + +/** + * What the overview of Drive shows (Drive 1.18, mockup "Übersicht"): what is to be done, the files opened last, the + * memories, people and places of the photos, what others added in shared areas, the storage. A part is null or empty + * when there is nothing (or its feature is off). [assembled]: built from the older endpoints (a server without + * `overview`); "Zuletzt geöffnet" and "Neu in Familie" are then missing. + */ +data class DOverview( + val todo: DTodo? = null, + val recent: List = emptyList(), + val memories: List = emptyList(), + val people: DOverviewPeople? = null, + val places: DOverviewPlaces? = null, + val activity: List = emptyList(), + val storage: DOverviewStorage? = null, + val assembled: Boolean = false, +) + +/** + * "Zu erledigen": the next deadlines (at most three of [deadlinesTotal]), the photos to review, the mail attachments + * "Zum Ablegen" ([toFile] null: no mail service) with the folder suggested for the first. + */ +data class DTodo(val deadlines: List, val deadlinesTotal: Int, val review: Int, val toFile: Int?, val toFileHint: String?) + +/** A file of "Zuletzt geöffnet": when the person opened it ([openedAt]) or, without that trace, changed it. */ +data class DRecent(val node: DNode, val openedAt: String?, val changedAt: String?, val areaLabel: String?) + +/** A memory group (this day [yearsAgo] years ago): its [cover] and how many photos; [items] when they came along. */ +data class DMemoryCard(val yearsAgo: Int, val date: String?, val cover: DNode, val count: Int, val items: List = emptyList()) + +/** The named people (at most eight) and how many are still without a name; [firstUnnamed] when one came along. */ +data class DOverviewPeople(val people: List, val unnamed: Int, val firstUnnamed: Long? = null) + +/** A place of the top three (coordinates when the server sends them, the fallback always has them). */ +data class DPlaceTop(val key: String, val name: String, val count: Int, val lat: Double? = null, val lon: Double? = null) + +/** "Orte": the top places, one bundle for the small map, how many photos have a place. */ +data class DOverviewPlaces(val top: List, val cluster: DCluster?, val total: Int) + +/** + * "Neu in Familie": what another person ([userName]) added or changed in a shared area, grouped (a person, a folder, + * about an hour). [kind] `added_photos` | `added_files` | `changed`. + */ +data class DActivity( + val at: String?, + val userId: Int?, + val userName: String?, + val kind: String, + val count: Int, + val areaId: String?, + val areaLabel: String?, + val folderId: Long?, + val folderPath: String?, + val sample: DNode?, +) + +/** How much storage the person (or the area) takes, of how much, and where it lies. */ +data class DOverviewStorage(val used: Long, val total: Long?, val target: String?) + +/** Pure parser of `GET overview` (bounded like every Drive parser; a part of the wrong shape is left out). */ +internal object DriveOverviewJson { + + private const val DEADLINES_MAX = 3 + private const val RECENT_MAX = 10 + private const val MEMORIES_MAX = 4 + private const val PEOPLE_MAX = 8 + private const val PLACES_MAX = 3 + private const val ACTIVITY_MAX = 5 + private val KINDS = setOf(DriveOverviewLogic.ADDED_PHOTOS, DriveOverviewLogic.ADDED_FILES, DriveOverviewLogic.CHANGED) + + /** `GET overview` → the overview (at the top or as `overview`); null when it is not an object. */ + fun overview(o: JsonObject): DOverview? { + val src = o.obj("overview") ?: o + return DOverview( + todo = todo(src.obj("todo")), + recent = src.arr("recent")?.mapNotNull { recent(it as? JsonObject) }?.take(RECENT_MAX).orEmpty(), + memories = src.arr("memories")?.mapNotNull { memory(it as? JsonObject) }?.take(MEMORIES_MAX).orEmpty(), + people = people(src), + places = places(src.obj("places")), + activity = src.arr("activity")?.mapNotNull { activity(it as? JsonObject) }?.take(ACTIVITY_MAX).orEmpty(), + storage = storage(src.obj("storage")), + ) + } + + private fun todo(o: JsonObject?): DTodo? { + if (o == null) return null + val deadlines = o.arr("deadlines")?.mapNotNull { DriveJson.deadline(it as? JsonObject) }.orEmpty() + .filter { it.node?.encrypted != true }.take(DEADLINES_MAX) + return DTodo( + deadlines = deadlines, + deadlinesTotal = (o.int("deadlinesTotal") ?: deadlines.size).coerceAtLeast(deadlines.size), + review = o.int("review")?.coerceAtLeast(0) ?: 0, + toFile = o.int("toFile")?.coerceAtLeast(0), + toFileHint = o.str("toFileHint")?.trim()?.take(200)?.ifEmpty { null }, + ) + } + + private fun recent(o: JsonObject?): DRecent? { + val node = DriveJson.node(o) ?: return null + if (node.isDir || node.encrypted || DriveOverviewLogic.isMedia(node)) return null + return DRecent(node, o!!.str("openedAt")?.take(40), o.str("changedAt")?.take(40), o.str("areaLabel")?.trim()?.take(100)?.ifEmpty { null }) + } + + private fun memory(o: JsonObject?): DMemoryCard? { + if (o == null) return null + val items = o.arr("items")?.mapNotNull { DriveJson.node(it as? JsonObject) }.orEmpty().filter { !it.encrypted } + val cover = DriveJson.node(o.obj("cover"))?.takeIf { !it.encrypted } ?: items.firstOrNull() ?: return null + return DMemoryCard( + yearsAgo = o.int("yearsAgo")?.coerceAtLeast(0) ?: 0, + date = o.str("date")?.take(40), + cover = cover, + count = (o.int("count") ?: items.size).coerceAtLeast(1), + items = items.take(30), + ) + } + + /** `people: [...]` with `unnamed` beside it, or `people: { people|items: [...], unnamed }`; null when missing. */ + private fun people(src: JsonObject): DOverviewPeople? { + val e = src.get("people") ?: return null + val (list, unnamedCount) = when { + e.isJsonArray -> e.asJsonArray to (src.int("unnamed") ?: src.int("peopleUnnamed")) + e.isJsonObject -> e.asJsonObject.let { p -> (p.arr("people") ?: p.arr("items") ?: p.arr("named")) to p.int("unnamed") } + else -> return null + } + val all = list?.mapNotNull { DrivePlacesJson.person(it as? JsonObject) }.orEmpty() + val named = all.filter { it.named && !it.hidden }.take(PEOPLE_MAX) + val firstUnnamed = all.firstOrNull { !it.named && !it.hidden }?.id + val unnamed = (unnamedCount ?: all.count { !it.named && !it.hidden }).coerceAtLeast(0) + return DOverviewPeople(named, unnamed, firstUnnamed) + } + + private fun places(o: JsonObject?): DOverviewPlaces? { + if (o == null) return null + val top = o.arr("top")?.mapNotNull { e -> + val p = e as? JsonObject ?: return@mapNotNull null + DPlaceTop( + key = DrivePlacesJson.placeKey(p.str("key")) ?: return@mapNotNull null, + name = p.str("name")?.trim()?.take(DrivePlacesJson.NAME_MAX)?.ifEmpty { null } ?: return@mapNotNull null, + count = p.int("count")?.coerceAtLeast(0) ?: 0, + lat = DrivePlacesJson.lat(p.num("lat")), + lon = DrivePlacesJson.lon(p.num("lon")), + ) + }?.take(PLACES_MAX).orEmpty() + val cluster = o.obj("cluster")?.let { c -> + val lat = DrivePlacesJson.lat(c.num("lat")) ?: return@let null + val lon = DrivePlacesJson.lon(c.num("lon")) ?: return@let null + DCluster(lat, lon, c.int("count")?.coerceAtLeast(0) ?: 0, null, null) + } + val total = (o.int("total") ?: top.sumOf { it.count }).coerceAtLeast(0) + return DOverviewPlaces(top, cluster, total) + } + + private fun activity(o: JsonObject?): DActivity? { + if (o == null) return null + val user = o.obj("user") + val area = o.obj("area") + val folder = o.obj("folder") + return DActivity( + at = o.str("at")?.take(40), + userId = user?.int("id"), + userName = user?.str("name")?.trim()?.take(100)?.ifEmpty { null }, + kind = o.str("kind")?.takeIf { it in KINDS } ?: return null, + count = o.int("count")?.coerceAtLeast(1) ?: 1, + areaId = DriveJson.areaId(area?.str("id")), + areaLabel = area?.str("label")?.trim()?.take(100)?.ifEmpty { null }, + folderId = folder?.long("id"), + folderPath = folder?.str("path")?.trim()?.take(500)?.ifEmpty { null }, + sample = DriveJson.node(o.obj("sample"))?.takeIf { !it.encrypted }, + ) + } + + private fun storage(o: JsonObject?): DOverviewStorage? { + if (o == null) return null + return DOverviewStorage( + used = o.long("used")?.coerceAtLeast(0) ?: return null, + total = o.long("total")?.takeIf { it > 0 }, + target = o.str("target")?.trim()?.take(100)?.ifEmpty { null }, + ) + } + + private fun JsonObject.num(key: String): Double? = + get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asDouble }.getOrNull() }?.takeIf { it.isFinite() } +} + +/** Pure part of the overview (unit-tested): which sections show, the fallback, the labels. */ +internal object DriveOverviewLogic { + + const val ADDED_PHOTOS = "added_photos" + const val ADDED_FILES = "added_files" + const val CHANGED = "changed" + + /** The sections in the mockup's order. */ + enum class Section { SEARCH, STATUS, TODO, RECENT, MEMORIES, PEOPLE, PLACES, ACTIVITY } + + /** The line "Fotos gesichert · vor N Min." (only with the photo backup on). */ + sealed class Backup { + data class Running(val done: Int, val total: Int) : Backup() + data class Saved(val at: Long) : Backup() + data object Waiting : Backup() + /** Stopped by [code] (no permission, Drive full …): the backup's page tells more. */ + data class Stopped(val code: String) : Backup() + } + + /** The photo backup's state as a line; null while it is off. */ + fun backup(enabled: Boolean, running: Boolean, done: Int, total: Int, lastRunAt: Long, error: String?): Backup? = when { + !enabled -> null + running -> Backup.Running(done, total) + error != null -> Backup.Stopped(error) + lastRunAt > 0 -> Backup.Saved(lastRunAt) + else -> Backup.Waiting + } + + fun hasTodo(t: DTodo?): Boolean = t != null && (t.deadlines.isNotEmpty() || t.review > 0 || (t.toFile ?: 0) > 0) + + /** The number beside "Zu erledigen": the deadlines, then one each for the review and "Zum Ablegen". */ + fun todoCount(t: DTodo?): Int { + if (t == null) return 0 + return t.deadlinesTotal + (if (t.review > 0) 1 else 0) + (if ((t.toFile ?: 0) > 0) 1 else 0) + } + + fun hasPeople(p: DOverviewPeople?): Boolean = p != null && (p.people.isNotEmpty() || p.unnamed > 0) + + fun hasPlaces(p: DOverviewPlaces?): Boolean = p != null && p.top.isNotEmpty() + + /** The sections shown, in order; an empty one is left out (the search always shows). */ + fun sections(o: DOverview?, backup: Backup?): List
= buildList { + add(Section.SEARCH) + if (o == null) return@buildList + if (backup != null || o.storage != null) add(Section.STATUS) + if (hasTodo(o.todo)) add(Section.TODO) + if (o.recent.isNotEmpty()) add(Section.RECENT) + if (o.memories.isNotEmpty()) add(Section.MEMORIES) + if (hasPeople(o.people)) add(Section.PEOPLE) + if (hasPlaces(o.places)) add(Section.PLACES) + if (o.activity.isNotEmpty()) add(Section.ACTIVITY) + } + + /** "Heute vor 2 Jahren" ([today]: the cover is of exactly this calendar day) or "Vor 2 Jahren" (a day beside it). */ + data class MemoryLabel(val years: Int, val today: Boolean) + + fun memoryLabel(card: DMemoryCard, today: LocalDate, zone: ZoneId): MemoryLabel { + val day = DrivePlacesLogic.dayOf(card.cover, zone) + ?: card.date?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() } + val same = day != null && day.monthValue == today.monthValue && day.dayOfMonth == today.dayOfMonth + val years = card.yearsAgo.takeIf { it > 0 } ?: day?.let { today.year - it.year }?.takeIf { it > 0 } ?: 0 + return MemoryLabel(years, same) + } + + /** Photos and videos are the Photos page's, not "Zuletzt geöffnet". */ + fun isMedia(node: DNode): Boolean = DriveLogic.kind(node).let { it == DriveLogic.Kind.IMAGE || it == DriveLogic.Kind.VIDEO } + + /** + * The overview of a server without `overview` (Drive ≤ 1.17), from what it has: the deadlines from today, the + * memories, the people, the places, the review count, the storage of "Meine Dateien". A missing part stays out. + */ + fun assemble( + today: LocalDate, + deadlines: List?, + classes: DPhotoClasses?, + memories: List?, + people: DPeople?, + places: List?, + storage: DOverviewStorage?, + ): DOverview { + val next = deadlines.orEmpty() + .filter { it.node?.encrypted != true && (DriveLogic.daysUntil(it.date, today) ?: -1) >= 0 } + .sortedBy { it.date } + val review = classes?.review ?: 0 + val todo = if (deadlines != null || classes != null) DTodo(next.take(3), next.size, review, null, null) else null + val cards = memories.orEmpty().filter { it.items.isNotEmpty() }.take(4) + .map { DMemoryCard(it.yearsAgo, it.date, it.items.first(), it.items.size, it.items) } + val overviewPeople = people?.let { p -> + val g = DrivePlacesLogic.groups(p.people) + DOverviewPeople(g.named.take(8), g.unnamed.size, g.unnamed.firstOrNull()?.id) + } + val overviewPlaces = places?.takeIf { it.isNotEmpty() }?.let { list -> + val sorted = list.sortedByDescending { it.count } + val first = sorted.first() + val total = list.sumOf { it.count } + DOverviewPlaces( + top = sorted.take(3).map { DPlaceTop(it.key, it.name, it.count, it.lat, it.lon) }, + cluster = DCluster(first.lat, first.lon, total, null, null), + total = total, + ) + } + return DOverview( + todo = todo, + memories = cards, + people = overviewPeople, + places = overviewPlaces, + storage = storage, + assembled = true, + ) + } + + /** The storage of "Meine Dateien" from the areas (what the storage page shows for the person). */ + fun storageOf(areas: List): DOverviewStorage? { + val mine = areas.firstOrNull { it.kind == "mine" }?.storage ?: return null + return DOverviewStorage(mine.bytes ?: return null, mine.quota?.takeIf { it > 0 }, mine.target) + } + + /** How full, 0…1 (null without a limit). */ + fun fill(s: DOverviewStorage): Float? = s.total?.takeIf { it > 0 }?.let { (s.used.toDouble() / it).coerceIn(0.0, 1.0).toFloat() } + + /** + * The box of the small map: around the top places when they have coordinates, else a region around the bundle + * (a single point would be a street). + */ + fun mapBox(p: DOverviewPlaces): DBbox? { + val points = p.top.mapNotNull { t -> t.lat?.let { lat -> t.lon?.let { lon -> lat to lon } } } + if (points.size >= 2) return DrivePlacesLogic.around(points, margin = 0.3) + val c = p.cluster ?: points.firstOrNull()?.let { DCluster(it.first, it.second, p.total, null, null) } ?: return null + return DBbox( + (c.lon - REGION_LON).coerceAtLeast(-180.0), + (c.lat - REGION_LAT).coerceAtLeast(-85.0), + (c.lon + REGION_LON).coerceAtMost(180.0), + (c.lat + REGION_LAT).coerceAtMost(85.0), + ) + } + + /** The bubble on the small map: the server's bundle, else all photos at the first place. */ + fun mapCluster(p: DOverviewPlaces): DCluster? = p.cluster?.let { if (it.count <= 0) it.copy(count = p.total) else it } + ?: p.top.firstOrNull { it.lat != null && it.lon != null }?.let { DCluster(it.lat!!, it.lon!!, p.total, null, null) } + + private const val REGION_LAT = 2.5 + private const val REGION_LON = 4.0 + + /** When an event of "Neu in Familie" happened, as epoch ms (null when not a time). */ + fun millis(iso: String?): Long? = iso?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } + + /** The last folder name of a path ("Familie/Urlaub 2026" → "Urlaub 2026"). */ + fun folderName(path: String?): String? = path?.trim('/')?.substringAfterLast('/')?.ifEmpty { null } + + /** Live events reload the overview at most this often. */ + const val LIVE_THROTTLE_MS = 15_000L +} 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 adb9e018..740077ae 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1893,6 +1893,9 @@ private fun DriveHome(navController: NavHostController, onShown: (Long?) -> Unit vault = nav.vault, settings = { nav.go(Screen.DriveSettings.route) }, cleanup = { nav.go(Screen.DriveCleanup.route) }, + backup = { nav.go(Screen.DriveBackup.route) }, + // Mail › "Zum Ablegen" (only where Mail's routes are there) + mailFiling = { runCatching { navController.navigate(Screen.MailFiling.route) } }, ), ) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt index a5e3e8fe..91c03431 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt @@ -577,7 +577,6 @@ internal fun UploadsSheet( internal object DriveViewPref { private const val PREFS = "drive_ui" private const val KEY = "grid" - private const val KEY_PHOTOS = "photos" fun grid(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY, false) @@ -585,13 +584,6 @@ internal object DriveViewPref { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY, grid).apply() } - /** The Drive tab shows the photos (Drive 1.7), not the files. */ - fun photos(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_PHOTOS, false) - - fun setPhotos(context: Context, photos: Boolean) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_PHOTOS, photos).apply() - } - private const val KEY_CAMERA = "photos_camera" /** The Photos tab shows only the camera uploads (Drive 1.10, feature `photosFolder`). */ diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt index 2ed648c6..d6882bb8 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt @@ -115,11 +115,15 @@ data class DriveNav( val cleanup: () -> Unit = {}, /** The folder the Files page shows now (null: none, or the photos) — a folder page's path goes back to it. */ val shown: (Long?) -> Unit = {}, + /** The photo backup (the overview's "Fotos gesichert", Drive 1.18). */ + val backup: () -> Unit = {}, + /** Mail › "Zum Ablegen" (the overview's attachments to file); null: no mail here. */ + val mailFiling: (() -> Unit)? = null, ) /** - * The Drive tab: the chosen area's files, the areas and the trash in the menu top left. The - * photo backup is in the settings. + * The Drive tab (Drive 1.18): "Übersicht" | "Dateien" | "Fotos", always opening on the overview; the areas and the + * trash in the menu top left. The photo backup is in the settings. */ @Composable fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel()) { @@ -131,6 +135,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel } val folderViewModel: DriveFolderViewModel = hiltViewModel(key = "drive-tab") val photosViewModel: DrivePhotosViewModel = hiltViewModel(key = "drive-photos") + val overviewViewModel: DriveOverviewViewModel = hiltViewModel(key = "drive-overview") val context = LocalContext.current val scope = rememberCoroutineScope() val drawer = rememberDrawerState(DrawerValue.Closed) @@ -168,37 +173,29 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel } } val openMenu: () -> Unit = { scope.launch { drawer.open() } } - // Drive 1.7 (feature `photos`): the tab has two pages, the files and the photos; a swipe goes from one to the other. + // Drive 1.18: Übersicht | Dateien | Fotos (feature `photos`); a swipe goes from one to the next. Drive opens on the + // overview: the page is not kept (only while the tab is on the back stack, e.g. back from a folder) val hasPhotos = me?.has(DMe.FEATURE_PHOTOS) == true + val tabs = DriveLogic.tabs(hasPhotos) val ticked by photosViewModel.selected.collectAsStateWithLifecycle() val folderTicked by folderViewModel.bulk.selected.collectAsStateWithLifecycle() /** Items are ticked on one of the pages: its own bar shows instead of the tab's, swiping stays off. */ val selecting = ticked.isNotEmpty() || folderTicked.isNotEmpty() - val pager = rememberPagerState(initialPage = DriveLogic.tabOf(DriveViewPref.photos(context), hasPhotos)) { DriveLogic.tabCount(hasPhotos) } - val onPhotosPage = DriveLogic.isPhotosTab(pager.currentPage, hasPhotos) - val showPage: (Boolean) -> Unit = { photos -> scope.launch { pager.animateScrollToPage(DriveLogic.tabOf(photos, hasPhotos)) } } + val pager = rememberPagerState(initialPage = DriveLogic.pageOf(DriveLogic.Tab.OVERVIEW, hasPhotos)) { DriveLogic.tabCount(hasPhotos) } + val tab = DriveLogic.tabAt(pager.currentPage, hasPhotos) + val onPhotosPage = tab == DriveLogic.Tab.PHOTOS + val onFilesPage = tab == DriveLogic.Tab.FILES + val showTab: (DriveLogic.Tab) -> Unit = { t -> scope.launch { pager.animateScrollToPage(DriveLogic.pageOf(t, hasPhotos)) } } // the folder on the Files page: the path of a folder page goes back here when it names this one - val shownId = shown?.id?.takeIf { !onPhotosPage } + val shownId = shown?.id?.takeIf { onFilesPage } // Drive 1.14 (`photoSearch`): photos without words get them now (in the background, under the backup's conditions) val photoSearch = me?.has(DMe.FEATURE_PHOTO_SEARCH) == true LaunchedEffect(onPhotosPage, photoSearch) { if (onPhotosPage && photoSearch) com.gatecontrol.android.drive.backup.DrivePhotoLabelsWork.request(context) } LaunchedEffect(shownId) { nav.shown(shownId) } - // the page the person ends on is kept, and shown again when the person comes back to the tab - var synced by remember { mutableStateOf(false) } - LaunchedEffect(hasPhotos) { - if (!hasPhotos) return@LaunchedEffect - val want = DriveLogic.tabOf(DriveViewPref.photos(context), true) - if (pager.currentPage != want) pager.scrollToPage(want) - synced = true - } - LaunchedEffect(pager.settledPage, synced) { if (synced && hasPhotos) DriveViewPref.setPhotos(context, DriveLogic.isPhotosTab(pager.settledPage, true)) } - LifecycleResumeEffect(pager, hasPhotos, synced) { - val want = DriveLogic.tabOf(DriveViewPref.photos(context), hasPhotos) - if (synced && pager.currentPage != want && !pager.isScrollInProgress) scope.launch { pager.scrollToPage(want) } - onPauseOrDispose { } - } + /** "Wer ist das?" of the overview: the person being named. */ + var naming by remember { mutableStateOf(null) } val vaults by viewModel.vaults.collectAsStateWithLifecycle() val expanded by viewModel.expanded.collectAsStateWithLifecycle() val children by viewModel.children.collectAsStateWithLifecycle() @@ -210,7 +207,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel } val grid = rememberDriveGrid() // a folder below the area's top (opened from the menu): back goes up a level - BackHandler(enabled = !drawer.isOpen && !selecting && !onPhotosPage && shown != null && shown.parentId != null && shown.areaId == current?.id) { + BackHandler(enabled = !drawer.isOpen && !selecting && onFilesPage && shown != null && shown.parentId != null && shown.areaId == current?.id) { shown?.parentId?.let(folderViewModel::open) } BackHandler(enabled = drawer.isOpen) { scope.launch { drawer.close() } } @@ -232,7 +229,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel areas = areas, personal = personal, shared = shared, - selected = shown?.id?.takeIf { !onPhotosPage }, + selected = shown?.id?.takeIf { onFilesPage }, onToggle = viewModel::toggle, onOpen = { row -> val area = areas.firstOrNull { it.id == row.areaId } @@ -244,7 +241,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel folderViewModel.open(row.id) } } - if (onPhotosPage) showPage(false) + if (!onFilesPage) showTab(DriveLogic.Tab.FILES) close() }, // Drive 1.9 (`vault`): the vaults are opened from here only (Drive 1.11) @@ -285,40 +282,79 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel bar = { TabBar( when { + tab == DriveLogic.Tab.OVERVIEW -> stringResource(R.string.drive_title) onPhotosPage -> stringResource(R.string.drive_mode_photos) shown != null && shown.parentId != null && shown.areaId == current.id -> shown.name else -> current.label }, onMenu = openMenu, - onSearch = if (onPhotosPage && DriveLogic.photoSearchable(me)) nav.photoSearch else nav.search, + // the overview has its own search field + onSearch = when { + tab == DriveLogic.Tab.OVERVIEW -> null + onPhotosPage && DriveLogic.photoSearchable(me) -> nav.photoSearch + else -> nav.search + }, actions = { - if (!onPhotosPage) { - FolderViewActions(folderViewModel, grid) - } else if (me?.has(DMe.FEATURE_PHOTO_PLACES) == true) { - // Drive 1.16: the photos on the map - GcIconButton(GcIcons.Map, contentDescription = stringResource(R.string.drive_pp_map), onClick = nav.photoMap, tint = MaterialTheme.colorScheme.onBackground) + when (tab) { + DriveLogic.Tab.OVERVIEW -> + GcIconButton(GcIcons.Settings, contentDescription = stringResource(R.string.drive_settings_title), onClick = nav.settings, tint = MaterialTheme.colorScheme.onBackground) + DriveLogic.Tab.FILES -> FolderViewActions(folderViewModel, grid) + DriveLogic.Tab.PHOTOS -> { + // Drive 1.16: the photos on the map, the people (Drive 1.18: the symbols of the page's head) + if (me?.has(DMe.FEATURE_PHOTO_PLACES) == true) { + GcIconButton(GcIcons.Map, contentDescription = stringResource(R.string.drive_pp_map), onClick = nav.photoMap, tint = MaterialTheme.colorScheme.onBackground) + } + if (me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true) { + GcIconButton(GcIcons.Family, contentDescription = stringResource(R.string.drive_pp_people), onClick = nav.people, tint = MaterialTheme.colorScheme.onBackground) + } + } } }, ) }, ) - if (hasPhotos) Box(Modifier.inert(selecting)) { DriveModeSwitch(photos = onPhotosPage, onPhotos = showPage) } + Box(Modifier.inert(selecting)) { DriveModeSwitch(tabs = tabs, tab = tab, onTab = showTab) } HorizontalPager( state = pager, modifier = Modifier.weight(1f), // ticked items keep their page (the selection's bars belong to it) // As a page beside Start, swiping changes the page: Dateien and Fotos by tapping only. - userScrollEnabled = DriveLogic.tabSwipe(hasPhotos, selecting) && !com.gatecontrol.android.ui.start.LocalInStartPager.current, - key = { it }, + userScrollEnabled = DriveLogic.tabSwipe(selecting) && !com.gatecontrol.android.ui.start.LocalInStartPager.current, + key = { DriveLogic.tabAt(it, hasPhotos) }, ) { page -> - if (DriveLogic.isPhotosTab(page, hasPhotos)) { + val pageTab = DriveLogic.tabAt(page, hasPhotos) + if (pageTab == DriveLogic.Tab.OVERVIEW) { + DriveOverviewContent( + viewModel = overviewViewModel, + areas = home.areas, + naming = { naming = it }, + actions = OverviewActions( + search = nav.search, + backup = nav.backup, + storage = if (me?.has(DMe.FEATURE_STORAGE) == true) nav.storage else null, + deadline = { d -> d.node?.id?.let(nav.file) ?: nav.deadlines() }, + deadlines = nav.deadlines, + review = { photosViewModel.setClass("review"); showTab(DriveLogic.Tab.PHOTOS) }, + toFile = nav.mailFiling, + file = nav.file, + folder = nav.folder, + files = { showTab(DriveLogic.Tab.FILES) }, + photos = { showTab(DriveLogic.Tab.PHOTOS) }, + memory = {}, + people = nav.people, + person = nav.person, + who = {}, + map = nav.photoMap, + place = nav.place, + ), + ) + } else if (pageTab == DriveLogic.Tab.PHOTOS) { DrivePhotosContent( viewModel = photosViewModel, topBar = {}, onAlbum = nav.album, onCleanup = nav.cleanup, onFile = nav.file, - places = PhotosPlacesNav(search = nav.photoSearch, map = nav.photoMap, place = nav.place, people = nav.people, person = nav.person), ownHead = false, ) } else { @@ -375,6 +411,13 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel } } } + naming?.let { id -> + NamePersonSheet( + personId = id, + onDismiss = { naming = null; overviewViewModel.load() }, + onNext = { overviewViewModel.nextToName(id) { next -> naming = next; if (next == null) overviewViewModel.load() } }, + ) + } guardAsk?.let { (incident, undo) -> GuardConfirm( incident = incident, diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewUi.kt new file mode 100644 index 00000000..45a55ca8 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewUi.kt @@ -0,0 +1,588 @@ +package com.gatecontrol.android.ui.drive + +import android.text.format.DateUtils +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DActivity +import com.gatecontrol.android.drive.DArea +import com.gatecontrol.android.drive.DDeadline +import com.gatecontrol.android.drive.DMemoryCard +import com.gatecontrol.android.drive.DOverview +import com.gatecontrol.android.drive.DOverviewPeople +import com.gatecontrol.android.drive.DOverviewPlaces +import com.gatecontrol.android.drive.DOverviewStorage +import com.gatecontrol.android.drive.DRecent +import com.gatecontrol.android.drive.DTodo +import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.DriveOverviewLogic +import com.gatecontrol.android.drive.DriveOverviewLogic.Section +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcIconSquare +import com.gatecontrol.android.ui.familie.OsmAttribution +import com.gatecontrol.android.ui.theme.GateControlTheme +import java.time.LocalDate +import java.time.ZoneId + +/** Where the overview's rows lead (Drive 1.18); null: not offered here. */ +internal data class OverviewActions( + val search: () -> Unit, + val backup: () -> Unit, + val storage: (() -> Unit)?, + val deadline: (DDeadline) -> Unit, + val deadlines: () -> Unit, + val review: () -> Unit, + val toFile: (() -> Unit)?, + val file: (Long) -> Unit, + val folder: (Long) -> Unit, + val files: () -> Unit, + val photos: () -> Unit, + val memory: (DMemoryCard) -> Unit, + val people: () -> Unit, + val person: (Long) -> Unit, + val who: () -> Unit, + val map: () -> Unit, + val place: (String) -> Unit, +) + +/** The small map of "Orte": a still picture this big (the mockup's 120 × 84, wide enough for the attribution). */ +private val MAP_W = 168.dp +private val MAP_H = 100.dp + +/** + * The overview page of the Drive tab (Drive 1.18, mockup "Übersicht"): the search over everything, the backup and + * the storage, then "Zu erledigen", "Zuletzt geöffnet", "Erinnerungen", "Personen", "Orte" and "Neu in …" — each only + * when it has something. + */ +@Composable +internal fun DriveOverviewContent(viewModel: DriveOverviewViewModel, areas: List, actions: OverviewActions, naming: (Long) -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + val viewing by viewModel.viewing.collectAsStateWithLifecycle() + val config by viewModel.backup.config.collectAsStateWithLifecycle() + val stats by viewModel.backup.stats.collectAsStateWithLifecycle() + val status by viewModel.backup.status.collectAsStateWithLifecycle() + LifecycleResumeEffect(viewModel) { + viewModel.start() + viewModel.load() + onPauseOrDispose { } + } + val backup = DriveOverviewLogic.backup(config.enabled, status.running, status.done, status.total, stats.lastRunAt, stats.error) + when (val s = state) { + DriveLoad.Loading -> DriveLoading() + is DriveLoad.Error -> DriveErrorBox(s.code, onRetry = viewModel::load) + is DriveLoad.Ready -> OverviewPage( + overview = s.value, + backup = backup, + areas = areas, + actions = actions.copy(memory = viewModel::openMemory, who = { viewModel.whoIsThat(naming) }), + ) + } + viewing?.let { items -> + PhotoViewer(items = items, start = 0, fetch = viewModel::fetch, onMore = null, onClose = viewModel::closeMemory) + } +} + +/** + * The overview itself (no view model: tested as it is). The small map of "Orte" is never a map in the list: the list + * shows its still picture ([PhotoMapStills]); until there is one, a map of the same size draws it behind the list. + */ +@Composable +internal fun OverviewPage( + overview: DOverview, + backup: DriveOverviewLogic.Backup?, + areas: List, + actions: OverviewActions, + listState: LazyListState = rememberLazyListState(), +) { + val sections = DriveOverviewLogic.sections(overview, backup) + val places = overview.places?.takeIf { Section.PLACES in sections } + val stillKey = places?.let { overviewMapKey(it) } + Box(Modifier.fillMaxSize()) { + if (places != null && stillKey != null && PhotoMapStills[stillKey] == null) OverviewMapRenderer(places, stillKey) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).testTag(OVERVIEW_LIST_TAG), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + sections.forEach { section -> + when (section) { + Section.SEARCH -> item(key = "search", contentType = "search") { OverviewSearch(actions.search) } + Section.STATUS -> item(key = "status", contentType = "status") { StatusRow(backup, overview.storage, actions) } + Section.TODO -> item(key = "todo", contentType = "todo") { TodoSection(overview.todo!!, areas, actions) } + Section.RECENT -> item(key = "recent", contentType = "recent") { RecentSection(overview.recent, areas, actions) } + Section.MEMORIES -> item(key = "memories", contentType = "memories") { MemoriesSection(overview.memories, actions) } + Section.PEOPLE -> item(key = "people", contentType = "people") { PeopleSection(overview.people!!, actions) } + Section.PLACES -> item(key = "places", contentType = "places") { PlacesSection(overview.places!!, stillKey, actions) } + Section.ACTIVITY -> item(key = "activity", contentType = "activity") { ActivitySection(overview.activity, actions) } + } + } + if (sections.size == 1) { + item(key = "empty") { + Text( + stringResource(R.string.drive_ov_empty), + style = MaterialTheme.typography.bodyMedium, + color = GateControlTheme.extraColors.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) + } + } + } + } +} + +internal const val OVERVIEW_LIST_TAG = "drive-overview" + +/** What the still picture of the small map shows (places, bubble, size, colours): the key of [PhotoMapStills]. */ +@Composable +private fun overviewMapKey(p: DOverviewPlaces): String? { + val box = DriveOverviewLogic.mapBox(p) ?: return null + val c = DriveOverviewLogic.mapCluster(p) + val density = LocalDensity.current + val w = with(density) { MAP_W.roundToPx() } + val h = with(density) { MAP_H.roundToPx() } + val scheme = MaterialTheme.colorScheme + return "ov|$box|${c?.lat},${c?.lon},${c?.count}|${w}x$h|${scheme.primary.toArgb()},${scheme.surface.toArgb()},${scheme.background.toArgb()}" +} + +/** + * Draws the small map once, outside the list and as big as its picture (never 0 × 0), behind the list's background: + * once its tiles are all there it becomes the still picture, and then it goes. + */ +@Composable +private fun OverviewMapRenderer(p: DOverviewPlaces, key: String) { + Box(Modifier.size(MAP_W, MAP_H).clearAndSetSemantics { }) { + PhotoMap( + clusters = listOfNotNull(DriveOverviewLogic.mapCluster(p)), + selected = null, + fit = DriveOverviewLogic.mapBox(p), + interactive = false, + onCluster = {}, + onMoved = { _, _ -> }, + contentDescription = "", + modifier = Modifier.size(MAP_W, MAP_H), + stillKey = key, + onStill = PhotoMapStills::put, + ) + } +} + +// ─── the sections ───────────────────────────────────────────────── + +/** A heading of the overview with a number or a link on the right ("Alle", "Karte"). */ +@Composable +private fun OverviewHead(title: String, count: Int? = null, link: String? = null, onLink: (() -> Unit)? = null) { + val x = GateControlTheme.extraColors + Row( + Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 4.dp).heightIn(min = 44.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).semantics { heading() }) + if (count != null) Text(count.toString(), style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(end = 12.dp)) + if (link != null && onLink != null) { + TextButton(onClick = onLink, modifier = Modifier.heightIn(min = 44.dp)) { + Text(link, color = x.accentText, fontWeight = FontWeight.SemiBold) + } + } + } +} + +/** "Dateien, Fotos, Personen, Orte": a tap opens the search. */ +@Composable +private fun OverviewSearch(onClick: () -> Unit) { + val x = GateControlTheme.extraColors + Row( + Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).fillMaxWidth().heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(x.panel2) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.drive_ov_search), onClick = onClick) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Search, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.drive_ov_search_hint), color = x.muted, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** "Fotos gesichert · vor 4 Min." and the storage with its bar, side by side. */ +@Composable +private fun StatusRow(backup: DriveOverviewLogic.Backup?, storage: DOverviewStorage?, actions: OverviewActions) { + val x = GateControlTheme.extraColors + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (backup != null) { + val ok = backup is DriveOverviewLogic.Backup.Saved || backup is DriveOverviewLogic.Backup.Running + Row( + Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)).background(if (ok) x.accentBg else x.warnBg) + .clickable(role = Role.Button, onClick = actions.backup).padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(if (ok) GcIcons.Check else GcIcons.Upload, contentDescription = null, tint = if (ok) x.accentText else x.warn, modifier = Modifier.size(16.dp)) + Text(backupText(backup), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (storage != null) { + val used = DriveLogic.size(storage.used) + val text = storage.total?.let { stringResource(R.string.drive_ov_storage, used, DriveLogic.size(it)) } ?: stringResource(R.string.drive_ov_storage_used, used) + val desc = stringResource(R.string.drive_ov_storage_desc, listOfNotNull(text, storage.target).joinToString(" · ")) + Column( + Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)).background(x.panel2) + .then(if (actions.storage != null) Modifier.clickable(role = Role.Button, onClick = actions.storage) else Modifier) + .semantics(mergeDescendants = true) { contentDescription = desc } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + ) { + Text(text, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + DriveOverviewLogic.fill(storage)?.let { f -> + LinearProgressIndicator( + progress = { f }, + color = x.blue, + trackColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)), + ) + } + } + } + } +} + +@Composable +private fun backupText(b: DriveOverviewLogic.Backup): String = when (b) { + is DriveOverviewLogic.Backup.Running -> + if (b.total > 0) stringResource(R.string.drive_ov_backup_running, b.done, b.total) else stringResource(R.string.drive_ov_backup_running_start) + is DriveOverviewLogic.Backup.Saved -> stringResource( + R.string.drive_ov_backup_saved, + DateUtils.getRelativeTimeSpanString(b.at, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE).toString(), + ) + DriveOverviewLogic.Backup.Waiting -> stringResource(R.string.drive_ov_backup_waiting) + is DriveOverviewLogic.Backup.Stopped -> stringResource(R.string.drive_ov_backup_stopped) +} + +/** "Zu erledigen": the next deadlines, the photos to review, the mail attachments to file. */ +@Composable +private fun TodoSection(t: DTodo, areas: List, actions: OverviewActions) { + val x = GateControlTheme.extraColors + val today = remember { LocalDate.now() } + OverviewHead(stringResource(R.string.drive_ov_todo), count = DriveOverviewLogic.todoCount(t)) + OverviewCard { + val rows = buildList<@Composable (Boolean) -> Unit> { + t.deadlines.forEach { d -> + add { last -> + val file = d.node?.let { nodeTitle(it) } + val area = d.node?.areaId?.let { id -> areas.firstOrNull { it.id == id }?.label } + TodoRow( + icon = { Icon(GcIcons.Calendar, contentDescription = null, tint = x.warn, modifier = Modifier.size(18.dp)) }, + tint = x.warnBg, + title = d.label ?: file ?: stringResource(deadlineLabel(d.kind)), + line = listOfNotNull(deadlineWhen(DriveLogic.daysUntil(d.date, today)), area).joinToString(" · "), + last = last, + onClick = { actions.deadline(d) }, + ) + } + } + if (t.deadlinesTotal > t.deadlines.size) { + val more = t.deadlinesTotal - t.deadlines.size + add { last -> + TodoRow( + icon = { Icon(GcIcons.Calendar, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) }, + tint = x.panel2, + title = pluralStringResource(R.plurals.drive_ov_more_deadlines, more, more), + line = null, + last = last, + onClick = actions.deadlines, + ) + } + } + if (t.review > 0) { + add { last -> + TodoRow( + icon = { Text("?", color = x.warn, fontWeight = FontWeight.Bold, fontSize = 16.sp) }, + tint = x.warnBg, + title = pluralStringResource(R.plurals.drive_ov_review, t.review, t.review), + line = stringResource(R.string.drive_ov_review_hint), + last = last, + onClick = actions.review, + ) + } + } + val toFile = t.toFile ?: 0 + if (toFile > 0 && actions.toFile != null) { + add { last -> + TodoRow( + icon = { Icon(GcIcons.Mail, contentDescription = null, tint = x.blue, modifier = Modifier.size(18.dp)) }, + tint = x.blueBg, + title = pluralStringResource(R.plurals.drive_ov_to_file, toFile, toFile), + line = t.toFileHint?.let { stringResource(R.string.drive_ov_to_file_hint, it) }, + last = last, + onClick = actions.toFile, + ) + } + } + } + rows.forEachIndexed { i, row -> row(i == rows.lastIndex) } + } +} + +@Composable +private fun OverviewCard(content: @Composable () -> Unit) { + Column(Modifier.padding(horizontal = 16.dp).fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(MaterialTheme.colorScheme.surface)) { content() } +} + +@Composable +private fun TodoRow(icon: @Composable () -> Unit, tint: Color, title: String, line: String?, last: Boolean, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + Column { + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GcIconSquare(size = 36.dp, background = tint) { icon() } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (!line.isNullOrEmpty()) Text(line, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (!last) Box(Modifier.fillMaxWidth().height(1.dp).background(x.border)) + } +} + +/** "Zuletzt geöffnet": the files as small cards side by side. */ +@Composable +private fun RecentSection(recent: List, areas: List, actions: OverviewActions) { + val x = GateControlTheme.extraColors + OverviewHead(stringResource(R.string.drive_ov_recent), link = stringResource(R.string.drive_pp_all), onLink = actions.files) + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(recent, key = { it.node.id }) { r -> + val at = DriveOverviewLogic.millis(r.openedAt ?: r.changedAt ?: r.node.mtime) + val ago = at?.let { DateUtils.getRelativeTimeSpanString(it, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE).toString() } + val area = r.areaLabel ?: areas.firstOrNull { it.id == r.node.areaId }?.label + Column( + Modifier.width(150.dp).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surface) + .clickable(role = Role.Button) { actions.file(r.node.id) }.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + NodeIcon(r.node, size = 40) + Text(nodeTitle(r.node), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.heightIn(min = 36.dp)) + Text(listOfNotNull(ago, area).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +/** "Erinnerungen": "Heute vor 2 Jahren", "Vor einem Jahr" with the cover photo; a tap shows the photos. */ +@Composable +private fun MemoriesSection(memories: List, actions: OverviewActions) { + OverviewHead(stringResource(R.string.drive_ov_memories), link = stringResource(R.string.drive_pp_all), onLink = actions.photos) + val today = remember { LocalDate.now() } + val zone = remember { ZoneId.systemDefault() } + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(memories, key = { "${it.yearsAgo}-${it.cover.id}" }) { m -> + val picture = rememberDrivePreview(m.cover, large = true) ?: rememberDrivePreview(m.cover) + val label = DriveOverviewLogic.memoryLabel(m, today, zone) + val title = when { + label.years <= 0 -> stringResource(R.string.drive_photos_memories) + label.today -> pluralStringResource(R.plurals.drive_ov_memory_today, label.years, label.years) + else -> pluralStringResource(R.plurals.drive_photos_years_ago, label.years, label.years) + } + val line = listOfNotNull(pluralStringResource(R.plurals.drive_photos_count, m.count, m.count), m.cover.geo?.place?.name).joinToString(" · ") + Box( + Modifier.width(200.dp).height(130.dp).clip(RoundedCornerShape(16.dp)).background(GateControlTheme.extraColors.blueBg) + .clickable(role = Role.Button) { actions.memory(m) }, + ) { + if (picture != null) Image(picture, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.28f))) + Column(Modifier.align(Alignment.BottomStart).padding(horizontal = 12.dp, vertical = 10.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(line, style = MaterialTheme.typography.bodySmall, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +/** "Personen": the named faces, then "Wer ist das?" while someone has no name. */ +@Composable +private fun PeopleSection(p: DOverviewPeople, actions: OverviewActions) { + val x = GateControlTheme.extraColors + OverviewHead(stringResource(R.string.drive_pp_people), link = stringResource(R.string.drive_pp_all), onLink = actions.people) + LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + items(p.people, key = { it.id }) { person -> + Column( + Modifier.width(68.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button) { actions.person(person.id) }.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + PersonFace(person, 52.dp) + Text(person.name.orEmpty(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (p.unnamed > 0) { + item(key = "who") { + Column( + Modifier.width(76.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = actions.who).padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + WhoCircle(52.dp) + Text(stringResource(R.string.drive_pp_who), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = x.warn, maxLines = 1) + } + } + } + } +} + +/** + * "Orte": the small map as its still picture (a tap opens the map) and the places with most photos. Until the + * picture is there the item shows the bubble on an empty map; no map is ever made in here. + */ +@Composable +private fun PlacesSection(p: DOverviewPlaces, stillKey: String?, actions: OverviewActions) { + val x = GateControlTheme.extraColors + OverviewHead(stringResource(R.string.drive_pp_places), link = stringResource(R.string.drive_pp_map), onLink = actions.map) + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + val desc = stringResource(R.string.drive_pp_map_open) + Box( + Modifier.size(MAP_W, MAP_H).clip(RoundedCornerShape(14.dp)).background(x.panel2) + .clickable(role = Role.Button, onClickLabel = desc, onClick = actions.map).semantics { contentDescription = desc }, + ) { + val still = stillKey?.let { PhotoMapStills[it] } + if (still != null) { + Image(still, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + OsmAttribution(Modifier.align(Alignment.BottomEnd)) + } else { + val total = DriveOverviewLogic.mapCluster(p)?.count ?: p.total + Box( + Modifier.align(Alignment.Center).heightIn(min = 28.dp).clip(RoundedCornerShape(99.dp)).background(MaterialTheme.colorScheme.primary).padding(horizontal = 10.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { Text(countText(total), color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) } + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + p.top.forEach { place -> + Box( + Modifier.heightIn(min = 36.dp).clip(RoundedCornerShape(99.dp)).border(1.dp, x.border2, RoundedCornerShape(99.dp)) + .clickable(role = Role.Button) { actions.place(place.key) }.padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.CenterStart, + ) { Text("${place.name} · ${countText(place.count)}", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } + } +} + +/** "Neu in Familie": what the others added lately, a row per person, folder and hour. */ +@Composable +private fun ActivitySection(activity: List, actions: OverviewActions) { + val x = GateControlTheme.extraColors + val area = activity.mapNotNull { it.areaLabel }.distinct().singleOrNull() + OverviewHead( + if (area != null) stringResource(R.string.drive_ov_activity_in, area) else stringResource(R.string.drive_ov_activity_shared), + link = stringResource(R.string.drive_pp_all), + onLink = actions.files, + ) + OverviewCard { + activity.forEachIndexed { i, a -> + val open: (() -> Unit)? = when { + a.folderId != null -> ({ actions.folder(a.folderId) }) + a.sample != null -> ({ actions.file(a.sample.id) }) + else -> null + } + Column { + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).then(if (open != null) Modifier.clickable(role = Role.Button, onClick = open) else Modifier) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val name = a.userName ?: stringResource(R.string.drive_ov_someone) + Box(Modifier.size(36.dp).clip(CircleShape).background(personColor((a.userId ?: 0).toLong())), contentAlignment = Alignment.Center) { + Text(DrivePlacesLogic.initials(name), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = 13.sp, maxLines = 1) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(boldName(activityText(a, name), name), style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + val where = listOfNotNull(a.areaLabel, DriveOverviewLogic.folderName(a.folderPath)).distinct().joinToString(" › ").ifEmpty { null } + val ago = DriveOverviewLogic.millis(a.at)?.let { + DateUtils.getRelativeTimeSpanString(it, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE).toString() + } + val line = listOfNotNull(where, ago).joinToString(" · ") + if (line.isNotEmpty()) Text(line, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + a.sample?.let { NodeIcon(it, size = 40) } + } + if (i < activity.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(x.border)) + } + } + } +} + +/** "Mama hat 14 Fotos hinzugefügt", "Papa hat „Zeugnis Lea.pdf“ abgelegt", "Tim hat 3 Dateien geändert". */ +@Composable +private fun activityText(a: DActivity, name: String): String { + val one = a.sample?.takeIf { a.count == 1 }?.let { nodeTitle(it) } + return when (a.kind) { + DriveOverviewLogic.ADDED_PHOTOS -> pluralStringResource(R.plurals.drive_ov_act_photos, a.count, name, a.count) + DriveOverviewLogic.ADDED_FILES -> + if (one != null) stringResource(R.string.drive_ov_act_file, name, one) else pluralStringResource(R.plurals.drive_ov_act_files, a.count, name, a.count) + else -> + if (one != null) stringResource(R.string.drive_ov_act_changed_one, name, one) else pluralStringResource(R.plurals.drive_ov_act_changed, a.count, name, a.count) + } +} + +/** [text] with [name] in bold (where it stands, in any language). */ +private fun boldName(text: String, name: String): AnnotatedString = buildAnnotatedString { + append(text) + val at = text.indexOf(name) + if (at >= 0 && name.isNotEmpty()) addStyle(SpanStyle(fontWeight = FontWeight.Bold), at, at + name.length) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewViewModel.kt new file mode 100644 index 00000000..d1020d92 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOverviewViewModel.kt @@ -0,0 +1,156 @@ +package com.gatecontrol.android.ui.drive + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DMemoryCard +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DOverview +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DriveOverviewLogic +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.drive.DrivePlacesPrefs +import com.gatecontrol.android.drive.DriveTransfers +import com.gatecontrol.android.drive.backup.DriveBackup +import com.gatecontrol.android.drive.faces.DrivePeopleWork +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.launch +import java.io.File +import java.time.LocalDate +import javax.inject.Inject + +/** + * The overview of Drive (Drive 1.18): `GET overview` in one call, or — on a server without `overview` — built from + * the deadlines, the memories, the people, the places and the review count. Changes of the change log reload it, + * at most every [DriveOverviewLogic.LIVE_THROTTLE_MS]. + */ +@HiltViewModel +class DriveOverviewViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + val live: DriveLive, + val backup: DriveBackup, + private val transfers: DriveTransfers, + private val placesPrefs: DrivePlacesPrefs, +) : ViewModel() { + + private val _state = MutableStateFlow>(DriveLoad.Loading) + val state: StateFlow> = _state.asStateFlow() + + /** The photos of a memory shown full screen (null: none). */ + private val _viewing = MutableStateFlow?>(null) + val viewing: StateFlow?> = _viewing.asStateFlow() + + private var job: Job? = null + private var started = false + + /** Loads once and from then on follows the change log (throttled). */ + fun start() { + if (started) return + started = true + load() + viewModelScope.launch { + live.changed.conflate().collect { + load() + delay(DriveOverviewLogic.LIVE_THROTTLE_MS) + } + } + } + + /** Again (on resume, a change, "Erneut versuchen"); what shows stays until the new answer is there. */ + fun load() { + job?.cancel() + if (_state.value !is DriveLoad.Ready) _state.value = DriveLoad.Loading + job = viewModelScope.launch { + val me = live.current() + if (me == null) { + if (_state.value !is DriveLoad.Ready) _state.value = DriveLoad.Error("offline") + return@launch + } + sideEffects(me) + val r = if (me.has(DMe.FEATURE_OVERVIEW)) api.overview() else DResult.Ok(assemble(me)) + when { + r is DResult.Ok -> _state.value = DriveLoad.Ready(r.value) + // keep what shows when a reload fails + _state.value !is DriveLoad.Ready -> _state.value = r.toLoad { it } + } + } + } + + /** What the Photos page's head did: the places' settings for this phone, the faces of new photos in the background. */ + private suspend fun sideEffects(me: DMe) { + val places = me.has(DMe.FEATURE_PHOTO_PLACES) + val people = me.has(DMe.FEATURE_PHOTO_PEOPLE) + if (!places && !people) return + api.photoSettings().valueOrNull()?.let(placesPrefs::took) + if (people) DrivePeopleWork.request(context) + } + + /** A server without `overview`: the parts from their own endpoints (side by side). */ + private suspend fun assemble(me: DMe): DOverview = coroutineScope { + val today = LocalDate.now() + val deadlines = async { if (me.has(DMe.FEATURE_DOC_AI)) api.deadlines(from = today.toString()).valueOrNull() else null } + val classes = async { if (me.has(DMe.FEATURE_PHOTO_CLASSES)) api.photoClasses().valueOrNull() else null } + val memories = async { if (me.has(DMe.FEATURE_PHOTOS)) api.memories(today.toString()).valueOrNull() else null } + val people = async { if (me.has(DMe.FEATURE_PHOTO_PEOPLE)) api.people().valueOrNull() else null } + val places = async { if (me.has(DMe.FEATURE_PHOTO_PLACES)) api.photoPlaces().valueOrNull() else null } + DriveOverviewLogic.assemble( + today = today, + deadlines = deadlines.await(), + classes = classes.await(), + memories = memories.await(), + people = people.await(), + places = places.await(), + storage = DriveOverviewLogic.storageOf(me.areas), + ) + } + + /** "Wer ist das?": the person without a name with most photos ([onPerson] gets it; nothing when there is none). */ + fun whoIsThat(onPerson: (Long) -> Unit) { + val known = (_state.value as? DriveLoad.Ready)?.value?.people?.firstUnnamed + if (known != null) return onPerson(known) + viewModelScope.launch { + val people = api.people().valueOrNull()?.people ?: return@launch + DrivePlacesLogic.groups(people).unnamed.firstOrNull()?.let { onPerson(it.id) } + } + } + + /** "Nächste Person" after naming [after]: the next one without a name from `GET people` afresh (null: none left). */ + fun nextToName(after: Long, onNext: (Long?) -> Unit) { + viewModelScope.launch { + val people = api.people().valueOrNull()?.people.orEmpty() + onNext(DrivePlacesLogic.groups(people).unnamed.firstOrNull { it.id != after }?.id) + } + } + + /** A memory full screen: its photos (fetched when the overview only had the cover). */ + fun openMemory(card: DMemoryCard) { + if (card.items.size >= card.count) { + _viewing.value = card.items + return + } + viewModelScope.launch { + val groups = api.memories(LocalDate.now().toString()).valueOrNull().orEmpty() + val items = groups.firstOrNull { it.yearsAgo == card.yearsAgo }?.items?.takeIf { it.isNotEmpty() } + _viewing.value = items ?: card.items.ifEmpty { listOf(card.cover) } + } + } + + fun closeMemory() { + _viewing.value = null + } + + suspend fun fetch(node: DNode): DResult = transfers.fetch(node) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt index b9e8ad77..830ef974 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt @@ -24,14 +24,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -95,11 +93,9 @@ import com.gatecontrol.android.R import com.gatecontrol.android.drive.DAlbum import com.gatecontrol.android.drive.DArea import com.gatecontrol.android.drive.DMe -import com.gatecontrol.android.drive.DMemories import com.gatecontrol.android.drive.DNode import com.gatecontrol.android.drive.DResult import com.gatecontrol.android.drive.DriveLogic -import com.gatecontrol.android.drive.DrivePlacesLogic import com.gatecontrol.android.drive.work.DriveWorkLogic import com.gatecontrol.android.drive.work.DriveWorkPhotos import androidx.compose.material3.SnackbarDuration @@ -152,13 +148,21 @@ internal fun photoDate(node: DNode): String? = (node.takenAt ?: node.mtime)?.let }.getOrNull() } -/** "Dateien" | "Fotos" on top of the Drive tab (only when Drive has photos). */ +/** "Übersicht" | "Dateien" | "Fotos" on top of the Drive tab (Drive 1.18; "Fotos" only when Drive has photos). */ @Composable -internal fun DriveModeSwitch(photos: Boolean, onPhotos: (Boolean) -> Unit) { +internal fun DriveModeSwitch(tabs: List, tab: DriveLogic.Tab, onTab: (DriveLogic.Tab) -> Unit) { GcSegmented( - options = listOf(false to stringResource(R.string.drive_mode_files), true to stringResource(R.string.drive_mode_photos)), - selected = photos, - onSelect = onPhotos, + options = tabs.map { t -> + t to stringResource( + when (t) { + DriveLogic.Tab.OVERVIEW -> R.string.drive_mode_overview + DriveLogic.Tab.FILES -> R.string.drive_mode_files + DriveLogic.Tab.PHOTOS -> R.string.drive_mode_photos + }, + ) + }, + selected = tab, + onSelect = onTab, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } @@ -181,8 +185,8 @@ internal fun PhotosSelectionHead(viewModel: DrivePhotosViewModel) { } /** - * The photos of Drive (Drive 1.7): the timeline by month with "Vor einem Jahr" on top, loading on while scrolling, and - * the albums. Long press ticks photos for an album; a tap shows them full screen. + * The photos of Drive (Drive 1.7): the timeline by month, loading on while scrolling, and the albums. Long press ticks + * photos for an album; a tap shows them full screen. Memories, people and places are on the overview (Drive 1.18). */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -192,8 +196,6 @@ internal fun DrivePhotosContent( onAlbum: (String) -> Unit, onCleanup: () -> Unit = {}, onFile: ((Long) -> Unit)? = null, - /** Drive 1.16: where the search, the map, a place and the people lead (null: not shown). */ - places: PhotosPlacesNav? = null, /** The page shows its own selection head in place of [topBar] (false: the Drive tab shows it, [PhotosSelectionHead]). */ ownHead: Boolean = true, ) { @@ -202,7 +204,6 @@ internal fun DrivePhotosContent( val me by viewModel.live.me.collectAsStateWithLifecycle() val timeline by viewModel.timeline.collectAsStateWithLifecycle() val months by viewModel.months.collectAsStateWithLifecycle() - val memories by viewModel.memories.collectAsStateWithLifecycle() val albums by viewModel.albums.collectAsStateWithLifecycle() val selected by viewModel.selected.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() @@ -211,8 +212,8 @@ internal fun DrivePhotosContent( val hasCamera = me?.has(DMe.FEATURE_PHOTOS_FOLDER) == true val cameraOnly by viewModel.cameraOnly.collectAsStateWithLifecycle() var tab by rememberSaveable { mutableStateOf("timeline") } - /** Full screen: the timeline (null) or the memories of so many years ago, from that photo on. */ - var viewing by remember { mutableStateOf?>(null) } + /** Full screen: the timeline from this photo on. */ + var viewing by remember { mutableStateOf(null) } var choosing by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(null) } @@ -230,16 +231,6 @@ internal fun DrivePhotosContent( val workOn = workSettings.active || serverClasses var moreOpen by remember { mutableStateOf(false) } - // Drive 1.16: the people and the places above the timeline - val header: DrivePlacesHeaderViewModel = androidx.hilt.navigation.compose.hiltViewModel(key = "drive-places") - val barPeople by header.people.collectAsStateWithLifecycle() - val topPlaces by header.places.collectAsStateWithLifecycle() - val placeClusters by header.clusters.collectAsStateWithLifecycle() - val peopleOn = places != null && me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true - val placesOn = places != null && me?.has(DMe.FEATURE_PHOTO_PLACES) == true - var naming by remember { mutableStateOf(null) } - LaunchedEffect(me?.features) { if (places != null) header.load(me) } - LaunchedEffect(Unit) { viewModel.start() val cm = context.getSystemService(android.net.ConnectivityManager::class.java) @@ -300,7 +291,6 @@ internal fun DrivePhotosContent( // ticking changes the title row only (as high either way); everything below stays, dimmed — the grid must not // move under the finger of the long press if (ownHead) SelectionSwap(selecting, bar = topBar, selection = { PhotosSelectionHead(viewModel) }) else topBar() - if (places != null && (placesOn || peopleOn)) PhotosSearchField(onClick = places.search, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).inert(selecting)) if (workOn) { val why = if (serverClasses) null else stringResource(R.string.drive_work_needs_1130) val reviewDesc = stringResource(R.string.drive_work_review_count, reviewCount) @@ -342,15 +332,8 @@ internal fun DrivePhotosContent( BulkBusyCard(busy, viewModel.bulk::cancelBusy) } } + // "N zu prüfen" is the count of the chip "Prüfen" (Drive 1.18: no band of its own) if (workOn && tab != "albums") Column(Modifier.inert(selecting)) { - if (reviewCount > 0) { - WorkBand( - pluralStringResource(R.plurals.drive_work_banner, reviewCount, reviewCount), - button = if (cls != "review") stringResource(R.string.drive_work_banner_show) else null, - warn = true, - onClick = { viewModel.setClass("review") }, - ) - } // "Prüfen" while the AI still learns: how far, and what helps if (cls == "review" && reviewCount > 0 && workSettings.active && !learned.ready) { WorkBand( @@ -385,39 +368,11 @@ internal fun DrivePhotosContent( selected = selected, months = if (shown !== timeline) emptyMap() else months, badge = if (workOn) ({ n -> TileBadge(review = DriveWorkLogic.isReview(n, localReview), work = n.work && cls == null) }) else null, - onOpen = { i -> viewing = null to i }, + onOpen = { i -> viewing = i }, onToggle = viewModel::toggle, onMore = viewModel::more, onRetry = viewModel::refresh, empty = stringResource(R.string.drive_photos_empty), - // the header stays while ticking (else the photos below it would move up) - header = ({ - // Drive 1.16: "Personen" with "Wer ist das?", "Orte" with the map — on "Alle" only - if (places != null && cls == null) { - val (named, unnamed) = DrivePlacesLogic.bar(barPeople?.people.orEmpty()) - if (peopleOn && (named.isNotEmpty() || unnamed != null)) { - item(span = { GridItemSpan(maxLineSpan) }) { - Column { - PhotosSectionHead(stringResource(R.string.drive_pp_people), stringResource(R.string.drive_pp_all), places.people) - PeopleBar(named, unnamed, onPerson = { places.person(it.id) }, onWho = { naming = it.id }) - } - } - } - if (placesOn && topPlaces.isNotEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Column(Modifier.padding(bottom = 4.dp)) { - PhotosSectionHead(stringResource(R.string.drive_pp_places), stringResource(R.string.drive_pp_map), places.map) - PlacesCard(topPlaces, placeClusters, onMap = places.map, onPlace = { places.place(it.key) }) - } - } - } - } - if (memories.isNotEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - MemoriesRow(memories, onOpen = { m -> viewing = m.yearsAgo to 0 }) - } - } - }), onSelect = viewModel.bulk::set, ) } @@ -472,12 +427,12 @@ internal fun DrivePhotosContent( ) } - viewing?.let { (years, start) -> + viewing?.let { start -> PhotoViewer( - items = if (years == null) timeline.items else memories.firstOrNull { it.yearsAgo == years }?.items.orEmpty(), + items = timeline.items, start = start, fetch = viewModel::fetch, - onMore = if (years == null) viewModel::more else null, + onMore = viewModel::more, onClose = { viewing = null }, ) } @@ -490,17 +445,6 @@ internal fun DrivePhotosContent( ) } } - naming?.let { id -> - NamePersonSheet( - personId = id, - onDismiss = { naming = null; header.load(me) }, - onNext = { - val next = DrivePlacesLogic.groups(barPeople?.people.orEmpty()).unnamed.firstOrNull { it.id != id } - naming = next?.id - if (next == null) header.load(me) - }, - ) - } creating?.let { withSelected -> AlbumNameDialog( title = stringResource(R.string.drive_album_new), @@ -513,38 +457,6 @@ internal fun DrivePhotosContent( } } -/** "Vor einem Jahr", "Vor 3 Jahren": a card per year with the day's photos. */ -@Composable -private fun MemoriesRow(memories: List, onOpen: (DMemories) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(bottom = 8.dp)) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(memories, key = { it.yearsAgo }) { m -> - val cover = m.items.first() - val picture = rememberDrivePreview(cover, large = true) ?: rememberDrivePreview(cover) - Box( - Modifier - .width(220.dp) - .height(150.dp) - .clip(RoundedCornerShape(18.dp)) - .background(GateControlTheme.extraColors.blueBg) - .clickable(role = Role.Button) { onOpen(m) }, - ) { - if (picture != null) Image(picture, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.25f))) - Column(Modifier.align(Alignment.BottomStart).padding(12.dp)) { - Text( - if (m.yearsAgo <= 0) stringResource(R.string.drive_photos_memories) else pluralStringResource(R.plurals.drive_photos_years_ago, m.yearsAgo, m.yearsAgo), - style = MaterialTheme.typography.titleMedium, - color = Color.White, - ) - Text(pluralStringResource(R.plurals.drive_photos_count, m.items.size, m.items.size), style = MaterialTheme.typography.bodySmall, color = Color.White) - } - } - } - } - } -} - /** * Photos as tiles, by month when [months] is given, loading the next page near the end. A tap opens one ([onOpen] * with its index), a long press — or a tap while some are ticked — ticks it. With [onSelect] a long press and a swipe diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt index b4aae63b..911e96f0 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt @@ -158,9 +158,6 @@ class DrivePhotosViewModel @Inject constructor( /** Photos per month ("2026-10" → 120) for the headings. */ val months: StateFlow> = _months.asStateFlow() - private val _memories = MutableStateFlow>(emptyList()) - val memories: StateFlow> = _memories.asStateFlow() - private val _albums = MutableStateFlow>?>(null) val albums: StateFlow>?> = _albums.asStateFlow() @@ -215,18 +212,16 @@ class DrivePhotosViewModel @Inject constructor( pager.reload(quiet = quiet) viewModelScope.launch { _months.value = api.photoMonths(camera = camera, cls = cls).valueOrNull()?.associate { it.month to it.count }.orEmpty() } if (work.serverClasses(live.me.value)) viewModelScope.launch { _classes.value = api.photoClasses(camera = camera).valueOrNull() } else _classes.value = null - viewModelScope.launch { _memories.value = api.memories(LocalDate.now().toString(), camera = camera).valueOrNull().orEmpty() } if (_albums.value != null) loadAlbums() } - /** "Nur Kamera-Uploads" on or off: the timeline, its months and the memories again. */ + /** "Nur Kamera-Uploads" on or off: the timeline and its months again. */ fun setCameraOnly(on: Boolean) { if (on == _cameraOnly.value) return _cameraOnly.value = on DriveViewPref.setCameraOnly(context, on) bulk.clear() _months.value = emptyMap() - _memories.value = emptyList() load(quiet = false) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt index 9ddb3dbb..f11abb37 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt @@ -93,15 +93,6 @@ import kotlin.math.hypot import kotlin.math.max import kotlin.math.roundToInt -/** Where the Photos page's places and people lead (Drive 1.16). */ -internal data class PhotosPlacesNav( - val search: () -> Unit, - val map: () -> Unit, - val place: (String) -> Unit, - val people: () -> Unit, - val person: (Long) -> Unit, -) - // ─── faces ───────────────────────────────────────────────────────── /** A person's colour while no face is shown (from the id, the avatar palette). */ @@ -150,7 +141,7 @@ internal fun PersonFace(p: DPerson, size: Dp, modifier: Modifier = Modifier) { /** "Wer ist das?" as a dashed circle with a question mark. */ @Composable -private fun WhoCircle(size: Dp) { +internal fun WhoCircle(size: Dp) { val x = GateControlTheme.extraColors Box( Modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.surface).border(1.dp, x.warn, CircleShape), @@ -158,119 +149,9 @@ private fun WhoCircle(size: Dp) { ) { Text("?", color = x.warn, fontWeight = FontWeight.Bold, fontSize = 20.sp) } } -// ─── the Photos page: people and places ───────────────────────────── - -/** A heading of the Photos page with a link on the right ("Alle", "Karte"). */ -@Composable -internal fun PhotosSectionHead(title: String, link: String?, onLink: (() -> Unit)?) { - Row(Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp, start = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).semantics { heading() }) - if (link != null && onLink != null) { - TextButton(onClick = onLink, modifier = Modifier.heightIn(min = 44.dp)) { - Text(link, color = GateControlTheme.extraColors.accentText, fontWeight = FontWeight.SemiBold) - } - } - } -} - -/** "Personen": the named people as round faces, then "Wer ist das?" for someone without a name. */ -@Composable -internal fun PeopleBar(named: List, unnamed: DPerson?, onPerson: (DPerson) -> Unit, onWho: (DPerson) -> Unit) { - val x = GateControlTheme.extraColors - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - named.forEach { p -> - Column( - Modifier.width(66.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button) { onPerson(p) }.padding(vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - PersonFace(p, 58.dp) - Text(p.name.orEmpty(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - if (unnamed != null) { - Column( - Modifier.width(72.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button) { onWho(unnamed) }.padding(vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - WhoCircle(58.dp) - Text(stringResource(R.string.drive_pp_who), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = x.warn, maxLines = 1) - } - } - } -} - -/** - * "Orte": a small map of the places (a tap opens the map), the places with most photos as chips below. The list - * builds this item again whenever it scrolls back: once the map's tiles were all there it is a still picture of it - * ([PhotoMapStills]), no map of its own. - */ -@Composable -internal fun PlacesCard(places: List, clusters: List, onMap: () -> Unit, onPlace: (DPlaceSummary) -> Unit) { - val x = GateControlTheme.extraColors - val scheme = MaterialTheme.colorScheme - val desc = stringResource(R.string.drive_pp_map_open) - val fit = remember(places) { DrivePlacesLogic.around(places.take(12).map { it.lat to it.lon }) } - BoxWithConstraints(Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(16.dp)).background(x.panel2)) { - val w = constraints.maxWidth - val h = constraints.maxHeight - val key = remember(fit, clusters, w, h, scheme) { - val bundles = clusters.joinToString(";") { "${it.lat},${it.lon},${it.count}" } - "$fit|$bundles|${w}x$h|${scheme.primary.toArgb()},${scheme.surface.toArgb()},${scheme.background.toArgb()}" - } - val still = PhotoMapStills[key] - if (still != null) { - Image(still, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) - OsmAttribution(Modifier.align(Alignment.BottomEnd)) - } else { - PhotoMap( - clusters = clusters, - selected = null, - fit = fit, - interactive = false, - onCluster = { onMap() }, - onMoved = { _, _ -> }, - contentDescription = desc, - modifier = Modifier.fillMaxWidth().height(120.dp), - stillKey = key, - onStill = PhotoMapStills::put, - ) - } - // a still picture: a tap anywhere opens the map - Box(Modifier.matchParentSize().clickable(role = Role.Button, onClickLabel = desc, onClick = onMap).semantics { contentDescription = desc }) - } - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - places.take(8).forEach { p -> - Box( - Modifier.height(36.dp).clip(RoundedCornerShape(10.dp)).border(1.dp, x.border2, RoundedCornerShape(10.dp)) - .clickable(role = Role.Button) { onPlace(p) }.padding(horizontal = 14.dp), - contentAlignment = Alignment.Center, - ) { Text("${p.name} · ${countText(p.count)}", style = MaterialTheme.typography.titleSmall, maxLines = 1) } - } - } -} - /** 1.204 in the phone's language. */ internal fun countText(n: Int): String = java.text.NumberFormat.getIntegerInstance().format(n) -/** The search field of the Photos page: a tap opens the search with suggestions. */ -@Composable -internal fun PhotosSearchField(onClick: () -> Unit, modifier: Modifier = Modifier) { - val x = GateControlTheme.extraColors - val hint = stringResource(R.string.drive_pp_search_hint) - Row( - modifier.fillMaxWidth().heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(x.panel2) - .clickable(role = Role.Button, onClickLabel = stringResource(R.string.drive_pp_search_title), onClick = onClick) - .padding(horizontal = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon(GcIcons.Search, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) - Text(hint, color = x.muted, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - // ─── the map ─────────────────────────────────────────────────────── /** @@ -411,8 +292,9 @@ private const val CLOSE_ZOOM = 14.0 private var livePhotoMaps = 0 /** - * The head map of Fotos as still pictures, by what they show (places, bundles, size, colours); the newest few are kept. - * Main thread; a new one recomposes the card, which then lets its map go. + * The small map of the overview (Drive 1.18; until then the head of Fotos) as still pictures, by what they show + * (places, bundles, size, colours); the newest few are kept. Main thread; a new one recomposes the list, which then + * lets the map that drew it go. */ internal object PhotoMapStills { private const val MAX = 3 diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt index c3139d8e..5195f8d1 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt @@ -48,57 +48,6 @@ import javax.inject.Inject private fun names(people: DPeople?): Map = people?.people.orEmpty().filter { !it.hidden && it.named }.associate { it.id to it.name!! } -/** - * The places and people on the Photos page (Drive 1.16): the bar of people with "Wer ist das?", the places card with - * its chips. Only loaded while Drive has the features. - */ -@HiltViewModel -class DrivePlacesHeaderViewModel @Inject constructor( - @ApplicationContext private val context: Context, - private val api: DriveApi, - val live: DriveLive, - private val prefs: DrivePlacesPrefs, -) : ViewModel() { - private val _people = MutableStateFlow(null) - val people: StateFlow = _people.asStateFlow() - private val _places = MutableStateFlow>(emptyList()) - val places: StateFlow> = _places.asStateFlow() - private val _clusters = MutableStateFlow>(emptyList()) - val clusters: StateFlow> = _clusters.asStateFlow() - val settings: StateFlow = prefs.settings - - private var job: Job? = null - - fun load(me: DMe?) { - job?.cancel() - val placesOn = me?.has(DMe.FEATURE_PHOTO_PLACES) == true - val peopleOn = me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true - if (!placesOn) { _places.value = emptyList(); _clusters.value = emptyList() } - if (!peopleOn) _people.value = null - if (!placesOn && !peopleOn) return - job = viewModelScope.launch { - api.photoSettings().valueOrNull()?.let(prefs::took) - if (peopleOn) { - _people.value = api.people().valueOrNull() - // new photos' faces, old ones' (in the background, WLAN and — by default — charging) - DrivePeopleWork.request(context) - } - if (placesOn) { - val list = api.photoPlaces().valueOrNull().orEmpty() - _places.value = list - val box = DrivePlacesLogic.around(list.take(12).map { it.lat to it.lon }) - _clusters.value = if (box == null) emptyList() else api.photoMap(box, zoomFor(box)).valueOrNull().orEmpty() - } - } - } - - /** A zoom that shows [b] in about 360 dp. */ - private fun zoomFor(b: DBbox): Int { - val span = maxOf(b.east - b.west, (b.north - b.south) * 1.5).coerceAtLeast(0.0001) - return (kotlin.math.ln(360.0 / span) / kotlin.math.ln(2.0) + 1).toInt().coerceIn(2, 14) - } -} - /** "Karte" (Drive 1.16, `photoPlaces`): bundles of photos on OpenStreetMap, one chosen with its photos below. */ @HiltViewModel class DriveMapViewModel @Inject constructor( diff --git a/app/src/main/res/values-de/strings_drive12.xml b/app/src/main/res/values-de/strings_drive12.xml new file mode 100644 index 00000000..834439fd --- /dev/null +++ b/app/src/main/res/values-de/strings_drive12.xml @@ -0,0 +1,54 @@ + + + + Übersicht + In Drive suchen + Dateien, Fotos, Personen, Orte + Fotos gesichert · %1$s + Fotos werden gesichert · %1$d von %2$d + Fotos werden gesichert + Foto-Sicherung wartet + Foto-Sicherung angehalten + %1$s von %2$s + %1$s belegt + Speicher: %1$s + Zu erledigen + + %1$d weitere Frist + %1$d weitere Fristen + + + %1$d Foto prüfen + %1$d Fotos prüfen + + Arbeit oder privat? + + %1$d Mail-Anhang zum Ablegen + %1$d Mail-Anhänge zum Ablegen + + Vorschlag: %1$s + Zuletzt geöffnet + Erinnerungen + + Heute vor einem Jahr + Heute vor %1$d Jahren + + Neu in %1$s + Neu in geteilten Bereichen + Jemand + + %1$s hat ein Foto hinzugefügt + %1$s hat %2$d Fotos hinzugefügt + + + %1$s hat eine Datei abgelegt + %1$s hat %2$d Dateien abgelegt + + %1$s hat „%2$s“ abgelegt + + %1$s hat eine Datei geändert + %1$s hat %2$d Dateien geändert + + %1$s hat „%2$s“ geändert + Gerade gibt es nichts Neues. + diff --git a/app/src/main/res/values/strings_drive12.xml b/app/src/main/res/values/strings_drive12.xml new file mode 100644 index 00000000..faa93821 --- /dev/null +++ b/app/src/main/res/values/strings_drive12.xml @@ -0,0 +1,54 @@ + + + + Overview + Search Drive + Files, photos, people, places + Photos backed up · %1$s + Backing up photos · %1$d of %2$d + Backing up photos + Photo backup is waiting + Photo backup stopped + %1$s of %2$s + %1$s used + Storage: %1$s + To do + + %1$d more deadline + %1$d more deadlines + + + Review %1$d photo + Review %1$d photos + + Work or private? + + %1$d mail attachment to file + %1$d mail attachments to file + + Suggestion: %1$s + Recently opened + Memories + + Today one year ago + Today %1$d years ago + + New in %1$s + New in shared areas + Someone + + %1$s added a photo + %1$s added %2$d photos + + + %1$s added a file + %1$s added %2$d files + + %1$s added “%2$s” + + %1$s changed a file + %1$s changed %2$d files + + %1$s changed “%2$s” + Nothing new right now. + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTabsTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTabsTest.kt index 9194a021..e96062d0 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTabsTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTabsTest.kt @@ -5,7 +5,7 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -/** The two pages of the Drive tab, sorting by file type (photos and videos are files like any other). */ +/** The pages of the Drive tab, sorting by file type (photos and videos are files like any other). */ class DriveLogicTabsTest { private fun file(id: Long, name: String, mime: String?, vault: String? = null) = @@ -15,23 +15,29 @@ class DriveLogicTabsTest { DNode(id, "mine:7", 1, name, true, 0, null, null, 1, null, null, null, vaultId = vault) @Test - fun `the pages of the Drive tab`() { - assertEquals(1, DriveLogic.tabCount(hasPhotos = false)) - assertEquals(2, DriveLogic.tabCount(hasPhotos = true)) - assertEquals(DriveLogic.TAB_PHOTOS, DriveLogic.tabOf(photos = true, hasPhotos = true)) - assertEquals(DriveLogic.TAB_FILES, DriveLogic.tabOf(photos = false, hasPhotos = true)) - // the saved "photos" without the Photos tab: the files - assertEquals(DriveLogic.TAB_FILES, DriveLogic.tabOf(photos = true, hasPhotos = false)) - assertTrue(DriveLogic.isPhotosTab(DriveLogic.TAB_PHOTOS, hasPhotos = true)) - assertFalse(DriveLogic.isPhotosTab(DriveLogic.TAB_FILES, hasPhotos = true)) - assertFalse(DriveLogic.isPhotosTab(DriveLogic.TAB_PHOTOS, hasPhotos = false)) - // swiping: two pages, nothing ticked - assertTrue(DriveLogic.tabSwipe(hasPhotos = true, selecting = false)) - assertFalse(DriveLogic.tabSwipe(hasPhotos = true, selecting = true)) - assertFalse(DriveLogic.tabSwipe(hasPhotos = false, selecting = false)) - // round trip page → saved choice → page - for (page in 0 until DriveLogic.tabCount(true)) { - assertEquals(page, DriveLogic.tabOf(DriveLogic.isPhotosTab(page, true), true)) + fun `the pages of the Drive tab - Uebersicht, Dateien, Fotos`() { + assertEquals(listOf(DriveLogic.Tab.OVERVIEW, DriveLogic.Tab.FILES), DriveLogic.tabs(hasPhotos = false)) + assertEquals(listOf(DriveLogic.Tab.OVERVIEW, DriveLogic.Tab.FILES, DriveLogic.Tab.PHOTOS), DriveLogic.tabs(hasPhotos = true)) + assertEquals(2, DriveLogic.tabCount(hasPhotos = false)) + assertEquals(3, DriveLogic.tabCount(hasPhotos = true)) + // Drive opens on the overview, with or without photos + assertEquals(0, DriveLogic.pageOf(DriveLogic.Tab.OVERVIEW, hasPhotos = true)) + assertEquals(0, DriveLogic.pageOf(DriveLogic.Tab.OVERVIEW, hasPhotos = false)) + // the files keep their page when the photos come (me loads later) + assertEquals(DriveLogic.pageOf(DriveLogic.Tab.FILES, false), DriveLogic.pageOf(DriveLogic.Tab.FILES, true)) + assertEquals(2, DriveLogic.pageOf(DriveLogic.Tab.PHOTOS, hasPhotos = true)) + // the photos without the Photos page: the files + assertEquals(1, DriveLogic.pageOf(DriveLogic.Tab.PHOTOS, hasPhotos = false)) + assertEquals(DriveLogic.Tab.PHOTOS, DriveLogic.tabAt(2, hasPhotos = true)) + // a page beyond the last (the photos went away): the last + assertEquals(DriveLogic.Tab.FILES, DriveLogic.tabAt(2, hasPhotos = false)) + assertEquals(DriveLogic.Tab.OVERVIEW, DriveLogic.tabAt(-1, hasPhotos = false)) + // swiping: not while items are ticked + assertTrue(DriveLogic.tabSwipe(selecting = false)) + assertFalse(DriveLogic.tabSwipe(selecting = true)) + // round trip page → tab → page + for (photos in listOf(false, true)) { + for (page in 0 until DriveLogic.tabCount(photos)) assertEquals(page, DriveLogic.pageOf(DriveLogic.tabAt(page, photos), photos)) } } diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt new file mode 100644 index 00000000..766cdf33 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt @@ -0,0 +1,234 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.drive.DriveOverviewLogic.Section +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.ZoneId + +/** Drive 1.18: `GET overview`, the overview of an older Drive, which sections show, the labels. */ +class DriveOverviewTest { + + private fun json(s: String) = JsonParser.parseString(s).asJsonObject + private val utc = ZoneId.of("UTC") + private val today = LocalDate.of(2026, 11, 1) + + private fun node(id: Long, name: String, mime: String? = null, takenAt: String? = null, dir: Boolean = false) = + DNode(id, "mine:7", 1, name, dir, 10, mime, null, 1, null, null, null, takenAt = takenAt) + + /** An answer as the contract describes it. */ + private val answer = """ + {"ok":true, + "todo":{"deadlines":[ + {"id":1,"kind":"payment","date":"2026-11-04","label":"Rechnung Stadtwerke zahlen","node":{"id":11,"areaId":"family:1","name":"Rechnung.pdf"}}, + {"id":2,"kind":"cancellation","date":"2026-11-13","label":"Kfz-Versicherung kündigen"}, + {"id":3,"kind":"bogus","date":"2026-11-14"}, + {"id":4,"kind":"payment","date":"2026-11-15"}], + "deadlinesTotal":2,"review":12,"toFile":3,"toFileHint":" Familie › Versicherungen "}, + "recent":[ + {"id":21,"areaId":"family:1","name":"Steuerbescheid 2025.pdf","mime":"application/pdf","openedAt":"2026-10-31T09:00:00Z","areaLabel":"Familie"}, + {"id":22,"areaId":"mine:7","name":"Urlaub.jpg","mime":"image/jpeg"}, + {"id":23,"areaId":"mine:7","name":"Ordner","isDir":true}, + {"id":24,"areaId":"mine:7","name":"Haushaltsbuch.xlsx","changedAt":"2026-10-29T09:00:00Z"}], + "memories":[ + {"yearsAgo":2,"date":"2024-11-01","cover":{"id":31,"areaId":"mine:7","name":"Strand.jpg","takenAt":"2024-11-01T10:00:00Z"},"count":30}, + {"yearsAgo":1,"date":"2025-11-01","count":2}], + "people":[{"id":41,"name":"Lea","count":30},{"id":42,"name":"Mama","count":20},{"id":43,"name":null,"count":9},{"id":44,"name":"Weg","hidden":true}], + "unnamed":4, + "places":{"top":[{"key":"geonames:2886242","name":"Köln","count":1629},{"key":"geonames:2911298","name":"Hamburg","count":214},{"key":"bad key!","name":"x"}], + "cluster":{"lat":50.94,"lon":6.96,"count":3871},"total":3871}, + "activity":[ + {"at":"2026-11-01T08:00:00Z","user":{"id":2,"name":"Mama"},"kind":"added_photos","count":14,"area":{"id":"family:1","label":"Familie"},"folder":{"id":51,"path":"Familie/Urlaub 2026"},"sample":{"id":52,"areaId":"family:1","name":"IMG.jpg"}}, + {"at":"2026-10-31T08:00:00Z","user":{"id":3,"name":"Papa"},"kind":"added_files","count":1,"area":{"id":"family:1","label":"Familie"},"folder":null,"sample":{"id":53,"areaId":"family:1","name":"Zeugnis Lea.pdf"}}, + {"kind":"deleted","count":1}], + "storage":{"used":212000000000,"total":1000000000000,"target":"NAS"}} + """.trimIndent() + + @Test + fun `GET overview is taken as the contract says, the rest is dropped`() { + val o = DriveOverviewJson.overview(json(answer))!! + assertFalse(o.assembled) + val t = o.todo!! + assertEquals(listOf(1L, 2L, 4L), t.deadlines.map { it.id }) + // the total is never below what came + assertEquals(3, t.deadlinesTotal) + assertEquals(12, t.review) + assertEquals(3, t.toFile) + assertEquals("Familie › Versicherungen", t.toFileHint) + // no folders, photos or videos in "Zuletzt geöffnet" + assertEquals(listOf("Steuerbescheid 2025.pdf", "Haushaltsbuch.xlsx"), o.recent.map { it.node.name }) + assertEquals("Familie", o.recent[0].areaLabel) + assertEquals("2026-10-29T09:00:00Z", o.recent[1].changedAt) + // a memory without a cover (or items) is left out + assertEquals(1, o.memories.size) + assertEquals(30, o.memories[0].count) + val p = o.people!! + assertEquals(listOf("Lea", "Mama"), p.people.map { it.name }) + assertEquals(4, p.unnamed) + assertEquals(43L, p.firstUnnamed) + assertEquals(listOf("Köln", "Hamburg"), o.places!!.top.map { it.name }) + assertEquals(3871, o.places!!.cluster!!.count) + assertEquals(2, o.activity.size) + assertEquals("Familie/Urlaub 2026", o.activity[0].folderPath) + assertEquals(DOverviewStorage(212_000_000_000, 1_000_000_000_000, "NAS"), o.storage) + } + + @Test + fun `nested answer, people as an object, wrong parts empty`() { + val o = DriveOverviewJson.overview(json("""{"overview":{"people":{"people":[{"id":1,"name":"Tim"}],"unnamed":0},"places":{"top":[]},"storage":{"total":5}}}"""))!! + assertEquals(listOf("Tim"), o.people!!.people.map { it.name }) + assertEquals(0, o.people!!.unnamed) + assertNull(o.todo) + assertFalse(DriveOverviewLogic.hasPlaces(o.places)) + assertNull(o.storage) + val empty = DriveOverviewJson.overview(json("{}"))!! + assertEquals(DOverview(), empty) + // only the search + assertEquals(listOf(Section.SEARCH), DriveOverviewLogic.sections(empty, null)) + } + + @Test + fun `sections show in the mockup order, an empty one stays out`() { + val o = DriveOverviewJson.overview(json(answer))!! + val saved = DriveOverviewLogic.Backup.Saved(1L) + assertEquals( + listOf(Section.SEARCH, Section.STATUS, Section.TODO, Section.RECENT, Section.MEMORIES, Section.PEOPLE, Section.PLACES, Section.ACTIVITY), + DriveOverviewLogic.sections(o, saved), + ) + // nothing to do, no recent files, no memories, no activity, no storage and the backup off + val bare = o.copy(todo = DTodo(emptyList(), 0, 0, null, null), recent = emptyList(), memories = emptyList(), activity = emptyList(), storage = null) + assertEquals(listOf(Section.SEARCH, Section.PEOPLE, Section.PLACES), DriveOverviewLogic.sections(bare, null)) + // the backup alone gives the status row + assertEquals(listOf(Section.SEARCH, Section.STATUS, Section.PEOPLE, Section.PLACES), DriveOverviewLogic.sections(bare, DriveOverviewLogic.Backup.Waiting)) + // people: "Wer ist das?" alone still shows, nobody at all does not + assertTrue(DriveOverviewLogic.hasPeople(DOverviewPeople(emptyList(), 2))) + assertFalse(DriveOverviewLogic.hasPeople(DOverviewPeople(emptyList(), 0))) + // "Zum Ablegen" alone is something to do + assertTrue(DriveOverviewLogic.hasTodo(DTodo(emptyList(), 0, 0, 3, null))) + assertFalse(DriveOverviewLogic.hasTodo(DTodo(emptyList(), 0, 0, 0, null))) + assertEquals(5, DriveOverviewLogic.todoCount(o.todo)) + assertEquals(0, DriveOverviewLogic.todoCount(null)) + // still loading: only the search + assertEquals(listOf(Section.SEARCH), DriveOverviewLogic.sections(null, saved)) + } + + @Test + fun `labels - Heute vor 2 Jahren, Vor einem Jahr`() { + val two = DMemoryCard(2, "2024-11-01", node(1, "a.jpg", takenAt = "2024-11-01T10:00:00Z"), 30) + assertEquals(DriveOverviewLogic.MemoryLabel(2, today = true), DriveOverviewLogic.memoryLabel(two, today, utc)) + // a day beside it (the plugin takes ± 1 day) + val one = DMemoryCard(1, "2025-11-01", node(2, "b.jpg", takenAt = "2025-10-31T20:00:00Z"), 2) + assertEquals(DriveOverviewLogic.MemoryLabel(1, today = false), DriveOverviewLogic.memoryLabel(one, today, utc)) + // no time on the cover: the group's date; no yearsAgo: from the date + val dated = DMemoryCard(0, "2023-11-01", node(3, "c.jpg"), 1) + assertEquals(DriveOverviewLogic.MemoryLabel(3, today = true), DriveOverviewLogic.memoryLabel(dated, today, utc)) + // nothing to go by: "An diesem Tag" + assertEquals(0, DriveOverviewLogic.memoryLabel(DMemoryCard(0, null, node(4, "d.jpg"), 1), today, utc).years) + } + + @Test + fun `the photo backup as a line`() { + assertNull(DriveOverviewLogic.backup(enabled = false, running = true, done = 1, total = 2, lastRunAt = 5, error = null)) + assertEquals(DriveOverviewLogic.Backup.Running(1, 2), DriveOverviewLogic.backup(true, true, 1, 2, 5, "x")) + assertEquals(DriveOverviewLogic.Backup.Stopped("no_permission"), DriveOverviewLogic.backup(true, false, 0, 0, 5, "no_permission")) + assertEquals(DriveOverviewLogic.Backup.Saved(5), DriveOverviewLogic.backup(true, false, 0, 0, 5, null)) + assertEquals(DriveOverviewLogic.Backup.Waiting, DriveOverviewLogic.backup(true, false, 0, 0, 0, null)) + } + + @Test + fun `without overview the overview is built from the other endpoints`() { + val d = { id: Long, date: String -> DDeadline(id, "payment", date, null, null, node = node(id, "f$id.pdf")) } + val o = DriveOverviewLogic.assemble( + today = today, + deadlines = listOf(d(2, "2026-11-20"), d(1, "2026-10-01"), d(3, "2026-11-02"), d(5, "2026-11-01"), d(6, "2026-12-24")), + classes = DPhotoClasses(work = 1, private = 2, review = 5), + memories = listOf(DMemories(1, "2025-11-01", listOf(node(9, "a.jpg"), node(10, "b.jpg"))), DMemories(2, null, emptyList())), + people = DPeople( + listOf( + DPerson(1, "Mama", 5, false, null, null), + DPerson(2, null, 9, false, null, null), + DPerson(3, null, 20, false, null, null), + DPerson(4, "X", 99, true, null, null), + ), + null, + null, + ), + places = listOf( + DPlaceSummary("b", "Hamburg", null, "DE", 2, 53.5, 10.0, null, null, null), + DPlaceSummary("a", "Köln", null, "DE", 7, 50.9, 6.9, null, null, null), + ), + storage = DOverviewStorage(100, 1000, "NAS"), + ) + assertTrue(o.assembled) + // from today on, by date; at most three, the total of all + assertEquals(listOf(5L, 3L, 2L), o.todo!!.deadlines.map { it.id }) + assertEquals(4, o.todo!!.deadlinesTotal) + assertEquals(5, o.todo!!.review) + assertNull(o.todo!!.toFile) + assertEquals(listOf(1 to 2), o.memories.map { it.yearsAgo to it.count }) + assertEquals(9L, o.memories[0].cover.id) + assertEquals(listOf("Mama"), o.people!!.people.map { it.name }) + assertEquals(2, o.people!!.unnamed) + assertEquals(3L, o.people!!.firstUnnamed) + assertEquals(listOf("Köln", "Hamburg"), o.places!!.top.map { it.name }) + assertEquals(9, o.places!!.total) + assertEquals(9, DriveOverviewLogic.mapCluster(o.places!!)!!.count) + // "Zuletzt geöffnet" and "Neu in …" cannot be built: they stay out + assertEquals( + listOf(Section.SEARCH, Section.STATUS, Section.TODO, Section.MEMORIES, Section.PEOPLE, Section.PLACES), + DriveOverviewLogic.sections(o, null), + ) + // features off: their parts are missing + val none = DriveOverviewLogic.assemble(today, null, null, null, null, null, null) + assertNull(none.todo) + assertNull(none.people) + assertNull(none.places) + assertEquals(listOf(Section.SEARCH), DriveOverviewLogic.sections(none, null)) + // the review count alone is something to do + val review = DriveOverviewLogic.assemble(today, null, DPhotoClasses(0, 0, 4), null, null, null, null) + assertEquals(listOf(Section.SEARCH, Section.TODO), DriveOverviewLogic.sections(review, null)) + } + + @Test + fun `the storage of Meine Dateien and how full it is`() { + val areas = listOf( + DArea("family:1", "family", "Familie", "write", "member", 2, false, DStorage(9, null, null, null, null)), + DArea("mine:7", "mine", "Meine Dateien", "manage", "owner", 1, false, DStorage(250, 1000, null, "NAS", null)), + ) + val s = DriveOverviewLogic.storageOf(areas)!! + assertEquals(DOverviewStorage(250, 1000, "NAS"), s) + assertEquals(0.25f, DriveOverviewLogic.fill(s)!!, 0.0001f) + assertNull(DriveOverviewLogic.fill(s.copy(total = null))) + assertNull(DriveOverviewLogic.storageOf(areas.take(1))) + } + + @Test + fun `the small map - a region around the bubble, else around the places`() { + val one = DOverviewPlaces(listOf(DPlaceTop("k", "Köln", 7)), DCluster(50.94, 6.96, 3871, null, null), 3871) + val b = DriveOverviewLogic.mapBox(one)!! + assertTrue(b.west < 6.96 && b.east > 6.96 && b.south < 50.94 && b.north > 50.94) + // a region, not a street + assertTrue(b.east - b.west >= 4.0) + val two = DOverviewPlaces(listOf(DPlaceTop("a", "Köln", 7, 50.9, 6.9), DPlaceTop("b", "Hamburg", 2, 53.5, 10.0)), null, 9) + val bb = DriveOverviewLogic.mapBox(two)!! + assertTrue(bb.west < 6.9 && bb.east > 10.0 && bb.south < 50.9 && bb.north > 53.5) + assertEquals(DCluster(50.9, 6.9, 9, null, null), DriveOverviewLogic.mapCluster(two)) + assertNull(DriveOverviewLogic.mapBox(DOverviewPlaces(listOf(DPlaceTop("k", "x", 1)), null, 1))) + } + + @Test + fun `small helpers`() { + assertTrue(DriveOverviewLogic.isMedia(node(1, "a.JPG"))) + assertTrue(DriveOverviewLogic.isMedia(node(2, "clip", mime = "video/mp4"))) + assertFalse(DriveOverviewLogic.isMedia(node(3, "a.pdf"))) + assertEquals("Urlaub 2026", DriveOverviewLogic.folderName("Familie/Urlaub 2026/")) + assertNull(DriveOverviewLogic.folderName("")) + assertNotNull(DriveOverviewLogic.millis("2026-11-01T08:00:00Z")) + assertNull(DriveOverviewLogic.millis("gestern")) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/ui/drive/DriveOverviewScrollTest.kt b/app/src/test/java/com/gatecontrol/android/ui/drive/DriveOverviewScrollTest.kt new file mode 100644 index 00000000..21b0753c --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/drive/DriveOverviewScrollTest.kt @@ -0,0 +1,165 @@ +package com.gatecontrol.android.ui.drive + +import android.app.Application +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.SubcomposeLayoutState +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.Constraints +import com.gatecontrol.android.drive.DActivity +import com.gatecontrol.android.drive.DCluster +import com.gatecontrol.android.drive.DDeadline +import com.gatecontrol.android.drive.DMemoryCard +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DOverview +import com.gatecontrol.android.drive.DOverviewPeople +import com.gatecontrol.android.drive.DOverviewPlaces +import com.gatecontrol.android.drive.DOverviewStorage +import com.gatecontrol.android.drive.DPerson +import com.gatecontrol.android.drive.DPlaceTop +import com.gatecontrol.android.drive.DRecent +import com.gatecontrol.android.drive.DTodo +import com.gatecontrol.android.drive.DriveOverviewLogic +import com.gatecontrol.android.ui.theme.GateControlTheme +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.osmdroid.views.MapView +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper + +/** + * The overview of Drive (1.18) scrolled up and down: it does not hang (ANR of 1.76: a map in a list item measured ahead + * at 0 × 0), the list items make no map — the small map of "Orte" is drawn once outside the list, at its own size. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp") +class DriveOverviewScrollTest { + + @get:Rule + val rule = createAndroidComposeRule() + + // a hang must fail the test, not the whole run: after a minute the main thread's stack, then the JVM ends + private val main = Thread.currentThread() + private val watchdog = Thread { + try { + Thread.sleep(60_000) + } catch (_: InterruptedException) { + return@Thread + } + System.err.println("DriveOverviewScrollTest hangs:\n" + main.stackTrace.joinToString("\n") { " at $it" }) + Runtime.getRuntime().halt(1) + }.apply { isDaemon = true } + + @Before + fun start() = watchdog.start() + + @After + fun stop() = watchdog.interrupt() + + private fun node(id: Long, name: String, mime: String? = null) = DNode(id, "family:1", 2, name, false, 10, mime, null, 1, "2026-10-30T10:00:00Z", null, null) + + /** Every section, the places around [lat] / [lon] (another still picture per test). */ + private fun overview(lat: Double, lon: Double) = DOverview( + todo = DTodo( + deadlines = listOf( + DDeadline(1, "payment", "2026-11-04", "Rechnung Stadtwerke zahlen", null, node = node(11, "Rechnung.pdf")), + DDeadline(2, "cancellation", "2026-11-13", "Kfz-Versicherung kündigen", null), + ), + deadlinesTotal = 5, + review = 12, + toFile = 3, + toFileHint = "Familie › Versicherungen", + ), + recent = (1L..10L).map { DRecent(node(100 + it, "Datei $it.pdf"), "2026-10-31T09:00:00Z", null, "Familie") }, + memories = (1..4).map { DMemoryCard(it, "${2026 - it}-11-01", node(200L + it, "m$it.jpg", "image/jpeg"), 10 * it) }, + people = DOverviewPeople((1L..8L).map { DPerson(it, "Person $it", 10, false, null, null) }, unnamed = 3), + places = DOverviewPlaces( + listOf(DPlaceTop("k:1", "Köln", 1629), DPlaceTop("k:2", "Hamburg", 214), DPlaceTop("k:3", "Palma", 30)), + DCluster(lat, lon, 3871, null, null), + 3871, + ), + activity = (1..5).map { + DActivity("2026-11-01T08:00:00Z", it, "Person $it", DriveOverviewLogic.ADDED_PHOTOS, 14, "family:1", "Familie", 50L + it, "Familie/Urlaub", node(300L + it, "x$it.jpg", "image/jpeg")) + }, + storage = DOverviewStorage(212_000_000_000, 1_000_000_000_000, "NAS"), + ) + + private val actions = OverviewActions( + search = {}, backup = {}, storage = {}, deadline = {}, deadlines = {}, review = {}, toFile = {}, file = {}, folder = {}, + files = {}, photos = {}, memory = {}, people = {}, person = {}, who = {}, map = {}, place = {}, + ) + + private fun maps(v: View): List = when (v) { + is MapView -> listOf(v) + is ViewGroup -> (0 until v.childCount).flatMap { maps(v.getChildAt(it)) } + else -> emptyList() + } + + @Test + fun `scrolling the overview up and down does not hang, and no list item makes a map`() { + val o = overview(50.94, 6.96) + val sections = DriveOverviewLogic.sections(o, DriveOverviewLogic.Backup.Saved(1L)) + assertEquals(8, sections.size) + val state = LazyListState() + rule.setContent { + GateControlTheme { OverviewPage(o, DriveOverviewLogic.Backup.Saved(System.currentTimeMillis() - 4 * 60_000), emptyList(), actions, state) } + } + rule.waitForIdle() + repeat(6) { + rule.onNodeWithTag(OVERVIEW_LIST_TAG).performScrollToIndex(sections.lastIndex) + rule.waitForIdle() + ShadowLooper.idleMainLooper() + rule.onNodeWithTag(OVERVIEW_LIST_TAG).performScrollToIndex(0) + rule.waitForIdle() + ShadowLooper.idleMainLooper() + } + rule.runOnIdle { + assertTrue("the list scrolls", state.layoutInfo.totalItemsCount == sections.size) + // at most the one map that draws the still picture, never one per scroll (list items make none) + val all = maps(rule.activity.window.decorView) + assertTrue("maps: ${all.size}", all.size <= 1) + for (m in all) { + // outside the list at its own size: never 0 × 0, and its zoom is a number within the map's limits + assertTrue("size ${m.width} x ${m.height}", m.width > 0 && m.height > 0) + assertTrue("zoom ${m.zoomLevelDouble}", m.zoomLevelDouble in 2.0..19.0) + } + } + } + + @Test + fun `the overview measured ahead at 0 x 0 does not zoom its map there`() { + val sub = SubcomposeLayoutState() + rule.setContent { + GateControlTheme { + SubcomposeLayout(sub, Modifier.fillMaxSize()) { c -> layout(c.maxWidth, c.maxHeight) {} } + } + } + rule.waitForIdle() + rule.runOnIdle { + // what a pager or a list does with a page before it is placed + val handle = sub.precompose("overview") { GateControlTheme { OverviewPage(overview(53.55, 10.0), null, emptyList(), actions) } } + handle.premeasure(0, Constraints(maxWidth = 0, maxHeight = 0)) + } + rule.waitForIdle() + ShadowLooper.idleMainLooper() + rule.runOnIdle { + val m = maps(rule.activity.window.decorView).single() + assertEquals(0, m.width) + // the zoom it was made with: the fitter waits for a size + assertEquals(3.0, m.zoomLevelDouble, 0.0) + } + } +} diff --git a/app/src/test/java/com/gatecontrol/android/ui/drive/DrivePhotosSelectionJumpTest.kt b/app/src/test/java/com/gatecontrol/android/ui/drive/DrivePhotosSelectionJumpTest.kt index e7df7926..4703b3ed 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/drive/DrivePhotosSelectionJumpTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/drive/DrivePhotosSelectionJumpTest.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -23,7 +22,6 @@ import androidx.compose.ui.test.performScrollToKey import androidx.compose.ui.test.printToString import androidx.compose.ui.unit.dp import com.gatecontrol.android.drive.DNode -import com.gatecontrol.android.drive.DPerson import com.gatecontrol.android.drive.DriveBulk import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcSubpageBar @@ -36,8 +34,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * The Photos page as it is built (title row, search field, chips, band, people and places above the months, the bar - * below while ticking): a long press ticks a photo — no photo on the screen may move, at the top of the list and + * The Photos page as it is built (title row, chips with the count of "Prüfen", the months, the bar below while + * ticking; Drive 1.18: people, places and memories are on the overview): a long press ticks a photo — no photo on the screen may move, at the top of the list and * scrolled into it. */ @RunWith(RobolectricTestRunner::class) @@ -53,7 +51,6 @@ class DrivePhotosSelectionJumpTest { val year = 2026 - ((id - 1) / 84) DNode(id, "mine:7", 2, "p$id.jpg", false, 10, "image/jpeg", null, 1, null, null, null, takenAt = "%04d-%02d-15T10:00:00Z".format(year, month)) } - private val people = (1L..6L).map { DPerson(it, "Person $it", 10, false, null, null) } private fun page(selected: () -> Set, onSelect: (Set) -> Unit) { val pages = PhotoPages(items = nodes, loaded = true) @@ -68,12 +65,10 @@ class DrivePhotosSelectionJumpTest { bar = { GcSubpageBar("Fotos", onBack = {}, backDescription = "Zurück") }, selection = { SelectionHead(count = selected().size, line = "Zu prüfen – wohin gehört es? Tippe auf Arbeit oder Privat.", allSelected = false, onClose = {}, onAll = null) }, ) - PhotosSearchField(onClick = {}, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).inert(selecting)) Row(Modifier.inert(selecting).padding(horizontal = 12.dp)) { WorkChip("Alle", selected = true, onClick = {}) WorkChip("Prüfen", selected = false, count = 3, onClick = {}) } - Column(Modifier.inert(selecting)) { WorkBand("3 Fotos zu prüfen", button = "Zeigen", warn = true, onClick = {}) } Box(Modifier.weight(1f)) { PhotoGrid( pages = pages, @@ -84,15 +79,6 @@ class DrivePhotosSelectionJumpTest { onRetry = {}, empty = "", months = months, - header = { - item(span = { GridItemSpan(maxLineSpan) }) { - Column { - PhotosSectionHead("Personen", "Alle", {}) - PeopleBar(people, null, onPerson = {}, onWho = {}) - } - } - item(span = { GridItemSpan(maxLineSpan) }) { PhotosSectionHead("Orte", "Karte", {}) } - }, onSelect = onSelect, ) } diff --git a/app/src/test/java/com/gatecontrol/android/ui/drive/PhotoMapPrefetchTest.kt b/app/src/test/java/com/gatecontrol/android/ui/drive/PhotoMapPrefetchTest.kt index 3e4c39f0..d02c0ccc 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/drive/PhotoMapPrefetchTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/drive/PhotoMapPrefetchTest.kt @@ -1,22 +1,12 @@ package com.gatecontrol.android.ui.drive import android.app.Application -import android.view.View -import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.layout.SubcomposeLayoutState import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.unit.Constraints -import com.gatecontrol.android.drive.DCluster -import com.gatecontrol.android.drive.DPlaceSummary import com.gatecontrol.android.ui.familie.OsmFitter import com.gatecontrol.android.ui.familie.configureOsm import com.gatecontrol.android.ui.familie.showBox -import com.gatecontrol.android.ui.theme.GateControlTheme import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -34,6 +24,7 @@ import org.robolectric.shadows.ShadowLooper /** * ANR in Drive › Fotos (1.76): the list measures the "Orte" item ahead while scrolling back, Compose lays its map out * at 0 × 0 before placing it, and osmdroid's zoomToBoundingBox then never returned. The map has to wait for its size. + * (Drive 1.18: the small map is on the overview, as a still picture — DriveOverviewScrollTest.) */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp") @@ -60,15 +51,6 @@ class PhotoMapPrefetchTest { @After fun stop() = watchdog.interrupt() - private val places = listOf(DPlaceSummary("geonames:2911298", "Hamburg", null, "DE", 12, 53.55, 10.0, null, null, null)) - private val clusters = listOf(DCluster(53.55, 10.0, 12, "geonames:2911298", null)) - - private fun maps(v: View): List = when (v) { - is MapView -> listOf(v) - is ViewGroup -> (0 until v.childCount).flatMap { maps(v.getChildAt(it)) } - else -> emptyList() - } - @Test fun `osmdroid's zoom of a box in a view smaller than its border is NaN`() { // what zoomToBoundingBox computes at 0 × 0 with 2 × 63 px border; its Projection then loops on this @@ -76,28 +58,6 @@ class PhotoMapPrefetchTest { assertTrue(zoom.isNaN()) } - @Test - fun `the places card measured ahead lays its map out at 0 x 0 and does not zoom there`() { - val sub = SubcomposeLayoutState() - rule.setContent { - GateControlTheme { - SubcomposeLayout(sub, Modifier.fillMaxSize()) { c -> layout(c.maxWidth, c.maxHeight) {} } - } - } - rule.waitForIdle() - rule.runOnIdle { - // what the lazy grid's prefetch does with the item before it is placed - val handle = sub.precompose("places") { GateControlTheme { PlacesCard(places, clusters, onMap = {}, onPlace = {}) } } - handle.premeasure(0, Constraints(maxWidth = 1080)) - } - rule.waitForIdle() - val map = rule.runOnIdle { maps(rule.activity.window.decorView).single() } - rule.runOnIdle { - assertEquals(0, map.width) - assertEquals(3.0, map.zoomLevelDouble, 0.0) - } - } - @Test fun `the fitter waits for a size, then shows the box once`() { val context = rule.activity From 01e58ff8b75ef3ba1cd5bcb1f6ec2edc5b959364 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:45:31 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(drive):=20=E2=80=9EZuletzt=20ge=C3=B6f?= =?UTF-8?q?fnet=E2=80=9C=20auch=20f=C3=BCr=20Dateien=20aus=20dem=20Cache?= =?UTF-8?q?=20(POST=20nodes//opened)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Öffnet die App eine Datei aus ihrem Cache, lädt sie nichts von Drive und das Plugin sieht das Öffnen nicht. Mit dem Feature `overview` meldet die App es jetzt mit POST nodes//opened (still, Fehler werden nicht gezeigt). Nicht für ältere Versionen, Ordner, Tresor-Dateien, Fotos und Videos. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../java/com/gatecontrol/android/drive/DriveApi.kt | 6 ++++++ .../com/gatecontrol/android/drive/DriveOverview.kt | 8 ++++++++ .../gatecontrol/android/drive/DriveTransfers.kt | 2 ++ .../gatecontrol/android/drive/DriveOverviewTest.kt | 14 ++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt index 658a9985..cf0ea055 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -438,6 +438,12 @@ class DriveApi @Inject constructor( suspend fun insightApply(id: Long, name: Boolean, folder: Boolean): DResult = post("nodes/$id/insight/apply", DriveJson.applyBody(name, folder), DriveJson::insightApplied) + /** + * Drive 1.18 (`overview`): a file opened from this phone's cache, without `content` (for "Zuletzt geöffnet"; + * `POST nodes//opened` → 204). + */ + suspend fun opened(id: Long): DResult = post("nodes/$id/opened", JsonObject(), unit) + /** Drive 1.18 (`overview`): everything the overview shows, in one call. */ suspend fun overview(): DResult = get("overview", parse = DriveOverviewJson::overview) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt index 52e59701..d10941e7 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveOverview.kt @@ -344,6 +344,14 @@ internal object DriveOverviewLogic { /** The last folder name of a path ("Familie/Urlaub 2026" → "Urlaub 2026"). */ fun folderName(path: String?): String? = path?.trim('/')?.substringAfterLast('/')?.ifEmpty { null } + /** + * A file served from this phone's cache is told to Drive as opened ("Zuletzt geöffnet" counts what `content` + * loads; a cached copy loads nothing): only with `overview`, the file itself (no older version), no folder, vault + * file, photo or video (Drive leaves those out). + */ + fun reportOpen(me: DMe?, node: DNode, versionId: Long?): Boolean = + me?.has(DMe.FEATURE_OVERVIEW) == true && versionId == null && !node.isDir && !node.encrypted && !isMedia(node) + /** Live events reload the overview at most this often. */ const val LIVE_THROTTLE_MS = 15_000L } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt index f9ec199c..072f9e69 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt @@ -412,6 +412,8 @@ class DriveTransfers @Inject constructor( val target = File(dir, name) if (target.isFile && (versionId != null || target.length() == node.size)) { target.setLastModified(System.currentTimeMillis()) + // Drive 1.18: "Zuletzt geöffnet" — the copy here loaded nothing from Drive, so it is told (errors stay quiet) + if (DriveOverviewLogic.reportOpen(live.me.value, node, versionId)) scope.launch { api.opened(node.id) } return@withContext DResult.Ok(target) } _downloading.update { it + (node.id to 0f) } diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt index 766cdf33..dd4cefd6 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveOverviewTest.kt @@ -221,6 +221,20 @@ class DriveOverviewTest { assertNull(DriveOverviewLogic.mapBox(DOverviewPlaces(listOf(DPlaceTop("k", "x", 1)), null, 1))) } + @Test + fun `a file from the cache is told to Drive as opened - only with overview, only the file itself`() { + val me = DMe(1, "Marc", "family", true, null, emptyList(), 0, null, null, emptyMap(), features = setOf(DMe.FEATURE_OVERVIEW)) + val pdf = node(1, "Rechnung.pdf", mime = "application/pdf") + assertTrue(DriveOverviewLogic.reportOpen(me, pdf, versionId = null)) + // an older version, a photo, a folder, a vault file, a Drive without overview: nothing + assertFalse(DriveOverviewLogic.reportOpen(me, pdf, versionId = 7)) + assertFalse(DriveOverviewLogic.reportOpen(me, node(2, "a.jpg"), null)) + assertFalse(DriveOverviewLogic.reportOpen(me, node(3, "Ordner", dir = true), null)) + assertFalse(DriveOverviewLogic.reportOpen(me, pdf.copy(vaultId = "v1"), null)) + assertFalse(DriveOverviewLogic.reportOpen(me.copy(features = emptySet()), pdf, null)) + assertFalse(DriveOverviewLogic.reportOpen(null, pdf, null)) + } + @Test fun `small helpers`() { assertTrue(DriveOverviewLogic.isMedia(node(1, "a.JPG")))