From 68e0943805413004d5adb751d9e835ed6d0462b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:27:45 +0000 Subject: [PATCH] Start: Seiten neben Start statt Tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Start wird eine Seitenreihe (HorizontalPager): Start immer zuerst, dann die angehefteten Seiten, ganz rechts die Plus-Seite „Seite anheften“ - Chats, Vault, Drive, Netzwerk und Familie wahlweise als Tab oder als Seite neben Start (echter Bildschirm des Tabs, „⋯“ in dessen Kopfzeile); angeheftet verschwindet das Symbol aus Leiste und Navigationsleiste - Widget-Seiten („Widgets 2“ …) mit Schnellaktionen und Widgets wie Start: lange drücken → Bearbeiten, Anordnen; leer mit gestricheltem Rahmen - Seitenpunkte über der Leiste (antippbar, Zähler für Chats/Remote), ohne Leiste mit Namenszeile - Anheften über Plus-Seite, langes Drücken auf einen Tab, „Seiten anordnen“ und Einstellungen → „Leiste und Seiten“; Snackbar mit Rückgängig; Admin-Sperre wie beim Anordnen - Start in der Leiste → erste Seite, Zurück auf einer Seite → Start; Pushes und Routen auf angeheftete Tabs öffnen deren Seite - Inneres Wischen auf Seiten nur noch per Tippen (Drive Dateien/Fotos, Schul-Tage) - Speicherung als page:-Einträge in der Start-Reihenfolge (ohne Serveränderung, ältere Apps behalten sie); Schalter „Leiste ausblenden, wenn nur Start bleibt“ lokal pro Gerät - Reine Logik in StartPages/MainTabs mit Unit-Tests, Doku ergänzt Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../android/layout/StartArrangement.kt | 36 +- .../gatecontrol/android/layout/StartLayout.kt | 22 +- .../gatecontrol/android/layout/StartPages.kt | 230 ++++++ .../android/layout/StartPagesStore.kt | 64 ++ .../gatecontrol/android/layout/TabLayout.kt | 11 +- .../android/layout/TabLayoutRepository.kt | 9 +- .../android/navigation/AppNavigation.kt | 680 +++++++++++++----- .../android/navigation/MainTabs.kt | 61 ++ .../gatecontrol/android/navigation/Screen.kt | 14 +- .../android/ui/chat/ChatListScreen.kt | 2 + .../android/ui/drive/DriveHomeScreen.kt | 5 +- .../android/ui/familie/FamilieHomeScreen.kt | 2 + .../android/ui/familie/SchoolScreen.kt | 4 +- .../android/ui/network/NetworkScreen.kt | 8 +- .../ui/quickactions/QuickActionsScreen.kt | 34 +- .../ui/quickactions/QuickActionsViewModel.kt | 33 +- .../android/ui/settings/SettingsPane.kt | 2 + .../android/ui/settings/SettingsScreen.kt | 14 + .../android/ui/start/StartArrangeScreen.kt | 16 +- .../android/ui/start/StartPageMenu.kt | 146 ++++ .../android/ui/start/StartPager.kt | 610 ++++++++++++++++ .../android/ui/start/StartPagesScreens.kt | 435 +++++++++++ .../android/ui/start/StartPagesViewModel.kt | 79 ++ .../android/ui/vault/VaultScreen.kt | 31 +- .../android/ui/vpn/StartEditSheet.kt | 27 +- .../gatecontrol/android/ui/vpn/VpnScreen.kt | 28 +- app/src/main/res/values-de/strings_pages.xml | 66 ++ app/src/main/res/values/strings_pages.xml | 66 ++ .../android/layout/StartPagesTest.kt | 195 +++++ .../android/layout/TabLayoutRepositoryTest.kt | 24 + .../android/navigation/MainTabsTest.kt | 50 ++ .../android/data/SettingsRepository.kt | 9 + docs/ARCHITECTURE.md | 2 +- docs/FEATURES.md | 57 ++ docs/LARGE_SCREENS.md | 5 + 35 files changed, 2790 insertions(+), 287 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/layout/StartPages.kt create mode 100644 app/src/main/java/com/gatecontrol/android/layout/StartPagesStore.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt create mode 100644 app/src/main/res/values-de/strings_pages.xml create mode 100644 app/src/main/res/values/strings_pages.xml create mode 100644 app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt b/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt index 2610e0c5..356e01ec 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartArrangement.kt @@ -29,15 +29,21 @@ class StartArrangement @Inject constructor( ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) - val entries: StateFlow> = + /** The blocks of the start tab (key null) and of each widget page beside it ([StartPages]). */ + val pages: StateFlow>> = combine(tabLayout.layout, quickActions.pins, quickActions.widgets) { layout, pins, widgets -> - StartLayout.merge(layout.start, pins.map { it.key }, widgets.map { StartEntry.Widget(it.device, it.wide) }) - }.stateIn(scope, SharingStarted.Eagerly, StartLayout.merge(emptyList(), emptyList(), emptyList())) + StartPages.merge(layout.start, pins.map { it.key }, widgets.map { StartEntry.Widget(it.device, it.wide) }) + }.stateIn(scope, SharingStarted.Eagerly, StartPages.merge(emptyList(), emptyList(), emptyList())) + + /** The start tab's blocks. */ + val entries: StateFlow> = pages.map { it[null].orEmpty() } + .stateIn(scope, SharingStarted.Eagerly, pages.value[null].orEmpty()) val access: StateFlow = tabLayout.access init { - // An order from the account (or arranged here): the pins take its order and widths. + // An order from the account (or arranged here): the pins take its order and widths + // (the pages' markers are no blocks: decode skips them). scope.launch { tabLayout.layout.map { it.start }.distinctUntilChanged().collect { saved -> if (saved.isEmpty()) return@collect @@ -51,17 +57,19 @@ class StartArrangement @Inject constructor( } } - /** Arranged by the person: the new order of all blocks. */ - suspend fun save(entries: List) = tabLayout.saveStart(StartLayout.encodeAll(entries)) + /** Arranged by the person: the new order of all blocks of [page] (null: the start tab). */ + suspend fun save(entries: List, page: String? = null) = + tabLayout.updateStart { StartPages.withEntries(it, page, StartLayout.encodeAll(entries)) } - /** After pinning, unpinning or a new width (pin sheet): the order takes the pins as they are now. */ - suspend fun syncFromPins() { - val merged = StartLayout.merge( - tabLayout.current().start, - quickActions.currentPins().map { it.key }, - quickActions.currentWidgets().map { StartEntry.Widget(it.device, it.wide) }, - ) - tabLayout.saveStart(StartLayout.encodeAll(merged)) + /** + * After pinning, unpinning or a new width (pin sheet): the order takes + * the pins as they are now; a block pinned just now joins [page] (the + * widget page it was pinned from, null: the start tab). + */ + suspend fun syncFromPins(page: String? = null) { + val tiles = quickActions.currentPins().map { it.key } + val widgets = quickActions.currentWidgets().map { StartEntry.Widget(it.device, it.wide) } + tabLayout.updateStart { StartPages.syncPins(it, tiles, widgets, page) } } /** Asks the server for the account's order (throttled). */ diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt b/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt index 6af6a7df..ef7137ef 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartLayout.kt @@ -73,13 +73,15 @@ object StartLayout { * where they did before arranging: a tile after the other tiles, a * widget after the last widget (before the cards when there is none), * Info and usage card at the end. Entries for blocks this device does - * not have are skipped. + * not have are skipped. [cards] false (a widget page beside Start, + * [StartPages]): no Info and usage card. */ - fun merge(saved: List, tiles: List, widgets: List): List { + fun merge(saved: List, tiles: List, widgets: List, cards: Boolean = true): List { val widgetByDevice = widgets.associateBy { it.device } val tileSet = tiles.toSet() + val withCards = cards val orderedTiles = ArrayList() - val cards = ArrayList() + val blocks = ArrayList() val seen = HashSet() for (raw in saved) { val entry = decode(raw) ?: continue @@ -87,19 +89,19 @@ object StartLayout { when (entry) { is StartEntry.Tile -> if (entry.action in tileSet) orderedTiles += entry // The width is the pin's: arranging and the pin sheet keep both in step. - is StartEntry.Widget -> widgetByDevice[entry.device]?.let { cards += it } - StartEntry.Info, StartEntry.Usage -> cards += entry + is StartEntry.Widget -> widgetByDevice[entry.device]?.let { blocks += it } + StartEntry.Info, StartEntry.Usage -> if (withCards) blocks += entry } } tiles.filter { seen.add(TILE + it) }.forEach { orderedTiles += StartEntry.Tile(it) } for (widget in widgets) { if (!seen.add(widget.key)) continue - val lastWidget = cards.indexOfLast { it is StartEntry.Widget } - cards.add(if (lastWidget >= 0) lastWidget + 1 else 0, widget) + val lastWidget = blocks.indexOfLast { it is StartEntry.Widget } + blocks.add(if (lastWidget >= 0) lastWidget + 1 else 0, widget) } - if (seen.add(INFO)) cards += StartEntry.Info - if (seen.add(USAGE)) cards += StartEntry.Usage - return orderedTiles + cards + if (withCards && seen.add(INFO)) blocks += StartEntry.Info + if (withCards && seen.add(USAGE)) blocks += StartEntry.Usage + return orderedTiles + blocks } /** diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt new file mode 100644 index 00000000..a495cbaf --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt @@ -0,0 +1,230 @@ +package com.gatecontrol.android.layout + +/** + * Pages beside the start tab (pure, unit-tested): functions that are a tab + * otherwise (Chats, Vault, Drive, Netzwerk, Familie) pinned to Start, and + * widget pages ("Widgets 2", "Widgets 3" …) holding quick actions and + * widgets like Start does. + * + * Kept in the start tab's list ([TabLayout.start]) so the server needs no + * change: a marker `page:` per page in the order of the pages; what + * stands before the first marker is the start tab's ([StartLayout]), what + * follows `page:w` belongs to that widget page. A function page has no + * entries — whatever follows its marker still belongs to the page before it + * (an older app keeps the markers as foreign entries, anchored after the + * entry they followed, and may put its own entries behind them). + * + * Older apps skip the markers ([StartLayout.decode] gives null) and show + * every pinned block on Start again; saving there keeps the markers + * ([TabLayout.keepForeign]). + */ +object StartPages { + const val MARKER = "page:" + + const val CHATS = "chats" + const val VAULT = "vault" + const val DRIVE = "drive" + const val NETWORK = "network" + const val FAMILIE = "familie" + + /** Functions that can be pages, in the order of the tab bar. */ + val FEATURES = listOf(CHATS, VAULT, DRIVE, NETWORK, FAMILIE) + + private val WIDGETS = Regex("^w([0-9]{1,3})$") + + /** One page beside Start: its [id] and, for a widget page, its [entries] (StartLayout ids). */ + data class Page(val id: String, val entries: List = emptyList()) + + /** The start list taken apart: the start tab's own [start] entries and the [pages] in their order. */ + data class Split(val start: List, val pages: List) { + val ids: List get() = pages.map { it.id } + } + + fun isMarker(raw: String): Boolean = raw.startsWith(MARKER) + + fun marker(id: String): String = MARKER + id + + fun isFeature(id: String): Boolean = id in FEATURES + + fun isWidgets(id: String): Boolean = widgetsNumber(id) != null + + /** The number of the widget page [id] (`w2` → 2), null for anything else. */ + fun widgetsNumber(id: String): Int? = WIDGETS.matchEntire(id)?.groupValues?.get(1)?.toIntOrNull()?.takeIf { it >= 2 } + + fun widgetsId(number: Int): String = "w$number" + + /** + * [saved] taken apart. The first marker of an id counts; a marker + * without id is dropped. Ids this version does not know stay pages (so + * saving keeps them) but have no entries, like a function page. + */ + fun split(saved: List): Split { + val start = ArrayList() + val pages = ArrayList() + val entries = HashMap>() + var owner: String? = null + for (raw in saved) { + if (isMarker(raw)) { + val id = raw.removePrefix(MARKER) + if (id.isEmpty() || pages.any { it.id == id }) continue + pages += Page(id) + if (isWidgets(id)) { + owner = id + entries[id] = ArrayList() + } + continue + } + val target = owner + if (target == null) start += raw else entries.getValue(target) += raw + } + return Split(start, pages.map { p -> entries[p.id]?.let { p.copy(entries = it) } ?: p }) + } + + /** [split] put back into one list: the start entries, then each marker with its page's entries. */ + fun join(split: Split): List = buildList { + addAll(split.start) + for (page in split.pages) { + add(marker(page.id)) + if (isWidgets(page.id)) addAll(page.entries) + } + } + + /** The page ids of [saved] in their order. */ + fun pages(saved: List): List = split(saved).ids + + /** + * The pages to show: function pages only when the function is + * [available] (its plugin listed, the Netzwerk segments allowed), + * every widget page; unknown ids are left out. + */ + fun visible(saved: List, available: Collection): List = + pages(saved).filter { isWidgets(it) || (isFeature(it) && it in available) } + + /** The entries of the page [page] (null: the start tab). */ + fun entriesOf(saved: List, page: String?): List { + val split = split(saved) + return if (page == null) split.start else split.pages.firstOrNull { it.id == page }?.entries.orEmpty() + } + + /** [saved] with the entries of [page] (null: the start tab) replaced by [entries]; unchanged when the page is missing. */ + fun withEntries(saved: List, page: String?, entries: List): List { + val split = split(saved) + if (page == null) return join(split.copy(start = entries)) + if (!isWidgets(page) || split.pages.none { it.id == page }) return saved + return join(split.copy(pages = split.pages.map { if (it.id == page) it.copy(entries = entries) else it })) + } + + /** [saved] with [id] as the last page (unchanged when it is a page already). */ + fun pin(saved: List, id: String): List { + val split = split(saved) + if (id.isEmpty() || split.pages.any { it.id == id }) return join(split) + return join(split.copy(pages = split.pages + Page(id))) + } + + /** + * [saved] without the page [id]: a function becomes a tab again; a + * widget page's blocks go back to the end of the start tab (nothing is + * unpinned, so taking it back restores it as it was). + */ + fun unpin(saved: List, id: String): List { + val split = split(saved) + val page = split.pages.firstOrNull { it.id == id } ?: return join(split) + return join(Split(split.start + page.entries, split.pages - page)) + } + + /** The id of a new widget page: the smallest number from 2 no page has. */ + fun nextWidgets(saved: List): String { + val used = pages(saved).mapNotNull(::widgetsNumber).toSet() + return widgetsId(generateSequence(2) { it + 1 }.first { it !in used }) + } + + /** [saved] with its pages in the order of [order] (ids it lacks keep their order after the others). */ + fun reorder(saved: List, order: List): List { + val split = split(saved) + return join(split.copy(pages = StartLayout.ordered(split.pages, order) { it.id })) + } + + /** [ids] with [id] one place earlier ([delta] -1) or later (+1); unchanged at the ends. */ + fun moveBy(ids: List, id: String, delta: Int): List = TabLayout.moveBy(ids, ids.indexOf(id), delta) + + /** Whether [id] can move by [delta] among [ids] (accessibility "nach oben/unten"). */ + fun canMove(ids: List, id: String, delta: Int): Boolean = moveBy(ids, id, delta) !== ids + + /** [ids] with [fromId] moved to the place of [toId] (dragging). */ + fun move(ids: List, fromId: String, toId: String): List { + val from = ids.indexOf(fromId) + val to = ids.indexOf(toId) + if (from < 0 || to < 0 || from == to) return ids + return ids.toMutableList().apply { add(to, removeAt(from)) } + } + + /** + * The blocks each page shows (null: the start tab): every pin of this + * device — buttons [tiles] (action keys) and [widgets] — on the page + * whose entries name it first; pins no page names stay on Start, as + * before there were pages ([StartLayout.merge]). Info and usage card + * only ever stand on Start. Every widget page of [saved] is in the map, + * an empty one with no blocks. + */ + fun merge(saved: List, tiles: List, widgets: List): Map> = + assign(saved, tiles, widgets, target = null) + + /** + * The start list after pins changed (pinned, unpinned, another width): + * every page keeps its blocks in their order; a block no page names yet + * (pinned just now) joins [target] (a widget page, null: the start tab) + * where [StartLayout.merge] puts new blocks; gone blocks drop out. + * Markers, function pages and unknown pages stay as they are. + */ + fun syncPins(saved: List, tiles: List, widgets: List, target: String?): List { + val split = split(saved) + val blocks = assign(saved, tiles, widgets, target) + return join( + Split( + start = StartLayout.encodeAll(blocks[null].orEmpty()), + pages = split.pages.map { page -> + if (isWidgets(page.id)) page.copy(entries = StartLayout.encodeAll(blocks[page.id].orEmpty())) else page + }, + ), + ) + } + + private fun assign(saved: List, tiles: List, widgets: List, target: String?): Map> { + val split = split(saved) + val widgetPages = split.pages.filter { isWidgets(it.id) } + // Which page names a block first (start before the pages, in their order). + val home = HashMap() + (listOf(null) + widgetPages).forEach { page -> + val entries = page?.entries ?: split.start + for (raw in entries) { + val key = StartLayout.decode(raw)?.key ?: continue + if (key !in home) home[key] = page?.id + } + } + val newHome = target?.takeIf { t -> widgetPages.any { it.id == t } } + fun pageOf(key: String): String? = if (key in home) home[key] else newHome + val result = LinkedHashMap>() + result[null] = StartLayout.merge( + split.start, + tiles.filter { pageOf(StartLayout.TILE + it) == null }, + widgets.filter { pageOf(it.key) == null }, + ) + for (page in widgetPages) { + result[page.id] = StartLayout.merge( + page.entries, + tiles.filter { pageOf(StartLayout.TILE + it) == page.id }, + widgets.filter { pageOf(it.key) == page.id }, + cards = false, + ) + } + return result + } + + /** + * Saving where the server keeps the order: [local] plus another + * device's entries ([TabLayout.keepForeign]) — but never a marker this + * device took away (unpinning would come back from the server's copy). + */ + fun keepForeign(local: List, remote: List): List = + TabLayout.keepForeign(local, remote, StartLayout::identity, keep = { !isMarker(it) }) +} diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartPagesStore.kt b/app/src/main/java/com/gatecontrol/android/layout/StartPagesStore.kt new file mode 100644 index 00000000..831cc5ed --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/layout/StartPagesStore.kt @@ -0,0 +1,64 @@ +package com.gatecontrol.android.layout + +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.support.CrashReports +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The pages beside Start ([StartPages]): kept in the start tab's order + * ([TabLayoutRepository], from the account where the server keeps it), so + * the second phone shows the same pages and an admin's locked order + * ([LayoutAccess.locked]) fixes them too. "Leiste ausblenden, wenn nur + * Start bleibt" is this device's own choice ([SettingsRepository]): it only + * changes how this screen looks, and an admin's lock does not take it away. + */ +@Singleton +class StartPagesStore @Inject constructor( + private val tabLayout: TabLayoutRepository, + private val settingsRepository: SettingsRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) + + /** The page ids in their order (unknown ones included; [StartPages.visible] picks those to show). */ + val pages: StateFlow> = tabLayout.layout.map { StartPages.pages(it.start) } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val access: StateFlow = tabLayout.access + + val hideBar: StateFlow = settingsRepository.getHideBarOnlyStart() + .stateIn(scope, SharingStarted.Eagerly, true) + + /** The start list as it is now (to take a change back, [restore]). */ + suspend fun snapshot(): List = tabLayout.current().start + + /** [id] (a function or a new widget page) as the last page. */ + suspend fun pin(id: String) = tabLayout.updateStart { StartPages.pin(it, id) } + + /** The page [id] goes: a function becomes a tab again, a widget page's blocks go back to Start. */ + suspend fun unpin(id: String) = tabLayout.updateStart { StartPages.unpin(it, id) } + + /** A new empty widget page as the last page; its id. */ + suspend fun addWidgets(): String { + var id = "" + tabLayout.updateStart { saved -> StartPages.nextWidgets(saved).also { id = it }.let { StartPages.pin(saved, it) } } + return id + } + + /** The pages in the order of [order] (page ids). */ + suspend fun reorder(order: List) = tabLayout.updateStart { StartPages.reorder(it, order) } + + /** "Rückgängig": the start list as it was before ([snapshot]). */ + suspend fun restore(start: List) = tabLayout.updateStart { start } + + suspend fun setHideBar(value: Boolean) = settingsRepository.setHideBarOnlyStart(value) +} diff --git a/app/src/main/java/com/gatecontrol/android/layout/TabLayout.kt b/app/src/main/java/com/gatecontrol/android/layout/TabLayout.kt index a5d817d6..f56c8aa7 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/TabLayout.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/TabLayout.kt @@ -44,9 +44,15 @@ data class TabLayout( * device's place for it. [identity] compares entries (the start * tab's width prefix does not make a widget another one). Foreign * entries are the first to go when the list would grow past - * [MAX_ENTRIES]. + * [MAX_ENTRIES]; those [keep] refuses are dropped (the pages' + * markers, [StartPages.keepForeign]). */ - fun keepForeign(local: List, remote: List, identity: (String) -> String = { it }): List { + fun keepForeign( + local: List, + remote: List, + identity: (String) -> String = { it }, + keep: (String) -> Boolean = { true }, + ): List { val known = local.map(identity).toSet() val result = local.toMutableList() var room = MAX_ENTRIES - local.size @@ -57,6 +63,7 @@ data class TabLayout( anchor = id continue } + if (!keep(entry)) continue if (room <= 0) break if (result.any { identity(it) == id }) continue val at = anchor?.let { a -> result.indexOfFirst { identity(it) == a } + 1 } ?: 0 diff --git a/app/src/main/java/com/gatecontrol/android/layout/TabLayoutRepository.kt b/app/src/main/java/com/gatecontrol/android/layout/TabLayoutRepository.kt index 0c395993..cb1e29a3 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/TabLayoutRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/TabLayoutRepository.kt @@ -153,6 +153,13 @@ class TabLayoutRepository @Inject constructor( /** The start tab's new order (StartLayout entries). */ suspend fun saveStart(entries: List) = save { it.copy(start = TabLayout.clean(entries)) } + /** + * The start list changed from what it is now (the pages beside Start + * live in it, [StartPages]): read and written in one step, so a change + * of the pages and one of the blocks do not undo each other. + */ + suspend fun updateStart(change: (List) -> List) = save { it.copy(start = TabLayout.clean(change(it.start))) } + /** The Familie tab's new order and hidden cards (FamilieLayout ids). */ suspend fun saveFamilie(order: List, hidden: List) = save { it.copy(familie = TabLayout.clean(order), familieHidden = TabLayout.clean(hidden)) } @@ -179,7 +186,7 @@ class TabLayoutRepository @Inject constructor( if (serverUrl.isBlank()) return val remote = s.remote val outgoing = if (remote == null) s.layout else s.layout.copy( - start = TabLayout.keepForeign(s.layout.start, remote.start, StartLayout::identity), + start = StartPages.keepForeign(s.layout.start, remote.start), familie = TabLayout.keepForeign(s.layout.familie, remote.familie), ) try { 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 2aeedbc2..c70f52c9 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -127,6 +126,28 @@ import com.gatecontrol.android.chat.ChatShareHandover import com.gatecontrol.android.ui.gatedrop.SharedScreen import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import com.gatecontrol.android.layout.StartPages +import com.gatecontrol.android.ui.start.LocalPageSnack +import com.gatecontrol.android.ui.start.START_PAGE +import com.gatecontrol.android.ui.start.StartPageRequest +import com.gatecontrol.android.ui.start.StartPager +import com.gatecontrol.android.ui.start.StartPagesArrangeScreen +import com.gatecontrol.android.ui.start.StartPagesSettingsScreen +import com.gatecontrol.android.ui.start.StartPagesViewModel +import com.gatecontrol.android.ui.start.startPageLabel +import kotlinx.coroutines.launch private data class TabItem( val route: String, @@ -134,6 +155,8 @@ private data class TabItem( val label: Int, val icon: ImageVector, val badge: Int = 0, + /** The page id when the tab can be pinned to Start (all but Start, StartPages). */ + val page: String? = MainTabs.pageOf(route), ) @Composable @@ -155,17 +178,106 @@ fun AppNavigation( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - val showBottomBar = currentRoute in MainTabs.BAR_ROUTES + // Pages beside Start (StartPages): functions pinned there leave the bar. + val pagesViewModel: StartPagesViewModel = hiltViewModel() + val allPages by pagesViewModel.pages.collectAsStateWithLifecycle() + val pagesAccess by pagesViewModel.access.collectAsStateWithLifecycle() + val hideBarSetting by pagesViewModel.hideBar.collectAsStateWithLifecycle() + val tabRoutes = MainTabs.routes( + network = MainTabs.hasNetwork(services = hasServicesPermission, pihole = hasPiholePermission, rdp = hasRdpPermission), + plugins = plugins, + ) + /** The functions this login has besides Start (tabs or pages). */ + val features = MainTabs.features(tabRoutes) + /** The pages shown beside Start, in their order. */ + val startPages = StartPages.visible(allPages, features) + val pinnedFeatures = startPages.filter { StartPages.isFeature(it) } + val barRoutes = MainTabs.visible(tabRoutes, pinnedFeatures) + val barHidden = MainTabs.hideBar(barRoutes, startPages, hideBarSetting) + val pinnedNow by rememberUpdatedState(pinnedFeatures) + /** The page of Start to show next (a pinned tab's route asked for, Start tapped). */ + var pageRequest by remember { mutableStateOf(null) } + /** The last request per page (its route's arguments stay with the page). */ + var pageRequests by remember { mutableStateOf(emptyMap()) } + val requestPage: (StartPageRequest) -> Unit = { request -> + pageRequest = request + pageRequests = pageRequests + (request.page to request) + } - val navigateToTab: (String) -> Unit = { route -> - navController.navigate(route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } + val showBottomBar = currentRoute in MainTabs.BAR_ROUTES && !barHidden + + /** To Start, showing [page] of it (null: whichever page it shows). */ + val showStart: (StartPageRequest?) -> Unit = { request -> + if (request != null) requestPage(request) + navController.navigate(Screen.Vpn.route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } launchSingleTop = true restoreState = true } } + // A tab pinned to Start opens as its page there (the route's arguments go along). + val navigateToTab: (String) -> Unit = { route -> + val page = MainTabs.pageFor(route)?.takeIf { it in pinnedNow } + if (page != null) { + showStart(StartPageRequest(page, route)) + } else { + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + /** A tab's route with arguments that must win over a saved state (no restoreState); a pinned one opens its page. */ + val openTab: (String) -> Unit = { route -> + val page = MainTabs.pageFor(route)?.takeIf { it in pinnedNow } + if (page != null) { + showStart(StartPageRequest(page, route)) + } else { + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + } + } + } + /** A pinned tab's route was opened anyway (an older path): its page instead. */ + @Composable + fun RedirectToPage(page: String, route: String) { + LaunchedEffect(page, route) { + requestPage(StartPageRequest(page, route)) + navController.navigate(Screen.Vpn.route) { + popUpTo(Screen.Vpn.route) { inclusive = false } + launchSingleTop = true + } + } + } + /** Start tapped in the bar: its first page, also when Start is shown already. */ + val goToStart: () -> Unit = { showStart(StartPageRequest(START_PAGE)) } + + // Pinning, taking off: a snackbar with "Rückgängig" (also from the settings). + val snackbarHostState = remember { SnackbarHostState() } + val snackScope = rememberCoroutineScope() + val undoLabel = stringResource(R.string.pages_undo) + val showSnack: (String, (() -> Unit)?) -> Unit = { message, undo -> + snackScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + message, + actionLabel = if (undo != null) undoLabel else null, + withDismissAction = undo == null, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) undo?.invoke() + } + } + val resources = LocalResources.current + /** Long press on a tab → "An Start anheften": the page, shown on Start. */ + val pinFromBar: (String) -> Unit = { page -> + pagesViewModel.pin(page) { undo -> showSnack(resources.getString(R.string.pages_snack_pinned, startPageLabel(resources, page)), undo) } + showStart(StartPageRequest(page)) + } // The plugin's own tab icon (SVG path), else the house. val familieIcon = remember(plugins.familieIcon) { @@ -181,10 +293,7 @@ fun AppNavigation( // Start, Chats (messenger plugin), Netzwerk (Dienste, Pi-hole, Remote), // the plugin tab Familie (docs/FAMILIE.md). The door plugin has no tab // (docs/DOOR.md), the settings open from the profile (gear top right). - val tabs = MainTabs.routes( - network = MainTabs.hasNetwork(services = hasServicesPermission, pihole = hasPiholePermission, rdp = hasRdpPermission), - plugins = plugins, - ).map { route -> + val tabs = barRoutes.map { route -> when (route) { Screen.Network.route -> TabItem(route, Screen.Network.create(), R.string.nav_network, GcIcons.Globe, onlineRdpHostCount) Screen.Familie.route -> TabItem(route, Screen.Familie.create(), R.string.nav_familie, familieIcon) @@ -229,15 +338,111 @@ fun AppNavigation( navBackStackEntry?.destination?.hierarchy?.any { it.route == tab.route } == true } - CompositionLocalProvider(LocalGcWindowLayout provides layout) { + // A tab's screen: as the tab (its route) or as a page beside Start (StartPager), the same either way. + @Composable + fun NetworkTab(initialTab: String?) { + GcContentWidth(maxWidth = 840.dp) { + NetworkScreen( + hasServices = hasServicesPermission, + hasPihole = hasPiholePermission, + hasRemote = hasRdpPermission, + initialTab = initialTab, + onGoToStart = goToStart, + ) + } + } + + @Composable + fun FamilieTab(dest: FamilieDest?) { + if (layout.twoPane) { + FamilieTwoPane(initial = dest, onOpenProfile = { openSettingsPane(SettingsPane.Profile) }) + } else { + GcContentWidth { + FamilieHomeScreen( + onOpen = { navController.navigate(Screen.FamiliePage.create(it.key)) }, + onOpenProfile = { openSettingsPane(SettingsPane.Profile) }, + ) + } + } + } + + @Composable + fun ChatsTab(conv: Long?, share: Boolean) { + val openInbox: () -> Unit = { navController.navigate(Screen.Inbox.create()) { launchSingleTop = true } } + val newChat: () -> Unit = { navController.navigate(Screen.ChatNew.create()) { launchSingleTop = true } } + val chatSettings: () -> Unit = { navController.navigate(Screen.ChatSettings.create()) { launchSingleTop = true } } + val openStory: (Long) -> Unit = { u -> navController.navigate(Screen.ChatStoryView.create(u)) { launchSingleTop = true } } + val storyComposer: () -> Unit = { navController.navigate(Screen.ChatStoryCompose.create()) { launchSingleTop = true } } + ChatSecureGate { + if (layout.twoPane) { + ChatsTwoPane( + initial = conv, + share = share, + onOpenInbox = openInbox, + onNewChat = newChat, + onOpenUpdates = openSettings, + onOpenInfo = { id -> navController.navigate(Screen.ChatInfo.create(id)) { launchSingleTop = true } }, + onOpenImage = { media -> navController.navigate(Screen.ChatImage.create(media)) }, + onOpenSettings = chatSettings, + onOpenStory = openStory, + onOpenStoryComposer = storyComposer, + ) + } else { + GcContentWidth { + ChatListScreen( + onOpen = { id -> + val sharing = share || ChatShareHandover.pending.value != null + navController.navigate(Screen.ChatConversation.create(id, sharing)) { launchSingleTop = true } + }, + onOpenInbox = openInbox, + onNewChat = newChat, + onOpenUpdates = openSettings, + onOpenSettings = chatSettings, + onOpenStory = openStory, + onOpenStoryComposer = storyComposer, + ) + } + } + } + } + + /** + * A function's page beside Start: its tab's screen. [route] is the + * tab's route last asked for (a push, a deep link) with its arguments; + * a new one starts the screen afresh with them. + */ + @Composable + fun FeatureTab(id: String, route: String?) { + when (id) { + StartPages.NETWORK -> NetworkTab(route?.let { MainTabs.arg(it, Screen.Network.ARG_TAB) }) + StartPages.FAMILIE -> FamilieTab(FamilieDest.parse(route?.let { MainTabs.arg(it, Screen.Familie.ARG_DEST) })) + StartPages.CHATS -> ChatsTab( + conv = route?.let { MainTabs.arg(it, Screen.Chats.ARG_CONV) }?.toLongOrNull(), + share = route?.let { MainTabs.arg(it, Screen.Chats.ARG_SHARE) } == "true", + ) + StartPages.VAULT -> VaultHome(navController) + StartPages.DRIVE -> DriveHome(navController, onShown = {}) + } + } + + val selectTab: (TabItem) -> Unit = { tab -> if (tab.route == Screen.Vpn.route) goToStart() else navigateToTab(tab.navigateTo) } + val tabMenu = TabMenuActions( + locked = pagesAccess.locked, + onPin = pinFromBar, + onSettings = { openSettingsPane(SettingsPane.Pages) }, + ) + + CompositionLocalProvider(LocalGcWindowLayout provides layout, LocalPageSnack provides showSnack) { Scaffold( containerColor = MaterialTheme.colorScheme.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { if (showBottomBar && !layout.useRail) { GcTabBar( tabs = tabs, isSelected = isTabSelected, - onSelect = { tab -> navigateToTab(tab.navigateTo) }, + onSelect = selectTab, + menu = tabMenu, ) } }, @@ -247,7 +452,8 @@ fun AppNavigation( GcNavRail( tabs = tabs, isSelected = isTabSelected, - onSelect = { tab -> navigateToTab(tab.navigateTo) }, + onSelect = selectTab, + menu = tabMenu, ) } NavHost( @@ -325,33 +531,49 @@ fun AppNavigation( onPauseOrDispose { } } val bell = bellState(bellInputs?.first, bellInputs?.second ?: true, systemAllowed) - VpnScreen( - unreadCount = unread, - bell = bell, - // Nothing arrives yet: the bell leads to where notifications are switched on. - onOpenInbox = { - if (bell.pushOff) openSettingsPane(SettingsPane.Notifications) - else navController.navigate(Screen.Inbox.create()) { launchSingleTop = true } - }, - onEnableNotifications = { openSettingsPane(SettingsPane.Notifications) }, - onDismissPushHint = inboxViewModel::dismissStartHint, - onTokenInvalid = { - navController.navigate(Screen.Setup.route) { - popUpTo(navController.graph.startDestinationId) { inclusive = true } - } - }, - onOpenLogs = { openSettingsPane(SettingsPane.Logs) }, - onOpenQuickActions = { navController.navigate(Screen.QuickActions.route) { launchSingleTop = true } }, - onOpenLocationSettings = { openSettingsPane(SettingsPane.Location) }, - onOpenDiagnosis = { openSettingsPane(SettingsPane.Diagnosis) }, - onOpenProfile = { openSettingsPane(SettingsPane.Profile) }, - onOpenInfoCardPicker = { navController.navigate(Screen.InfoCardPicker.route) { launchSingleTop = true } }, - onOpenConnectionDetails = { openSettingsPane(SettingsPane.Connection) }, - // Long press on a door quick action: the door screen, a normal destination — no tab. - onOpenDoor = openDoor, - onOpenStoryComposer = { navController.navigate(Screen.ChatStoryCompose.create()) { launchSingleTop = true } }, - onOpenOwnStories = { navController.navigate(Screen.ChatStoryView.create(0L)) { launchSingleTop = true } }, + StartPager( + pages = startPages, + candidates = features.filter { it !in allPages }, + badges = mapOf(StartPages.CHATS to chatBadge, StartPages.NETWORK to onlineRdpHostCount), + request = pageRequest, + requests = pageRequests, + names = barHidden, + onArrangePages = { navController.navigate(Screen.StartPagesArrange.route) { launchSingleTop = true } }, + onOpenQuickActions = { page -> navController.navigate(Screen.QuickActions.create(page)) { launchSingleTop = true } }, onNewReminder = { navController.navigate(Screen.ReminderNew.create(it)) { launchSingleTop = true } }, + onOpenDoor = openDoor, + // A new request for the page starts its screen afresh with the route's arguments. + feature = { id, request -> key(request?.seq) { FeatureTab(id, request?.route) } }, + start = { onArrange -> + VpnScreen( + unreadCount = unread, + bell = bell, + // Nothing arrives yet: the bell leads to where notifications are switched on. + onOpenInbox = { + if (bell.pushOff) openSettingsPane(SettingsPane.Notifications) + else navController.navigate(Screen.Inbox.create()) { launchSingleTop = true } + }, + onEnableNotifications = { openSettingsPane(SettingsPane.Notifications) }, + onDismissPushHint = inboxViewModel::dismissStartHint, + onTokenInvalid = { + navController.navigate(Screen.Setup.route) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + } + }, + onOpenLogs = { openSettingsPane(SettingsPane.Logs) }, + onOpenQuickActions = { navController.navigate(Screen.QuickActions.create()) { launchSingleTop = true } }, + onOpenLocationSettings = { openSettingsPane(SettingsPane.Location) }, + onOpenDiagnosis = { openSettingsPane(SettingsPane.Diagnosis) }, + onOpenProfile = { openSettingsPane(SettingsPane.Profile) }, + onOpenInfoCardPicker = { navController.navigate(Screen.InfoCardPicker.route) { launchSingleTop = true } }, + onOpenConnectionDetails = { openSettingsPane(SettingsPane.Connection) }, + // Long press on a door quick action: the door screen, a normal destination — no tab. + onOpenDoor = openDoor, + onOpenStoryComposer = { navController.navigate(Screen.ChatStoryCompose.create()) { launchSingleTop = true } }, + onOpenOwnStories = { navController.navigate(Screen.ChatStoryView.create(0L)) { launchSingleTop = true } }, + onArrange = onArrange, + ) + }, ) } @@ -375,14 +597,11 @@ fun AppNavigation( }, ), ) { backStackEntry -> - GcContentWidth(maxWidth = 840.dp) { - NetworkScreen( - hasServices = hasServicesPermission, - hasPihole = hasPiholePermission, - hasRemote = hasRdpPermission, - initialTab = backStackEntry.arguments?.getString(Screen.Network.ARG_TAB), - onGoToStart = { navigateToTab(Screen.Vpn.route) }, - ) + val tab = backStackEntry.arguments?.getString(Screen.Network.ARG_TAB) + if (StartPages.NETWORK in pinnedNow) { + RedirectToPage(StartPages.NETWORK, Screen.Network.create(tab)) + } else { + NetworkTab(tab) } } @@ -421,6 +640,7 @@ fun AppNavigation( onNavigateToConnection = { paneKey = SettingsPane.Connection.key }, onNavigateToShared = if (plugins.gatedrop) { { paneKey = SettingsPane.Shared.key } } else null, onNavigateToDrive = if (plugins.drive) { { navController.navigate(Screen.DriveSettings.route) { launchSingleTop = true } } } else null, + onNavigateToPages = { paneKey = SettingsPane.Pages.key }, selectedPane = pane, viewModel = settingsViewModel, ) @@ -433,6 +653,8 @@ fun AppNavigation( onOpenRoutes = { navController.navigate(Screen.NotifyRoutes.route) { launchSingleTop = true } }, onOpenRules = { navController.navigate(RulesRoutes.LIST) { launchSingleTop = true } }, onSelectPane = { paneKey = it.key }, + features = features, + onArrangePages = { navController.navigate(Screen.StartPagesArrange.route) { launchSingleTop = true } }, ) } }, @@ -451,6 +673,7 @@ fun AppNavigation( onNavigateToConnection = { navController.navigate(Screen.ConnectionDetails.route) { launchSingleTop = true } }, onNavigateToShared = if (plugins.gatedrop) { { navController.navigate(Screen.Shared.create()) { launchSingleTop = true } } } else null, onNavigateToDrive = if (plugins.drive) { { navController.navigate(Screen.DriveSettings.route) { launchSingleTop = true } } } else null, + onNavigateToPages = { navController.navigate(Screen.SettingsPages.route) { launchSingleTop = true } }, ) } } @@ -465,16 +688,11 @@ fun AppNavigation( }, ), ) { backStackEntry -> - val dest = FamilieDest.parse(backStackEntry.arguments?.getString(Screen.Familie.ARG_DEST)) - if (layout.twoPane) { - FamilieTwoPane(initial = dest, onOpenProfile = { openSettingsPane(SettingsPane.Profile) }) + val raw = backStackEntry.arguments?.getString(Screen.Familie.ARG_DEST) + if (StartPages.FAMILIE in pinnedNow) { + RedirectToPage(StartPages.FAMILIE, Screen.Familie.create(raw)) } else { - GcContentWidth { - FamilieHomeScreen( - onOpen = { navController.navigate(Screen.FamiliePage.create(it.key)) }, - onOpenProfile = { openSettingsPane(SettingsPane.Profile) }, - ) - } + FamilieTab(FamilieDest.parse(raw)) } } @@ -518,41 +736,10 @@ fun AppNavigation( ) { backStackEntry -> val conv = backStackEntry.arguments?.getString(Screen.Chats.ARG_CONV)?.toLongOrNull() val share = backStackEntry.arguments?.getString(Screen.Chats.ARG_SHARE) == "true" - val openInbox: () -> Unit = { navController.navigate(Screen.Inbox.create()) { launchSingleTop = true } } - val newChat: () -> Unit = { navController.navigate(Screen.ChatNew.create()) { launchSingleTop = true } } - val chatSettings: () -> Unit = { navController.navigate(Screen.ChatSettings.create()) { launchSingleTop = true } } - val openStory: (Long) -> Unit = { u -> navController.navigate(Screen.ChatStoryView.create(u)) { launchSingleTop = true } } - val storyComposer: () -> Unit = { navController.navigate(Screen.ChatStoryCompose.create()) { launchSingleTop = true } } - ChatSecureGate { - if (layout.twoPane) { - ChatsTwoPane( - initial = conv, - share = share, - onOpenInbox = openInbox, - onNewChat = newChat, - onOpenUpdates = openSettings, - onOpenInfo = { id -> navController.navigate(Screen.ChatInfo.create(id)) { launchSingleTop = true } }, - onOpenImage = { media -> navController.navigate(Screen.ChatImage.create(media)) }, - onOpenSettings = chatSettings, - onOpenStory = openStory, - onOpenStoryComposer = storyComposer, - ) + if (StartPages.CHATS in pinnedNow) { + RedirectToPage(StartPages.CHATS, Screen.Chats.create(conv, share)) } else { - GcContentWidth { - ChatListScreen( - onOpen = { id -> - val sharing = share || ChatShareHandover.pending.value != null - navController.navigate(Screen.ChatConversation.create(id, sharing)) { launchSingleTop = true } - }, - onOpenInbox = openInbox, - onNewChat = newChat, - onOpenUpdates = openSettings, - onOpenSettings = chatSettings, - onOpenStory = openStory, - onOpenStoryComposer = storyComposer, - ) - } - } + ChatsTab(conv, share) } } @@ -604,10 +791,7 @@ fun AppNavigation( if (!navController.popBackStack()) break } if (layout.twoPane) { - navController.navigate(Screen.Chats.create(conv = id)) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - } + openTab(Screen.Chats.create(conv = id)) } else { navController.navigate(Screen.ChatConversation.create(id)) } @@ -899,7 +1083,7 @@ fun AppNavigation( onBack = { selected = null }, onNavigate = { target -> if (target is PushNavTarget.Detail) selected = target.id - else if (target != PushNavTarget.Inbox) openPushTarget(navController, target, navigateToTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) + else if (target != PushNavTarget.Inbox) openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) }, viewModel = detailViewModel, ) @@ -926,7 +1110,7 @@ fun AppNavigation( onBack = { if (!navController.popBackStack()) navController.navigate(Screen.Inbox.create()) }, - onNavigate = { target -> openPushTarget(navController, target, navigateToTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) }, + onNavigate = { target -> openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) }, ) } } @@ -984,14 +1168,38 @@ fun AppNavigation( GcContentWidth { DiagnosisScreen(onBack = { navController.popBackStack() }) } } - composable(Screen.QuickActions.route) { + composable( + route = Screen.QuickActions.route, + arguments = listOf( + navArgument(Screen.QuickActions.ARG_PAGE) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { backStackEntry -> GcContentWidth { QuickActionsScreen( onBack = { navController.popBackStack() }, onOpenDoor = openDoor, onNewReminder = { navController.navigate(Screen.ReminderNew.create(it)) { launchSingleTop = true } }, + page = backStackEntry.arguments?.getString(Screen.QuickActions.ARG_PAGE)?.takeIf { StartPages.isWidgets(it) }, ) } } + // Pages beside Start: "Seiten anordnen" and Einstellungen → "Leiste und Seiten". + composable(Screen.StartPagesArrange.route) { + GcContentWidth { StartPagesArrangeScreen(available = features, onBack = { navController.popBackStack() }) } + } + composable(Screen.SettingsPages.route) { + GcContentWidth { + StartPagesSettingsScreen( + available = features, + onBack = { navController.popBackStack() }, + onArrangePages = { navController.navigate(Screen.StartPagesArrange.route) { launchSingleTop = true } }, + ) + } + } + composable( route = Screen.Shared.route, arguments = listOf( @@ -1016,8 +1224,16 @@ fun AppNavigation( ) } - vaultRoutes(navController, navigateToTab) - driveRoutes(navController, navigateToTab) + vaultRoutes(navController, navigateToTab) { + if (StartPages.VAULT in pinnedNow) RedirectToPage(StartPages.VAULT, Screen.Vault.route) else VaultHome(navController) + } + driveRoutes(navController, navigateToTab) { entry -> + if (StartPages.DRIVE in pinnedNow) { + RedirectToPage(StartPages.DRIVE, Screen.Drive.route) + } else { + DriveHome(navController, onShown = { id -> entry.savedStateHandle[DRIVE_TAB_SHOWN] = id }) + } + } } // A tapped push message (PushOpenActivity) — only once the app is set up. @@ -1026,7 +1242,7 @@ fun AppNavigation( val target = pushTarget ?: return@LaunchedEffect if (!isSetupComplete) return@LaunchedEffect PushNavigation.consume(target) - openPushTarget(navController, target, navigateToTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) + openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) } // After NavHost so the graph is set before navigating. @@ -1046,6 +1262,8 @@ private fun openPushTarget( navController: NavHostController, target: PushNavTarget, navigateToTab: (String) -> Unit, + /** A tab's route whose arguments win over a saved state (a pinned tab: its page beside Start). */ + openTab: (String) -> Unit, layout: GcWindowLayout, familieTab: Boolean, /** The door plugin is listed: its pushes open the door screens. */ @@ -1092,10 +1310,7 @@ private fun openPushTarget( when { target.conversationId == null -> navigateToTab(Screen.Chats.create()) target.conversationId == null -> navigateToTab(Screen.Chats.create()) - layout.twoPane -> navController.navigate(Screen.Chats.create(conv = target.conversationId)) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - } + layout.twoPane -> openTab(Screen.Chats.create(conv = target.conversationId)) else -> { navigateToTab(Screen.Chats.create()) navController.navigate(Screen.ChatConversation.create(target.conversationId)) { launchSingleTop = true } @@ -1105,10 +1320,7 @@ private fun openPushTarget( } is PushNavTarget.ChatShare -> when { !chats -> ChatShareHandover.clear() - target.conversationId == null || layout.twoPane -> navController.navigate(Screen.Chats.create(conv = target.conversationId, share = true)) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - } + target.conversationId == null || layout.twoPane -> openTab(Screen.Chats.create(conv = target.conversationId, share = true)) else -> { navigateToTab(Screen.Chats.create()) navController.navigate(Screen.ChatConversation.create(target.conversationId, share = true)) { launchSingleTop = true } @@ -1127,14 +1339,8 @@ private fun openPushTarget( navController.navigate(Screen.NotificationDetail.create(target.id)) } PushNavTarget.Vpn -> navigateToTab(Screen.Vpn.route) - PushNavTarget.Services -> navController.navigate(Screen.Network.create(NetworkTabs.SERVICES)) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - } - PushNavTarget.Remote -> navController.navigate(Screen.Network.create(NetworkTabs.REMOTE)) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - } + PushNavTarget.Services -> openTab(Screen.Network.create(NetworkTabs.SERVICES)) + PushNavTarget.Remote -> openTab(Screen.Network.create(NetworkTabs.REMOTE)) is PushNavTarget.Familie -> { val dest = FamilieDest.parse(target.dest) ?: FamilieDest.Home when { @@ -1143,6 +1349,7 @@ private fun openPushTarget( navController, target.messageId?.let { PushNavTarget.Detail(it) } ?: PushNavTarget.Inbox, navigateToTab, + openTab, layout, familieTab = false, door = door, @@ -1150,10 +1357,7 @@ private fun openPushTarget( ) // Wide screens: the screen beside the Familie start screen. No // restoreState: the dest argument must win over a saved state. - layout.twoPane -> navController.navigate(Screen.Familie.create(dest.key)) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - } + layout.twoPane -> openTab(Screen.Familie.create(dest.key)) else -> { navigateToTab(Screen.Familie.create()) if (dest != FamilieDest.Home) navController.navigate(Screen.FamiliePage.create(dest.key)) @@ -1173,6 +1377,7 @@ private fun openPushTarget( navController, target.messageId?.let { PushNavTarget.Detail(it) } ?: PushNavTarget.Inbox, navigateToTab, + openTab, layout, familieTab, door = false, @@ -1194,13 +1399,71 @@ private fun openPushTarget( } } -/** Bottom tab bar from the mockup: pill indicator behind the icon, label below. */ +/** + * A long press on a tab (all but Start): "An Start anheften" (the tab + * becomes a page beside Start, [onPin] its page id) and "Leiste und Seiten + * …" ([onSettings]); [locked]: the admin fixes the order, pinning is off. + */ +private class TabMenuActions( + val locked: Boolean, + val onPin: (String) -> Unit, + val onSettings: () -> Unit, +) + +/** A tab of the bar or the rail: tap selects it, a long press opens [TabMenu] (all but Start; [menuLabel] read out for it). */ +private fun Modifier.tabClick(tab: TabItem, selected: Boolean, onSelect: (TabItem) -> Unit, onMenu: (() -> Unit)?, menuLabel: String): Modifier = + combinedClickable( + role = Role.Tab, + onClick = { onSelect(tab) }, + onLongClick = onMenu, + onLongClickLabel = if (onMenu != null) menuLabel else null, + ).semantics { this.selected = selected } + +/** The menu of a long-pressed tab (mockup "Tab lange drücken"). */ +@Composable +private fun TabMenu(tab: TabItem, expanded: Boolean, actions: TabMenuActions, onDismiss: () -> Unit) { + val page = tab.page ?: return + val extra = GateControlTheme.extraColors + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + Text( + stringResource(tab.label), + style = MaterialTheme.typography.labelMedium, + color = extra.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + DropdownMenuItem( + text = { + Column { + Text(stringResource(R.string.pages_tab_pin)) + Text( + stringResource(if (actions.locked) R.string.arrange_locked else R.string.pages_tab_pin_desc), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + }, + leadingIcon = { Icon(GcIcons.Pin, contentDescription = null, modifier = Modifier.size(20.dp)) }, + enabled = !actions.locked, + onClick = { onDismiss(); actions.onPin(page) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.pages_tab_settings)) }, + leadingIcon = { Icon(GcIcons.Sliders, contentDescription = null, modifier = Modifier.size(20.dp)) }, + onClick = { onDismiss(); actions.onSettings() }, + ) + } +} + +/** Bottom tab bar from the mockup: pill indicator behind the icon, label below; a long press offers pinning to Start. */ @Composable private fun GcTabBar( tabs: List, isSelected: (TabItem) -> Boolean, onSelect: (TabItem) -> Unit, + menu: TabMenuActions, ) { + var menuFor by remember { mutableStateOf(null) } + val menuLabel = stringResource(R.string.pages_tab_options) val extra = GateControlTheme.extraColors val scheme = MaterialTheme.colorScheme Column( @@ -1219,16 +1482,19 @@ private fun GcTabBar( val pill = if (tabs.size > 5) 52.dp else 60.dp tabs.forEach { tab -> val selected = isSelected(tab) - TabItemContent( - tab = tab, - selected = selected, - pillWidth = pill, - modifier = Modifier - .weight(1f) - .height(72.dp) - .selectable(selected = selected, role = Role.Tab, onClick = { onSelect(tab) }) - .padding(top = 10.dp), - ) + Box(Modifier.weight(1f)) { + TabItemContent( + tab = tab, + selected = selected, + pillWidth = pill, + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .tabClick(tab, selected, onSelect, onMenu = tab.page?.let { { menuFor = tab.route } }, menuLabel = menuLabel) + .padding(top = 10.dp), + ) + TabMenu(tab, expanded = menuFor == tab.route, actions = menu, onDismiss = { menuFor = null }) + } } } } @@ -1293,8 +1559,11 @@ private fun GcNavRail( tabs: List, isSelected: (TabItem) -> Boolean, onSelect: (TabItem) -> Unit, + menu: TabMenuActions, ) { val extra = GateControlTheme.extraColors + var menuFor by remember { mutableStateOf(null) } + val menuLabel = stringResource(R.string.pages_tab_options) Row(Modifier.fillMaxHeight()) { Column( Modifier @@ -1314,14 +1583,17 @@ private fun GcNavRail( ) tabs.forEach { tab -> val selected = isSelected(tab) - TabItemContent( - tab = tab, - selected = selected, - modifier = Modifier - .width(80.dp) - .selectable(selected = selected, role = Role.Tab, onClick = { onSelect(tab) }) - .padding(vertical = 6.dp), - ) + Box { + TabItemContent( + tab = tab, + selected = selected, + modifier = Modifier + .width(80.dp) + .tabClick(tab, selected, onSelect, onMenu = tab.page?.let { { menuFor = tab.route } }, menuLabel = menuLabel) + .padding(vertical = 6.dp), + ) + TabMenu(tab, expanded = menuFor == tab.route, actions = menu, onDismiss = { menuFor = null }) + } } } Box(Modifier.width(1.dp).fillMaxHeight().background(extra.border)) @@ -1348,6 +1620,7 @@ private fun SettingsPane.route(): String = when (this) { SettingsPane.TrustedNetworks -> Screen.TrustedNetworks.route SettingsPane.Location -> Screen.LocationSharing.route SettingsPane.Notifications -> Screen.SettingsNotifications.route + SettingsPane.Pages -> Screen.SettingsPages.route SettingsPane.Shared -> Screen.Shared.create() SettingsPane.Logs -> Screen.Logs.route SettingsPane.Diagnosis -> Screen.Diagnosis.route @@ -1355,7 +1628,15 @@ private fun SettingsPane.route(): String = when (this) { /** A settings page in the detail pane beside the settings list. */ @Composable -private fun SettingsPaneContent(pane: SettingsPane, onOpenEnroll: () -> Unit, onSelectPane: (SettingsPane) -> Unit, onOpenRules: () -> Unit = {}, onOpenRoutes: (() -> Unit)? = null) { +private fun SettingsPaneContent( + pane: SettingsPane, + onOpenEnroll: () -> Unit, + onSelectPane: (SettingsPane) -> Unit, + onOpenRules: () -> Unit = {}, + onOpenRoutes: (() -> Unit)? = null, + features: List = emptyList(), + onArrangePages: () -> Unit = {}, +) { when (pane) { SettingsPane.Profile -> ProfileScreen(onBack = {}, onOpenRules = onOpenRules) SettingsPane.Connection -> ConnectionDetailsScreen(onBack = {}, onOpenDiagnosis = { onSelectPane(SettingsPane.Diagnosis) }) @@ -1364,6 +1645,7 @@ private fun SettingsPaneContent(pane: SettingsPane, onOpenEnroll: () -> Unit, on SettingsPane.TrustedNetworks -> TrustedNetworksScreen(onBack = {}) SettingsPane.Location -> LocationSharingScreen(onBack = {}) SettingsPane.Notifications -> NotificationSettingsScreen(onBack = {}, onOpenRoutes = onOpenRoutes) + SettingsPane.Pages -> StartPagesSettingsScreen(available = features, onBack = {}, onArrangePages = onArrangePages) SettingsPane.Shared -> SharedScreen(onBack = {}) SettingsPane.Logs -> LogsScreen(onNavigateBack = {}) SettingsPane.Diagnosis -> DiagnosisScreen(onBack = {}) @@ -1394,27 +1676,11 @@ private fun InboxPanePlaceholder() { * Vault (plugin gatecontrol-vault): the tab and its pages, each a route of its own so the tab bar shows on the list * only and back works as everywhere else. */ -private fun NavGraphBuilder.vaultRoutes(navController: NavHostController, navigateToTab: (String) -> Unit) { +private fun NavGraphBuilder.vaultRoutes(navController: NavHostController, navigateToTab: (String) -> Unit, home: @Composable () -> Unit) { val back: () -> Unit = { if (!navController.popBackStack()) navigateToTab(Screen.Vault.route) } val go: (String) -> Unit = { route -> navController.navigate(route) { launchSingleTop = true } } val recover: () -> Unit = { go(Screen.VaultRecover.route) } - composable(Screen.Vault.route) { - GcContentWidth { - com.gatecontrol.android.ui.vault.VaultScreen( - com.gatecontrol.android.ui.vault.VaultNav( - setup = { go(Screen.VaultSetup.route) }, - recover = recover, - entry = { go(Screen.VaultEntry.create(it)) }, - newEntry = { type, vault -> go(Screen.VaultEdit.create(type = type, vault = vault)) }, - vaults = { go(Screen.VaultVaults.route) }, - emergency = { go(Screen.VaultEmergency.route) }, - import = { go(Screen.VaultImport.route) }, - trash = { go(Screen.VaultTrash.route) }, - settings = { go(Screen.VaultSettings.route) }, - ), - ) - } - } + composable(Screen.Vault.route) { home() } composable(Screen.VaultSetup.route) { GcContentWidth { com.gatecontrol.android.ui.vault.VaultSetupScreen(onBack = back, onDone = back) } } @@ -1484,18 +1750,35 @@ private fun NavGraphBuilder.vaultRoutes(navController: NavHostController, naviga } } +/** The Vault tab's screen (as the tab or as a page beside Start); its pages are routes of their own. */ +@Composable +private fun VaultHome(navController: NavHostController) { + val go: (String) -> Unit = { route -> navController.navigate(route) { launchSingleTop = true } } + GcContentWidth { + com.gatecontrol.android.ui.vault.VaultScreen( + com.gatecontrol.android.ui.vault.VaultNav( + setup = { go(Screen.VaultSetup.route) }, + recover = { go(Screen.VaultRecover.route) }, + entry = { go(Screen.VaultEntry.create(it)) }, + newEntry = { type, vault -> go(Screen.VaultEdit.create(type = type, vault = vault)) }, + vaults = { go(Screen.VaultVaults.route) }, + emergency = { go(Screen.VaultEmergency.route) }, + import = { go(Screen.VaultImport.route) }, + trash = { go(Screen.VaultTrash.route) }, + settings = { go(Screen.VaultSettings.route) }, + ), + ) + } +} + /** The folder the Drive tab's Files page shows (kept in the tab's back stack entry). */ private const val DRIVE_TAB_SHOWN = "drive_shown" -/** - * Drive (plugin gatecontrol-drive): the tab, a folder, the trash and the search, each a route of its own so the tab - * bar shows on the tab only and back works as everywhere else. - */ -private fun NavGraphBuilder.driveRoutes(navController: NavHostController, navigateToTab: (String) -> Unit) { - val back: () -> Unit = { if (!navController.popBackStack()) navigateToTab(Screen.Drive.route) } +/** Drive's ways between its pages (the tab, a page beside Start and the routes on top share them). */ +private class DriveNavigation(private val navController: NavHostController) { val go: (String) -> Unit = { route -> navController.navigate(route) { launchSingleTop = true } } /** The folder the page on top shows (null: no folder page). */ - val topFolder: () -> Long? = { + private val topFolder: () -> Long? = { navController.currentBackStackEntry?.takeIf { it.destination.route == Screen.DriveFolder.route } ?.arguments?.getString(Screen.DriveFolder.ARG_ID)?.toLongOrNull() } @@ -1523,26 +1806,53 @@ private fun NavGraphBuilder.driveRoutes(navController: NavHostController, naviga } val file: (Long) -> Unit = { go(Screen.DriveFile.create(it)) } val vault: (String, Long) -> Unit = { v, f -> navController.navigate(Screen.DriveVault.create(v, f)) } - composable(Screen.Drive.route) { entry -> - GcContentWidth { - com.gatecontrol.android.ui.drive.DriveHomeScreen( - com.gatecontrol.android.ui.drive.DriveNav( - folder = folder, - shown = { id -> entry.savedStateHandle[DRIVE_TAB_SHOWN] = id }, - search = { go(Screen.DriveSearch.route) }, - trash = { go(Screen.DriveTrash.route) }, - storage = { go(Screen.DriveStorage.route) }, - folders = { go(Screen.DriveFolders.route) }, - album = { go(Screen.DriveAlbum.create(it)) }, - file = file, - deadlines = { go(Screen.DriveDeadlines.route) }, - vault = vault, - settings = { go(Screen.DriveSettings.route) }, - cleanup = { go(Screen.DriveCleanup.route) }, - ), - ) - } +} + +/** + * The Drive tab's screen (as the tab or as a page beside Start); [onShown] + * gets the folder its Files page shows (the tab keeps it for the path above + * a folder). + */ +@Composable +private fun DriveHome(navController: NavHostController, onShown: (Long?) -> Unit) { + val nav = remember(navController) { DriveNavigation(navController) } + GcContentWidth { + com.gatecontrol.android.ui.drive.DriveHomeScreen( + com.gatecontrol.android.ui.drive.DriveNav( + folder = nav.folder, + shown = onShown, + search = { nav.go(Screen.DriveSearch.route) }, + trash = { nav.go(Screen.DriveTrash.route) }, + storage = { nav.go(Screen.DriveStorage.route) }, + folders = { nav.go(Screen.DriveFolders.route) }, + album = { nav.go(Screen.DriveAlbum.create(it)) }, + file = nav.file, + deadlines = { nav.go(Screen.DriveDeadlines.route) }, + vault = nav.vault, + settings = { nav.go(Screen.DriveSettings.route) }, + cleanup = { nav.go(Screen.DriveCleanup.route) }, + ), + ) } +} + +/** + * Drive (plugin gatecontrol-drive): the tab, a folder, the trash and the search, each a route of its own so the tab + * bar shows on the tab only and back works as everywhere else. + */ +private fun NavGraphBuilder.driveRoutes( + navController: NavHostController, + navigateToTab: (String) -> Unit, + home: @Composable (androidx.navigation.NavBackStackEntry) -> Unit, +) { + val back: () -> Unit = { if (!navController.popBackStack()) navigateToTab(Screen.Drive.route) } + val nav = DriveNavigation(navController) + val go = nav.go + val folder = nav.folder + val crumb = nav.crumb + val file = nav.file + val vault = nav.vault + composable(Screen.Drive.route) { entry -> home(entry) } composable(Screen.DriveFolder.route, arguments = listOf(navArgument(Screen.DriveFolder.ARG_ID) { type = NavType.StringType })) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveFolderScreen(onBack = back, onFolder = folder, onFile = file, onVault = vault, onCrumb = crumb) } } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt b/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt index 10e498d0..2cb28eab 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt @@ -1,6 +1,7 @@ package com.gatecontrol.android.navigation import com.gatecontrol.android.familie.PluginAvailability +import com.gatecontrol.android.layout.StartPages import com.gatecontrol.android.ui.network.NetworkTabs /** @@ -46,4 +47,64 @@ internal object MainTabs { Screen.Rdp.route -> Screen.Network.create(NetworkTabs.REMOTE) else -> null } + + /** The page id ([StartPages]) of a tab's route (the route pattern); null for Start. */ + fun pageOf(route: String): String? = when (route) { + Screen.Chats.route -> StartPages.CHATS + Screen.Vault.route -> StartPages.VAULT + Screen.Drive.route -> StartPages.DRIVE + Screen.Network.route -> StartPages.NETWORK + Screen.Familie.route -> StartPages.FAMILIE + else -> null + } + + /** The tab's route pattern of a function page (the reverse of [pageOf]). */ + fun routeOf(page: String): String? = when (page) { + StartPages.CHATS -> Screen.Chats.route + StartPages.VAULT -> Screen.Vault.route + StartPages.DRIVE -> Screen.Drive.route + StartPages.NETWORK -> Screen.Network.route + StartPages.FAMILIE -> Screen.Familie.route + else -> null + } + + /** + * The page a route to navigate to (with its arguments, e.g. + * `network?tab=remote`) shows when its function is pinned to Start; + * null for Start and for the screens on top of a tab (`chats/c/7`). + */ + fun pageFor(route: String): String? = when (route.substringBefore('?')) { + Screen.Chats.BASE -> StartPages.CHATS + Screen.Vault.route -> StartPages.VAULT + Screen.Drive.route -> StartPages.DRIVE + Screen.Network.BASE -> StartPages.NETWORK + Screen.Familie.BASE -> StartPages.FAMILIE + else -> null + } + + /** The argument [name] of a route to navigate to (`familie?dest=…` → its dest, decoded), null when it has none. */ + fun arg(route: String, name: String): String? { + val query = route.substringAfter('?', "") + if (query.isEmpty()) return null + return query.split('&').firstNotNullOfOrNull { part -> + val key = part.substringBefore('=') + if (key == name) java.net.URLDecoder.decode(part.substringAfter('=', ""), "UTF-8").takeIf { it.isNotEmpty() } else null + } + } + + /** The functions of the tab [routes] that can be pinned to Start (all but Start). */ + fun features(routes: List): List = routes.mapNotNull(::pageOf) + + /** The tabs left in the bar: [routes] without the functions pinned to Start ([pinned] page ids); Start always stays. */ + fun visible(routes: List, pinned: Collection): List = + routes.filter { route -> pageOf(route)?.let { it in pinned } != true } + + /** + * Whether the bar (or the rail) is left out: only when the person wants + * that ([hideWhenOnlyStart], "Leiste ausblenden, wenn nur Start + * bleibt"), only Start is left as a tab and there are pages beside it + * to reach by swiping (else nothing changes for those without pages). + */ + fun hideBar(visibleTabs: List, pages: List, hideWhenOnlyStart: Boolean): Boolean = + hideWhenOnlyStart && visibleTabs.size <= 1 && pages.isNotEmpty() } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index ba66e01f..307d9b24 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -240,7 +240,19 @@ sealed class Screen(val route: String) { data object LocationSharing : Screen("settings/location") /** Diagnose & Problem melden (Settings › Hilfe, start screen on connection problems). */ data object Diagnosis : Screen("settings/diagnosis") - data object QuickActions : Screen("quick-actions") + /** + * "Schnellaktionen und Widgets"; `page` = the widget page beside Start + * it was opened from (what is pinned there joins that page). + */ + data object QuickActions : Screen("quick-actions?page={page}") { + const val ARG_PAGE = "page" + const val BASE = "quick-actions" + fun create(page: String? = null): String = if (page == null) BASE else "$BASE?page=$page" + } + /** "Seiten anordnen": the pages beside Start (StartPages), from a page's "⋯" and the settings. */ + data object StartPagesArrange : Screen("start-pages") + /** Einstellungen → "Leiste und Seiten": each function as tab or page beside Start. */ + data object SettingsPages : Screen("settings/pages") /** * New personal reminder for a device ("Erinnerung für dieses Gerät" in * a start-tab widget's settings): the reminder editor with the action diff --git a/app/src/main/java/com/gatecontrol/android/ui/chat/ChatListScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/chat/ChatListScreen.kt index a174fd33..cd09dd13 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/chat/ChatListScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/chat/ChatListScreen.kt @@ -125,6 +125,8 @@ fun ChatListScreen( if (!searching) viewModel.setQuery("") }) GcIconButton(GcIcons.Settings, stringResource(R.string.chat_settings_title), onClick = onOpenSettings) + // "⋯" of the page when Chats is pinned to Start. + com.gatecontrol.android.ui.start.StartPageMenuButton() } if (searching) { GcTextField( 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 f62de4bf..1d6adba2 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 @@ -280,7 +280,8 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel state = pager, modifier = Modifier.weight(1f), // ticked items keep their page (the selection's bars belong to it) - userScrollEnabled = DriveLogic.tabSwipe(hasPhotos, selecting), + // 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 }, ) { page -> if (DriveLogic.isPhotosTab(page, hasPhotos)) { @@ -437,6 +438,8 @@ private fun TabBar(title: String, onMenu: (() -> Unit)?, onSearch: (() -> Unit)? GcScreenTitle(title, modifier = Modifier.weight(1f).padding(start = 4.dp)) actions() if (onSearch != null) GcIconButton(GcIcons.Search, contentDescription = stringResource(R.string.drive_search), onClick = onSearch, tint = MaterialTheme.colorScheme.onBackground) + // "⋯" of the page when Drive is pinned to Start. + com.gatecontrol.android.ui.start.StartPageMenuButton() } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/familie/FamilieHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/familie/FamilieHomeScreen.kt index b6ab4fd7..e30838f1 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/familie/FamilieHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/familie/FamilieHomeScreen.kt @@ -211,6 +211,8 @@ private fun Header(profile: FamilieProfile?, onArrange: (() -> Unit)?) { } } } + // "⋯" of the page when Familie is pinned to Start. + com.gatecontrol.android.ui.start.StartPageMenuButton() } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/familie/SchoolScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/familie/SchoolScreen.kt index cf5df0ea..bf48d433 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/familie/SchoolScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/familie/SchoolScreen.kt @@ -264,7 +264,9 @@ private fun Plan( onHorizontalDrag = { _, amount -> drag += amount }, ) } - Column(swipe.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // As (part of) a page beside Start, swiping changes the page: the days by tapping only. + val daySwipe = if (com.gatecontrol.android.ui.start.LocalInStartPager.current) Modifier else swipe + Column(daySwipe.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { GcSectionLabel(dayHeading(date.toString()), Modifier.padding(start = 4.dp, top = 4.dp)) day?.holiday?.let { h -> GcBanner(tone = GcTone.Info, icon = GcIcons.Calendar) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/network/NetworkScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/network/NetworkScreen.kt index e7130ca7..d2ba734c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/network/NetworkScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/network/NetworkScreen.kt @@ -3,6 +3,8 @@ package com.gatecontrol.android.ui.network import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -98,7 +100,11 @@ fun NetworkScreen( Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - GcScreenTitle(stringResource(R.string.nav_network)) + Row(verticalAlignment = Alignment.CenterVertically) { + GcScreenTitle(stringResource(R.string.nav_network), Modifier.weight(1f)) + // "⋯" of the page when Netzwerk is pinned to Start. + com.gatecontrol.android.ui.start.StartPageMenuButton() + } if (available.size > 1 && current != null) { GcSegmented( options = available, diff --git a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt index 2bec6b62..744524f0 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsScreen.kt @@ -97,6 +97,8 @@ fun QuickActionsScreen( onOpenDoor: (() -> Unit)? = null, /** "Erinnerung für dieses Gerät" in the pin sheet: the reminder editor for the action `/` (null: no such row). */ onNewReminder: ((actionId: String) -> Unit)? = null, + /** The widget page beside Start the list was opened from: what is pinned here joins it (null: Start). */ + page: String? = null, viewModel: QuickActionsViewModel = hiltViewModel(), ) { val devices by viewModel.devices.collectAsStateWithLifecycle() @@ -186,8 +188,8 @@ fun QuickActionsScreen( device = open, pinnedActionIds = open.actions.filter { it.key in pinnedKeys }.map { it.id }.toSet(), widgetPin = widgets.firstOrNull { it.resolve(devices)?.key == open.key }, - onSaveButtons = { ids -> viewModel.setButtons(open, ids); sheetFor = null }, - onSaveWidget = { parts, wide -> viewModel.setWidget(open, parts, wide); sheetFor = null }, + onSaveButtons = { ids -> viewModel.setButtons(open, ids, page); sheetFor = null }, + onSaveWidget = { parts, wide -> viewModel.setWidget(open, parts, wide, page); sheetFor = null }, onRemoveWidget = { viewModel.removeWidget(open.key); sheetFor = null }, onDismiss = { sheetFor = null }, onReminder = onNewReminder?.let { start -> { sheetFor = null; start(open.carrier?.key ?: open.actions.first().key) } }, @@ -263,11 +265,12 @@ private const val REFRESH_MS = 60_000L * "Ausführen?" and the pin sheet a long press on a widget or button opens * (with "Erinnerung für dieses Gerät" when [onNewReminder] is given — it * gets the action's key `/` — and "Start-Tab anordnen", - * [onArrange]). + * [onArrange] with the page the sheet was opened on, null: Start). Once + * for Start and its pages together (they share the view model). */ @Composable fun QuickActionsEffects( - onArrange: () -> Unit, + onArrange: (page: String?) -> Unit, onNewReminder: ((actionId: String) -> Unit)? = null, viewModel: QuickActionsViewModel = hiltViewModel(), ) { @@ -306,8 +309,8 @@ fun QuickActionsEffects( device = device, pinnedActionIds = device.actions.filter { it.key in pinnedKeys }.map { it.id }.toSet(), widgetPin = widgets.firstOrNull { it.resolve(devices)?.key == device.key }, - onSaveButtons = { ids -> viewModel.setButtons(device, ids); viewModel.closeSheet() }, - onSaveWidget = { parts, wide -> viewModel.setWidget(device, parts, wide); viewModel.closeSheet() }, + onSaveButtons = { ids -> viewModel.setButtons(device, ids, request.page); viewModel.closeSheet() }, + onSaveWidget = { parts, wide -> viewModel.setWidget(device, parts, wide, request.page); viewModel.closeSheet() }, onRemoveWidget = { viewModel.removeWidget(device.key); viewModel.closeSheet() }, onDismiss = viewModel::closeSheet, fromStart = request.fromStart, @@ -318,7 +321,7 @@ fun QuickActionsEffects( open(request.action ?: device.carrier?.key ?: device.actions.first().key) } }, - onArrange = { viewModel.closeSheet(); onArrange() }, + onArrange = { viewModel.closeSheet(); onArrange(request.page) }, arrangeLocked = access.locked, ) } @@ -337,20 +340,25 @@ fun StartTiles( modifier: Modifier = Modifier, /** Long press on a door action: the door screen (null: door plugin not listed). */ onOpenDoor: (() -> Unit)? = null, + /** The widget page beside Start these tiles stand on (null: Start). */ + page: String? = null, viewModel: QuickActionsViewModel = hiltViewModel(), ) { val pins by viewModel.pins.collectAsStateWithLifecycle() val actions by viewModel.actions.collectAsStateWithLifecycle() val devices by viewModel.devices.collectAsStateWithLifecycle() val widgetPins by viewModel.widgets.collectAsStateWithLifecycle() - val entries by viewModel.entries.collectAsStateWithLifecycle() + val pages by viewModel.pages.collectAsStateWithLifecycle() val running by viewModel.running.collectAsStateWithLifecycle() val extra = GateControlTheme.extraColors val hasWidgets = widgetPins.any { it.resolve(devices) != null } - val order = entries.filterIsInstance().map { it.action } + val order = pages[page].orEmpty().filterIsInstance().map { it.action } val settingsLabel = stringResource(R.string.start_tile_settings) + // Only this page's buttons (a button on another page beside Start is not shown here). + val here = order.toSet() + val shown = StartLayout.ordered(pins.filter { it.key in here }, order) { it.key } - when (val row = QuickActionsRow.of(StartLayout.ordered(pins, order) { it.key }, actions, hasWidgets)) { + when (val row = QuickActionsRow.of(shown, actions, hasWidgets || page != null)) { QuickActionsRow.Hidden -> Unit QuickActionsRow.Choose -> Text( stringResource(R.string.start_edit_hint), @@ -376,7 +384,7 @@ fun StartTiles( width = tile, running = running == item.key, onClick = { viewModel.ask(item) }, - onLongPress = door ?: { viewModel.openSheet(PinSheetRequest(item.deviceKey, widget = false, action = item.key)) }, + onLongPress = door ?: { viewModel.openSheet(PinSheetRequest(item.deviceKey, widget = false, action = item.key, page = page)) }, longPressLabel = if (door != null) stringResource(R.string.door_open_screen) else settingsLabel, ) } @@ -399,6 +407,8 @@ fun StartCards( usage: @Composable () -> Unit, modifier: Modifier = Modifier, onOpenDoor: (() -> Unit)? = null, + /** The widget page beside Start these cards stand on (null: Start). */ + page: String? = null, viewModel: QuickActionsViewModel = hiltViewModel(), ) { val devices by viewModel.devices.collectAsStateWithLifecycle() @@ -427,7 +437,7 @@ fun StartCards( running = running, adjusting = adjusting, onRun = viewModel::ask, - onLongPress = door ?: { viewModel.openSheet(PinSheetRequest(device.key, widget = true)) }, + onLongPress = door ?: { viewModel.openSheet(PinSheetRequest(device.key, widget = true, page = page)) }, longPressLabel = if (door != null) doorLabel else settingsLabel, onControl = { carrier, control, value -> viewModel.control(carrier, control, value) }, modifier = cardModifier, diff --git a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt index 43f60656..a737abc1 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/quickactions/QuickActionsViewModel.kt @@ -35,9 +35,16 @@ import kotlinx.coroutines.launch /** * A pin sheet to show: the device [deviceKey], opened from a start-tab * widget ([widget]) or button ([action]: its key), [fromStart] by a long - * press on the start tab (only that side, with the reminder and arranging rows). + * press on the start tab (only that side, with the reminder and arranging rows); + * [page] the widget page beside Start it was opened on (null: Start). */ -data class PinSheetRequest(val deviceKey: String, val widget: Boolean, val action: String? = null, val fromStart: Boolean = true) +data class PinSheetRequest( + val deviceKey: String, + val widget: Boolean, + val action: String? = null, + val fromStart: Boolean = true, + val page: String? = null, +) /** The list "Schnellaktionen und Widgets", the pin sheet and the start tab's buttons and widgets. */ @HiltViewModel @@ -51,6 +58,9 @@ class QuickActionsViewModel @Inject constructor( /** The start tab's blocks in their order (tiles, widgets, Info and usage card). */ val entries: StateFlow> = arrangement.entries + /** The blocks of the start tab (key null) and of each widget page beside it. */ + val pages: StateFlow>> = arrangement.pages + /** Where the order lives and whether this person may change it. */ val layoutAccess: StateFlow = arrangement.access @@ -135,18 +145,21 @@ class QuickActionsViewModel @Inject constructor( _confirming.value = null } - /** The device's buttons on the start tab become exactly [actionIds]. */ - fun setButtons(device: QuickActionDeviceGroup, actionIds: Set) { + /** + * The device's buttons on the start tab become exactly [actionIds]; + * new ones join [page] (a widget page beside Start, null: Start). + */ + fun setButtons(device: QuickActionDeviceGroup, actionIds: Set, page: String? = null) { viewModelScope.launch { repository.setButtons(device, actionIds) - arrangement.syncFromPins() + arrangement.syncFromPins(page) } } - fun setWidget(device: QuickActionDeviceGroup, parts: List, wide: Boolean) { + fun setWidget(device: QuickActionDeviceGroup, parts: List, wide: Boolean, page: String? = null) { viewModelScope.launch { repository.setWidget(device, parts, wide) - arrangement.syncFromPins() + arrangement.syncFromPins(page) } } @@ -157,9 +170,9 @@ class QuickActionsViewModel @Inject constructor( } } - /** Arranged on "Start-Tab anordnen": the new order and widths. */ - fun saveArrangement(entries: List) { - viewModelScope.launch { arrangement.save(entries) } + /** Arranged on "Start-Tab anordnen" (or a widget page's [page]): the new order and widths. */ + fun saveArrangement(entries: List, page: String? = null) { + viewModelScope.launch { arrangement.save(entries, page) } } /** "−" on a tile while arranging: the button leaves the start tab. */ diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsPane.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsPane.kt index 69316c85..3fecdbba 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsPane.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsPane.kt @@ -14,6 +14,8 @@ enum class SettingsPane(val key: String) { TrustedNetworks("trusted-networks"), Location("location"), Notifications("notifications"), + /** "Leiste und Seiten": functions as tabs or as pages beside Start. */ + Pages("pages"), /** GateDrop "Geteilt" (only while the server offers GateDrop). */ Shared("shared"), Logs("logs"), diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index f0f4fbae..c5a78f43 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -45,6 +45,7 @@ import com.gatecontrol.android.ui.components.GcPolicySystemVpnCard import com.gatecontrol.android.ui.components.GcSubpageBar import com.gatecontrol.android.ui.components.GcSecondaryButton import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.components.GcRowDivider import com.gatecontrol.android.ui.components.GcSegmented import com.gatecontrol.android.ui.components.GcSwitchRow import com.gatecontrol.android.ui.components.GcTone @@ -85,6 +86,8 @@ fun SettingsScreen( onNavigateToShared: (() -> Unit)? = null, /** "Drive-Einstellungen" (backup, vaults, data on this phone …); null hides the row (no Drive on the server). */ onNavigateToDrive: (() -> Unit)? = null, + /** "Leiste und Seiten" (functions as tabs or as pages beside Start). */ + onNavigateToPages: (() -> Unit)? = null, /** Page shown next to the list on wide screens; its row is highlighted. */ selectedPane: SettingsPane? = null, viewModel: SettingsViewModel = hiltViewModel(), @@ -327,6 +330,17 @@ fun SettingsScreen( // --- Darstellung --------------------------------------------------- SettingsGroup(stringResource(R.string.settings_group_appearance)) { + if (onNavigateToPages != null) { + GcListRow( + title = stringResource(R.string.pages_settings_title), + description = stringResource(R.string.pages_settings_row_desc), + onClick = onNavigateToPages, + leading = { Icon(GcIcons.ViewGrid, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { GcChevron() }, + selected = selectedPane == SettingsPane.Pages, + ) + GcRowDivider(Modifier.padding(horizontal = 16.dp)) + } SegmentRow(stringResource(R.string.settings_theme)) { GcSegmented( options = listOf( diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt index bbad4006..e60e54f5 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartArrangeScreen.kt @@ -85,11 +85,15 @@ fun StartArrangeScreen( onDone: () -> Unit, onAddTile: () -> Unit, modifier: Modifier = Modifier, + /** A widget page beside Start to arrange instead of the start tab, with its [title]. */ + page: String? = null, + title: String? = null, viewModel: QuickActionsViewModel = hiltViewModel(), ) { val extra = GateControlTheme.extraColors val haptic = LocalHapticFeedback.current - val entries by viewModel.entries.collectAsStateWithLifecycle() + val pages by viewModel.pages.collectAsStateWithLifecycle() + val entries = pages[page].orEmpty() val access by viewModel.layoutAccess.collectAsStateWithLifecycle() val pins by viewModel.pins.collectAsStateWithLifecycle() val devices by viewModel.devices.collectAsStateWithLifecycle() @@ -98,7 +102,7 @@ fun StartArrangeScreen( var working by remember { mutableStateOf?>(null) } val list = working ?: entries val finish = { - working?.let { viewModel.saveArrangement(it) } + working?.let { viewModel.saveArrangement(it, page) } onDone() } BackHandler(onBack = finish) @@ -128,7 +132,7 @@ fun StartArrangeScreen( Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { ArrangeBar( - title = stringResource(R.string.start_arrange), + title = title ?: stringResource(R.string.start_arrange), hint = stringResource(R.string.start_arrange_hint), scope = access, onDone = finish, @@ -175,8 +179,10 @@ fun StartArrangeScreen( Text(stringResource(R.string.arrange_add_tile), style = MaterialTheme.typography.labelLarge, color = extra.muted) } } - item(key = "h:cards", span = { GridItemSpan(COLUMNS) }) { - GcSectionLabel(stringResource(R.string.arrange_cards), Modifier.padding(start = 4.dp, top = 8.dp)) + if (page == null || cards.isNotEmpty()) { + item(key = "h:cards", span = { GridItemSpan(COLUMNS) }) { + GcSectionLabel(stringResource(R.string.arrange_cards), Modifier.padding(start = 4.dp, top = 8.dp)) + } } items(cards, key = { it.key }, span = { GridItemSpan(if (it.isHalf) COLUMNS / 2 else COLUMNS) }) { card -> val (title, icon, accent) = when (card) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt new file mode 100644 index 00000000..2fcbde58 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt @@ -0,0 +1,146 @@ +package com.gatecontrol.android.ui.start + +import android.content.res.Resources +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.gatecontrol.android.R +import com.gatecontrol.android.layout.StartPages +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** + * What the "⋯" of a page beside Start offers: [label] the page's name, + * [widgets] a widget page ("Seite entfernen" instead of "Als Tab + * anzeigen"), [locked] the admin fixes the order (both off, with the hint). + */ +class StartPageMenu( + val label: String, + val widgets: Boolean, + val locked: Boolean, + val onUnpin: () -> Unit, + val onArrange: () -> Unit, +) + +/** + * The menu of the page beside Start a tab's screen is shown on (null: it + * is shown as a tab). The screen's own title row shows [StartPageMenuButton]. + */ +val LocalStartPageMenu = compositionLocalOf { null } + +/** + * True while a screen is a page beside Start: swiping sideways changes the + * page there, so its own segments and tabs change by tapping only (no + * swiping of their own). + */ +val LocalInStartPager = staticCompositionLocalOf { false } + +/** Shows a snackbar with [message] and, given [undo], "Rückgängig" (AppNavigation). */ +val LocalPageSnack = staticCompositionLocalOf<(message: String, undo: (() -> Unit)?) -> Unit> { { _, _ -> } } + +/** "⋯" with the page's menu, when the screen is a page beside Start ([LocalStartPageMenu]); nothing otherwise. */ +@Composable +fun StartPageMenuButton(modifier: Modifier = Modifier) { + val menu = LocalStartPageMenu.current ?: return + StartPageMenuButton(menu, modifier) +} + +@Composable +fun StartPageMenuButton(menu: StartPageMenu, modifier: Modifier = Modifier) { + val extra = GateControlTheme.extraColors + var open by remember { mutableStateOf(false) } + Box(modifier) { + GcIconButton(GcIcons.MoreHorizontal, contentDescription = stringResource(R.string.pages_menu_desc), onClick = { open = true }) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { + Column { + Text(stringResource(if (menu.widgets) R.string.pages_remove else R.string.pages_as_tab)) + val hint = when { + menu.locked -> R.string.arrange_locked + menu.widgets -> null + else -> R.string.pages_as_tab_desc + } + if (hint != null) { + Text( + stringResource(hint), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.widthIn(max = 220.dp), + ) + } + } + }, + leadingIcon = { Icon(if (menu.widgets) GcIcons.Trash else GcIcons.Pin, contentDescription = null, modifier = Modifier.size(20.dp)) }, + enabled = !menu.locked, + onClick = { open = false; menu.onUnpin() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.pages_arrange)) }, + leadingIcon = { Icon(StartIcons.Arrange, contentDescription = null, modifier = Modifier.size(20.dp)) }, + enabled = !menu.locked, + onClick = { open = false; menu.onArrange() }, + ) + } + } +} + +/** Ids of Start and the plus page in the pager (no page of [StartPages] has them: neither is a function or `w`). */ +internal const val START_PAGE = "start" +internal const val PLUS_PAGE = "plus" + +/** The name of the page [id] ("Netzwerk", "Widgets 2" …). */ +@Composable +fun startPageLabel(id: String): String = startPageLabel(LocalResources.current, id) + +fun startPageLabel(resources: Resources, id: String): String { + val widgets = StartPages.widgetsNumber(id) + return when { + id == START_PAGE -> resources.getString(R.string.nav_start) + id == PLUS_PAGE -> resources.getString(R.string.pages_plus_title) + widgets != null -> resources.getString(R.string.pages_widgets_name, widgets) + else -> featureLabel(id)?.let { resources.getString(it) } ?: id + } +} + +/** The tab's name of a function page. */ +fun featureLabel(id: String): Int? = when (id) { + StartPages.CHATS -> R.string.nav_chats + StartPages.VAULT -> R.string.vault_tab + StartPages.DRIVE -> R.string.drive_tab + StartPages.NETWORK -> R.string.nav_network + StartPages.FAMILIE -> R.string.nav_familie + else -> null +} + +/** The tab's icon of a function page, a grid for a widget page. */ +fun startPageIcon(id: String): ImageVector = when (id) { + START_PAGE -> GcIcons.Shield + StartPages.CHATS -> Icons.AutoMirrored.Outlined.Chat + StartPages.VAULT -> GcIcons.Vault + StartPages.DRIVE -> GcIcons.Cloud + StartPages.NETWORK -> GcIcons.Globe + StartPages.FAMILIE -> GcIcons.Family + else -> GcIcons.ViewGrid +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt new file mode 100644 index 00000000..e4d76e59 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPager.kt @@ -0,0 +1,610 @@ +package com.gatecontrol.android.ui.start + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources +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.selected +import androidx.compose.ui.semantics.semantics +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.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.layout.StartEntry +import com.gatecontrol.android.layout.StartPages +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcListRow +import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcScreenTitle +import com.gatecontrol.android.ui.components.GcSecondaryButton +import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.quickactions.QuickActionsEffects +import com.gatecontrol.android.ui.quickactions.QuickActionsViewModel +import com.gatecontrol.android.ui.quickactions.StartCards +import com.gatecontrol.android.ui.quickactions.StartTiles +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.vpn.HomeColumn +import com.gatecontrol.android.ui.vpn.StartEditSheet +import kotlinx.coroutines.launch + +/** + * A page of Start to show: a tab's route opened (push, deep link, a tap + * on Start) while its function is a page beside Start. [route] is the + * route asked for (its arguments, e.g. the Netzwerk segment); [seq] makes + * every request a new one. + */ +data class StartPageRequest(val page: String, val route: String? = null, val seq: Long = System.nanoTime()) + +/** + * Start as a row of pages (mockup "Start-Seiten"): the start tab first, + * then the pages pinned to it ([pages]: functions shown with their tab's + * own screen via [feature], widget pages like Start), the plus page last + * to pin another. Swiping changes the page; the dots below change it too + * (with the badges of [badges], e.g. unread chats). Back on another page + * goes to Start first. [names]: the bar is hidden, the dots get a line with + * the pages' names. The quick actions' sheets and "Anordnen" are here once + * for Start and its widget pages. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StartPager( + pages: List, + candidates: List, + badges: Map, + request: StartPageRequest?, + /** The last request per page: its route's arguments stay with the page until the next one for it. */ + requests: Map, + names: Boolean, + onArrangePages: () -> Unit, + onOpenQuickActions: (page: String?) -> Unit, + onNewReminder: ((actionId: String) -> Unit)?, + onOpenDoor: (() -> Unit)?, + start: @Composable (onArrange: () -> Unit) -> Unit, + feature: @Composable (id: String, request: StartPageRequest?) -> Unit, + viewModel: StartPagesViewModel = hiltViewModel(), + quickActions: QuickActionsViewModel = hiltViewModel(), +) { + val access by viewModel.access.collectAsStateWithLifecycle() + val blocks by viewModel.blocks.collectAsStateWithLifecycle() + val locked = access.locked + val resources = LocalResources.current + val snack = LocalPageSnack.current + val scope = rememberCoroutineScope() + var arranging by rememberSaveable { mutableStateOf(null) } + var focus by remember { mutableStateOf(null) } + var sheet by rememberSaveable { mutableStateOf(false) } + var removing by rememberSaveable { mutableStateOf(null) } + + QuickActionsEffects( + onArrange = { page -> arranging = page ?: START_PAGE }, + onNewReminder = onNewReminder, + viewModel = quickActions, + ) + + val ids = remember(pages) { listOf(START_PAGE) + pages + PLUS_PAGE } + val currentIds by rememberUpdatedState(ids) + // Before arranging may replace the pages: afterwards the same page shows again. + val pagerState = rememberPagerState { currentIds.size } + + // "Start-Tab anordnen" (or a widget page's) replaces the pages until "Fertig". + val arrangingPage = arranging + if (arrangingPage != null && !locked) { + val page = arrangingPage.takeIf { it != START_PAGE } + StartArrangeScreen( + onDone = { arranging = null }, + onAddTile = { onOpenQuickActions(page) }, + page = page, + title = page?.let { resources.getString(R.string.pages_widgets_arrange, startPageLabel(resources, it)) }, + viewModel = quickActions, + ) + return + } + + // A page asked for from outside (a tab's route, Start in the bar), once it exists. + var handled by rememberSaveable { mutableLongStateOf(0L) } + LaunchedEffect(request, ids) { + val r = request ?: return@LaunchedEffect + if (r.seq == handled) return@LaunchedEffect + val index = ids.indexOf(r.page) + if (index >= 0) { + handled = r.seq + pagerState.animateScrollToPage(index) + } + } + // A page pinned here: shown as soon as it is there. + LaunchedEffect(focus, ids) { + val f = focus ?: return@LaunchedEffect + val index = ids.indexOf(f) + if (index >= 0) { + focus = null + pagerState.animateScrollToPage(index) + } + } + // Back on a page beside Start: Start first, then the app as before. + BackHandler(enabled = pagerState.currentPage > 0) { + scope.launch { pagerState.animateScrollToPage(0) } + } + + val pin: (String) -> Unit = { id -> + sheet = false + focus = id + viewModel.pin(id) { undo -> snack(resources.getString(R.string.pages_snack_pinned, startPageLabel(resources, id)), undo) } + } + val addWidgets: () -> Unit = { + sheet = false + viewModel.addWidgets { id, undo -> + focus = id + snack(resources.getString(R.string.pages_snack_pinned, startPageLabel(resources, id)), undo) + } + } + val unpinNow: (String) -> Unit = { id -> + val message = if (StartPages.isWidgets(id)) { + resources.getString(R.string.pages_snack_removed) + } else { + resources.getString(R.string.pages_snack_unpinned, startPageLabel(resources, id)) + } + viewModel.unpin(id) { undo -> snack(message, undo) } + } + // A widget page with blocks: ask first (its blocks go back to Start). + val unpin: (String) -> Unit = { id -> + if (StartPages.isWidgets(id) && blocks[id].orEmpty().isNotEmpty()) removing = id else unpinNow(id) + } + fun menu(id: String) = StartPageMenu( + label = startPageLabel(resources, id), + widgets = StartPages.isWidgets(id), + locked = locked, + onUnpin = { unpin(id) }, + onArrange = onArrangePages, + ) + + Column(Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f).fillMaxWidth(), + key = { index -> currentIds.getOrElse(index) { index } }, + verticalAlignment = Alignment.Top, + ) { index -> + val id = currentIds.getOrNull(index) ?: return@HorizontalPager + Box(Modifier.fillMaxSize()) { + when { + id == START_PAGE -> start { arranging = START_PAGE } + id == PLUS_PAGE -> PlusPage(onOpen = { sheet = true }) + StartPages.isWidgets(id) -> CompositionLocalProvider(LocalInStartPager provides true) { + WidgetPage( + id = id, + menu = menu(id), + locked = locked, + onArrange = { arranging = id }, + onOpenQuickActions = { onOpenQuickActions(id) }, + onOpenDoor = onOpenDoor, + viewModel = quickActions, + ) + } + else -> CompositionLocalProvider(LocalStartPageMenu provides menu(id), LocalInStartPager provides true) { + feature(id, requests[id]) + } + } + } + } + PageDots( + ids = ids, + current = pagerState.currentPage, + badges = badges, + names = names, + onSelect = { index -> scope.launch { pagerState.animateScrollToPage(index) } }, + ) + } + + if (sheet) { + PinPageSheet( + candidates = candidates, + locked = locked, + onPin = pin, + onAddWidgets = addWidgets, + onDismiss = { sheet = false }, + ) + } + removing?.let { id -> + RemovePageDialog( + id = id, + count = blocks[id].orEmpty().size, + onConfirm = { removing = null; unpinNow(id) }, + onDismiss = { removing = null }, + ) + } +} + +/** "„Widgets 2“ entfernen?" for a widget page with [count] blocks (they go back to Start). */ +@Composable +internal fun RemovePageDialog(id: String, count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.pages_remove_confirm_title, startPageLabel(id))) }, + text = { Text(pluralStringResource(R.plurals.pages_remove_confirm_text, count, count)) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.pages_remove_confirm)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +/** + * The page dots above the bar: the current one a wide pill in the accent + * colour, the plus page a small "+"; a tap shows that page. A page's badge + * (unread chats, Remote hosts online) as a small number at its dot. [names] + * (no bar): a line with the pages' names below, the current one stronger. + */ +@Composable +private fun PageDots(ids: List, current: Int, badges: Map, names: Boolean, onSelect: (Int) -> Unit) { + val extra = GateControlTheme.extraColors + val scheme = MaterialTheme.colorScheme + val dotsLabel = stringResource(R.string.pages_dots_desc) + Column( + Modifier.fillMaxWidth().padding(top = 2.dp, bottom = if (names) 10.dp else 2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + Modifier.semantics { contentDescription = dotsLabel }, + verticalAlignment = Alignment.CenterVertically, + ) { + ids.forEachIndexed { index, id -> + val selected = index == current + val label = startPageLabel(id) + val badge = badges[id] ?: 0 + val description = if (badge > 0) { + stringResource(R.string.pages_dot_desc_badge, index + 1, ids.size, label, badge) + } else { + stringResource(R.string.pages_dot_desc, index + 1, ids.size, label) + } + val width by animateDpAsState(if (selected) 18.dp else 6.dp, label = "dot") + val color by animateColorAsState(if (selected) extra.accentText else extra.border2, label = "dotColor") + Box( + Modifier + .heightIn(min = 32.dp) + .widthIn(min = 22.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Tab, onClick = { onSelect(index) }) + .semantics { + contentDescription = description + this.selected = selected + }, + contentAlignment = Alignment.Center, + ) { + if (id == PLUS_PAGE) { + Icon(GcIcons.Plus, contentDescription = null, tint = color, modifier = Modifier.size(10.dp)) + } else { + Box(Modifier.height(6.dp).width(width).clip(RoundedCornerShape(3.dp)).background(color)) + } + if (badge > 0) { + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = 2.dp) + .height(14.dp) + .widthIn(min = 14.dp) + .clip(RoundedCornerShape(7.dp)) + .background(scheme.primary) + .padding(horizontal = 3.dp), + contentAlignment = Alignment.Center, + ) { + Text( + if (badge > 99) "99+" else badge.toString(), + color = scheme.onPrimary, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), + maxLines = 1, + ) + } + } + } + } + } + if (names) { + val labels = ids.filter { it != PLUS_PAGE }.map { startPageLabel(it) } + Text( + buildAnnotatedString { + labels.forEachIndexed { index, label -> + if (index > 0) append(" · ") + if (index == current) withStyle(SpanStyle(color = scheme.onSurface, fontWeight = FontWeight.SemiBold)) { append(label) } else append(label) + } + }, + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // The dots read out each page already. + modifier = Modifier.padding(horizontal = 16.dp).clearAndSetSemantics { }, + ) + } + } +} + +/** The plus page: a large dashed circle with "+" that opens [PinPageSheet]. */ +@Composable +private fun PlusPage(onOpen: () -> Unit) { + val extra = GateControlTheme.extraColors + val ring = extra.border2 + val description = stringResource(R.string.pages_sheet_title) + Column( + Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + Modifier + .size(112.dp) + .clip(CircleShape) + .drawBehind { + val stroke = 2.dp.toPx() + drawCircle( + color = ring, + radius = size.minDimension / 2 - stroke, + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f))), + ) + } + .clickable(role = Role.Button, onClick = onOpen) + .semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + Icon(GcIcons.Plus, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(40.dp)) + } + Text( + stringResource(R.string.pages_plus_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = 20.dp), + ) + Text( + stringResource(R.string.pages_plus_text), + style = MaterialTheme.typography.bodyMedium, + color = extra.muted, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp).widthIn(max = 300.dp), + ) + } +} + +/** + * "Seite an Start anheften": the functions still in the bar ([candidates]) + * and "Neue Widget-Seite". [locked]: the admin fixes the order, nothing to pick. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PinPageSheet( + candidates: List, + locked: Boolean, + onPin: (String) -> Unit, + onAddWidgets: () -> Unit, + onDismiss: () -> Unit, +) { + val extra = GateControlTheme.extraColors + val titleColor = if (locked) extra.faint else MaterialTheme.colorScheme.onSurface + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + ) { + Column(Modifier.padding(bottom = 24.dp)) { + Text( + stringResource(R.string.pages_sheet_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + stringResource(R.string.pages_sheet_text), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 8.dp), + ) + if (locked) { + GcBanner(tone = GcTone.Info, icon = GcIcons.Lock, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Text(stringResource(R.string.arrange_locked), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } + } + GcSectionLabel(stringResource(R.string.pages_sheet_from_bar), Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)) + if (candidates.isEmpty()) { + Text( + stringResource(R.string.pages_sheet_all_pinned), + style = MaterialTheme.typography.bodyMedium, + color = extra.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + candidates.forEachIndexed { index, id -> + if (index > 0) GcRowDivider(Modifier.padding(horizontal = 16.dp)) + GcListRow( + title = startPageLabel(id), + description = stringResource(R.string.pages_sheet_icon_leaves), + onClick = if (locked) null else ({ onPin(id) }), + titleColor = titleColor, + leading = { Icon(startPageIcon(id), contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) }, + trailing = { if (!locked) Icon(GcIcons.Pin, contentDescription = null, tint = extra.faint, modifier = Modifier.size(18.dp)) }, + ) + } + GcSectionLabel(stringResource(R.string.pages_sheet_more_room), Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp)) + GcListRow( + title = stringResource(R.string.pages_new_widgets), + description = stringResource(R.string.pages_new_widgets_desc), + onClick = if (locked) null else onAddWidgets, + titleColor = titleColor, + leading = { Icon(GcIcons.ViewGrid, contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) }, + trailing = { if (!locked) Icon(GcIcons.Plus, contentDescription = null, tint = extra.faint, modifier = Modifier.size(18.dp)) }, + ) + } + } +} + +/** + * A widget page beside Start: its name with "⋯", then its quick actions and + * widgets like on Start (no Info and usage card). A long press on a free + * spot opens the same menu as on Start (Schnellaktionen und Widgets, + * Anordnen); empty, a dashed frame says how to fill it. + */ +@Composable +private fun WidgetPage( + id: String, + menu: StartPageMenu, + locked: Boolean, + onArrange: () -> Unit, + onOpenQuickActions: () -> Unit, + onOpenDoor: (() -> Unit)?, + viewModel: QuickActionsViewModel, +) { + val extra = GateControlTheme.extraColors + val pages by viewModel.pages.collectAsStateWithLifecycle() + val entries = pages[id].orEmpty() + var editOpen by rememberSaveable(id) { mutableStateOf(false) } + val haptic = LocalHapticFeedback.current + val openEdit: () -> Unit = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + editOpen = true + } + if (editOpen) { + StartEditSheet( + title = stringResource(R.string.pages_widgets_edit_title), + arrangeTitle = stringResource(R.string.pages_widgets_arrange, menu.label), + onActions = { editOpen = false; onOpenQuickActions() }, + onInfoCard = null, + onDismiss = { editOpen = false }, + onArrange = { editOpen = false; onArrange() }, + arrangeLocked = locked, + ) + } + HomeColumn(Modifier.padding(top = 12.dp), onLongPress = openEdit) { + Row(verticalAlignment = Alignment.CenterVertically) { + GcScreenTitle(menu.label, Modifier.weight(1f).padding(start = 4.dp)) + StartPageMenuButton(menu) + } + if (entries.isEmpty()) { + EmptyWidgetPage(onEdit = openEdit, onAdd = onOpenQuickActions) + } else { + StartTiles(onOpenDoor = onOpenDoor, page = id, viewModel = viewModel) + StartCards( + cards = entries.filter { it !is StartEntry.Tile }, + info = {}, + usage = {}, + onOpenDoor = onOpenDoor, + page = id, + viewModel = viewModel, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val addLabel = stringResource(R.string.pages_widgets_add_desc) + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(16.dp)) + .dashedFrame(extra.border2, 16.dp) + .clickable(role = Role.Button, onClick = onOpenQuickActions) + .semantics { contentDescription = addLabel }, + contentAlignment = Alignment.Center, + ) { + Icon(GcIcons.Plus, contentDescription = null, tint = extra.muted, modifier = Modifier.size(20.dp)) + } + Text( + stringResource(R.string.pages_widgets_hint), + style = MaterialTheme.typography.bodySmall, + color = extra.faint, + modifier = Modifier.weight(1f), + ) + } + } + Spacer(Modifier.height(24.dp)) + } +} + +/** An empty widget page: a dashed frame with what to do and "Widgets hinzufügen". */ +@Composable +private fun EmptyWidgetPage(onEdit: () -> Unit, onAdd: () -> Unit) { + val extra = GateControlTheme.extraColors + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .dashedFrame(extra.border2, 20.dp) + .clickable(role = Role.Button, onClick = onEdit) + .padding(horizontal = 20.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + stringResource(R.string.pages_widgets_empty), + style = MaterialTheme.typography.bodyMedium, + color = extra.muted, + textAlign = TextAlign.Center, + ) + GcSecondaryButton(text = stringResource(R.string.pages_widgets_add), onClick = onAdd, fillWidth = false) + } +} + +/** A dashed rounded frame (empty places to fill). */ +private fun Modifier.dashedFrame(color: Color, radius: androidx.compose.ui.unit.Dp): Modifier = drawBehind { + val stroke = 1.5.dp.toPx() + val r = radius.toPx() + drawRoundRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(stroke / 2, stroke / 2), + size = androidx.compose.ui.geometry.Size(size.width - stroke, size.height - stroke), + cornerRadius = CornerRadius(r, r), + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 8f))), + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt new file mode 100644 index 00000000..21142216 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesScreens.kt @@ -0,0 +1,435 @@ +package com.gatecontrol.android.ui.start + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.layout.LayoutAccess +import com.gatecontrol.android.layout.StartPages +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcChevron +import com.gatecontrol.android.ui.components.GcIconSquare +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcListRow +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.components.GcSegmented +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +/** What pinning and taking off pages from a screen of their own needs: the snackbar texts and "Rückgängig". */ +private class PageActions( + private val viewModel: StartPagesViewModel, + private val resources: android.content.res.Resources, + private val snack: (String, (() -> Unit)?) -> Unit, +) { + fun pin(id: String) = viewModel.pin(id) { undo -> + snack(resources.getString(R.string.pages_snack_pinned, startPageLabel(resources, id)), undo) + } + + fun unpin(id: String) { + val message = if (StartPages.isWidgets(id)) { + resources.getString(R.string.pages_snack_removed) + } else { + resources.getString(R.string.pages_snack_unpinned, startPageLabel(resources, id)) + } + viewModel.unpin(id) { undo -> snack(message, undo) } + } + + fun addWidgets() = viewModel.addWidgets { id, undo -> + snack(resources.getString(R.string.pages_snack_pinned, startPageLabel(resources, id)), undo) + } +} + +@Composable +private fun rememberPageActions(viewModel: StartPagesViewModel): PageActions { + val resources = LocalResources.current + val snack = LocalPageSnack.current + return remember(viewModel, resources, snack) { PageActions(viewModel, resources, snack) } +} + +/** The admin fixes the order: pinning, taking off and arranging are off. */ +@Composable +private fun LockedHint(access: LayoutAccess, modifier: Modifier = Modifier) { + if (!access.locked) return + GcBanner(tone = GcTone.Info, icon = GcIcons.Lock, modifier = modifier) { + Text(stringResource(R.string.arrange_locked), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } +} + +/** + * "Seiten anordnen" (mockup 5): Start fixed at the top, the pages beside + * it to drag into order (screen readers: "Nach oben/unten"), each with + * "Als Tab" or, a widget page, "Entfernen"; below the functions still in + * the bar to pin, and "Neue Widget-Seite". [available]: the functions this + * login has as tabs or pages (MainTabs.features). + */ +@Composable +fun StartPagesArrangeScreen( + available: List, + onBack: () -> Unit, + viewModel: StartPagesViewModel = hiltViewModel(), +) { + val extra = GateControlTheme.extraColors + val haptic = LocalHapticFeedback.current + val all by viewModel.pages.collectAsStateWithLifecycle() + val access by viewModel.access.collectAsStateWithLifecycle() + val blocks by viewModel.blocks.collectAsStateWithLifecycle() + val actions = rememberPageActions(viewModel) + val locked = access.locked + val visible = StartPages.visible(all, available) + val inBar = available.filter { it !in all } + // The order while dragging (saved when the drag ends); follows the stored one otherwise. + var working by remember { mutableStateOf?>(null) } + val shown = working?.takeIf { it.toSet() == visible.toSet() } ?: visible + var removing by rememberSaveable { mutableStateOf(null) } + val remove: (String) -> Unit = { id -> + if (StartPages.isWidgets(id) && blocks[id].orEmpty().isNotEmpty()) removing = id else actions.unpin(id) + } + + val listState = rememberLazyListState() + val reorderState = rememberReorderableLazyListState(listState) { from, to -> + val next = StartPages.move(shown, from.key as? String ?: return@rememberReorderableLazyListState, to.key as? String ?: return@rememberReorderableLazyListState) + if (next !== shown) { + working = next + haptic.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + } + } + val upLabel = stringResource(R.string.arrange_move_up) + val downLabel = stringResource(R.string.arrange_move_down) + fun moveActions(id: String): List = if (locked) emptyList() else buildList { + if (StartPages.canMove(shown, id, -1)) add(CustomAccessibilityAction(upLabel) { viewModel.reorder(StartPages.moveBy(shown, id, -1)); true }) + if (StartPages.canMove(shown, id, 1)) add(CustomAccessibilityAction(downLabel) { viewModel.reorder(StartPages.moveBy(shown, id, 1)); true }) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(key = "bar") { + GcSubpageBar(title = stringResource(R.string.pages_arrange), onBack = onBack, backDescription = stringResource(R.string.common_back)) + } + item(key = "hint") { + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.pages_arrange_hint), style = MaterialTheme.typography.bodySmall, color = extra.muted) + LockedHint(access) + } + } + item(key = "h:pages") { + GcSectionLabel(stringResource(R.string.pages_beside_start), Modifier.padding(start = 20.dp, end = 20.dp, top = 8.dp)) + } + item(key = "start") { + PageRow( + icon = startPageIcon(START_PAGE), + title = stringResource(R.string.nav_start), + description = stringResource(R.string.pages_start_first), + accent = true, + dragging = false, + trailing = { + Icon(GcIcons.Lock, contentDescription = stringResource(R.string.pages_fixed), tint = extra.faint, modifier = Modifier.padding(end = 10.dp).size(18.dp)) + }, + ) + } + items(shown, key = { it }) { id -> + ReorderableItem(reorderState, key = id, enabled = !locked) { dragging -> + val label = startPageLabel(id) + PageRow( + icon = startPageIcon(id), + title = label, + description = if (dragging) stringResource(R.string.pages_moving) else null, + accent = true, + dragging = dragging, + grip = !locked, + trailing = { + GcOutlineButton( + text = stringResource(if (StartPages.isWidgets(id)) R.string.pages_remove_short else R.string.pages_as_tab_short), + onClick = { remove(id) }, + enabled = !locked, + fillWidth = false, + minHeight = 36.dp, + ) + }, + modifier = Modifier + .then( + if (locked) { + Modifier + } else { + Modifier.longPressDraggableHandle( + onDragStarted = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, + onDragStopped = { working?.let { viewModel.reorder(it) } }, + ) + }, + ) + .semantics { customActions = moveActions(id) }, + ) + } + } + if (inBar.isNotEmpty()) { + item(key = "h:bar") { + GcSectionLabel(stringResource(R.string.pages_in_bar), Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp)) + } + items(inBar, key = { "bar:$it" }) { id -> + PageRow( + icon = startPageIcon(id), + title = startPageLabel(id), + description = null, + accent = false, + dragging = false, + trailing = { + GcOutlineButton( + text = stringResource(R.string.pages_pin_short), + onClick = { actions.pin(id) }, + enabled = !locked, + fillWidth = false, + minHeight = 36.dp, + ) + }, + ) + } + } + item(key = "new") { + GcOutlineButton( + text = stringResource(R.string.pages_new_widgets), + onClick = { actions.addWidgets() }, + enabled = !locked, + icon = GcIcons.Plus, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp), + ) + } + } + removing?.let { id -> + RemovePageDialog( + id = id, + count = blocks[id].orEmpty().size, + onConfirm = { removing = null; actions.unpin(id) }, + onDismiss = { removing = null }, + ) + } +} + +/** One page in "Seiten anordnen": icon, name, what to do with it and — when it can be dragged — the grip. */ +@Composable +private fun PageRow( + icon: ImageVector, + title: String, + description: String?, + accent: Boolean, + dragging: Boolean, + modifier: Modifier = Modifier, + grip: Boolean = false, + trailing: @Composable () -> Unit, +) { + val extra = GateControlTheme.extraColors + Surface( + modifier = modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .heightIn(min = 58.dp) + .graphicsLayer { rotationZ = if (dragging) -1.5f else 0f }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, if (dragging) MaterialTheme.colorScheme.primary else extra.border), + shadowElevation = if (dragging) 8.dp else 0.dp, + ) { + Row( + Modifier.padding(start = if (grip) 6.dp else 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (grip) Icon(StartIcons.Grip, contentDescription = null, tint = extra.faint, modifier = Modifier.size(18.dp)) + GcIconSquare(size = 32.dp, background = if (accent) extra.accentBg else extra.panel2) { + Icon(icon, contentDescription = null, tint = if (accent) extra.accentText else extra.muted, modifier = Modifier.size(18.dp)) + } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (description != null) Text(description, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1) + } + trailing() + } + } +} + +/** + * Einstellungen → "Leiste und Seiten" (mockup 7): a preview of the bar and + * of the pages, each function as "Tab" or "Seite", and "Leiste ausblenden, + * wenn nur Start bleibt" (this device's choice, on by default). + * [available]: the functions this login has (MainTabs.features). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun StartPagesSettingsScreen( + available: List, + onBack: () -> Unit, + onArrangePages: () -> Unit, + viewModel: StartPagesViewModel = hiltViewModel(), +) { + val extra = GateControlTheme.extraColors + val all by viewModel.pages.collectAsStateWithLifecycle() + val access by viewModel.access.collectAsStateWithLifecycle() + val hideBar by viewModel.hideBar.collectAsStateWithLifecycle() + val actions = rememberPageActions(viewModel) + val locked = access.locked + val pinned = StartPages.visible(all, available) + val tabs = listOf(START_PAGE) + available.filter { it !in all } + val tabLabel = stringResource(R.string.pages_tab) + val pageLabel = stringResource(R.string.pages_page) + + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(rememberScrollState()), + ) { + GcSubpageBar(title = stringResource(R.string.pages_settings_title), onBack = onBack, backDescription = stringResource(R.string.common_back)) + Column( + Modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + LockedHint(access) + GcSectionLabel(stringResource(R.string.pages_preview), Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) + GcCard { + val names = pinned.map { startPageLabel(it) } + Text( + if (names.isEmpty()) { + stringResource(R.string.pages_preview_none) + } else { + stringResource(R.string.pages_preview_line, (listOf(stringResource(R.string.nav_start)) + names).joinToString(" → ")) + }, + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + tabs.forEachIndexed { index, id -> + val first = index == 0 + Row( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (first) extra.accentBg else extra.panel2) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(startPageIcon(id), contentDescription = null, tint = if (first) extra.accentText else extra.muted, modifier = Modifier.size(14.dp)) + Text(startPageLabel(id), style = MaterialTheme.typography.labelMedium, color = if (first) extra.accentText else extra.muted) + } + } + } + } + GcSectionLabel(stringResource(R.string.pages_each_as), Modifier.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 4.dp)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + GcListRow( + title = stringResource(R.string.nav_start), + description = stringResource(R.string.pages_start_always), + leading = { Icon(startPageIcon(START_PAGE), contentDescription = null, tint = extra.accentText, modifier = Modifier.size(22.dp)) }, + ) + if (available.isEmpty()) { + Text( + stringResource(R.string.pages_settings_no_features), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + } + available.forEach { id -> + GcRowDivider(Modifier.padding(horizontal = 16.dp)) + val label = startPageLabel(id) + val asPage = id in all + val groupLabel = stringResource(R.string.pages_segment_desc, label) + Row( + Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(startPageIcon(id), contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) + Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) + Box(Modifier.width(168.dp).semantics { contentDescription = groupLabel }) { + GcSegmented( + options = listOf(false to tabLabel, true to pageLabel), + selected = asPage, + onSelect = { page -> + if (!locked && page != asPage) { + if (page) actions.pin(id) else actions.unpin(id) + } + }, + ) + } + } + } + } + Text( + stringResource(R.string.pages_settings_footer), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 4.dp), + ) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + GcSwitchRow( + label = stringResource(R.string.pages_hide_bar), + description = stringResource(R.string.pages_hide_bar_desc), + checked = hideBar, + onCheckedChange = viewModel::setHideBar, + ) + GcRowDivider(Modifier.padding(horizontal = 16.dp)) + GcListRow( + title = stringResource(R.string.pages_arrange), + onClick = onArrangePages, + leading = { Icon(StartIcons.Arrange, contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) }, + trailing = { GcChevron() }, + ) + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt new file mode 100644 index 00000000..785c6a90 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPagesViewModel.kt @@ -0,0 +1,79 @@ +package com.gatecontrol.android.ui.start + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.layout.LayoutAccess +import com.gatecontrol.android.layout.StartArrangement +import com.gatecontrol.android.layout.StartEntry +import com.gatecontrol.android.layout.StartPagesStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The pages beside Start for the bar, the pager, "Seiten anordnen" and + * "Leiste und Seiten": pin, take off, new widget page, order and the + * bar switch. A change is refused while the admin fixes the order + * ([LayoutAccess.locked]); each one hands back how to take it back + * ("Rückgängig" of the snackbar), which still works after the screen that + * made it is gone. + */ +@HiltViewModel +class StartPagesViewModel @Inject constructor( + private val store: StartPagesStore, + arrangement: StartArrangement, +) : ViewModel() { + + /** All page ids in their order (StartPages.visible picks those shown). */ + val pages: StateFlow> = store.pages + + val access: StateFlow = store.access + + /** "Leiste ausblenden, wenn nur Start bleibt" (this device). */ + val hideBar: StateFlow = store.hideBar + + /** The blocks on Start (null) and each widget page (to ask before removing a page with blocks). */ + val blocks: StateFlow>> = arrangement.pages + + /** Undo outlives this view model (the settings may be closed before the snackbar's "Rückgängig"). */ + private val undoScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun change(action: suspend () -> Unit, done: (undo: () -> Unit) -> Unit) { + if (access.value.locked) return + viewModelScope.launch { + val before = store.snapshot() + action() + done { undoScope.launch { store.restore(before) } } + } + } + + /** [id] (a function) becomes a page beside Start. */ + fun pin(id: String, done: (undo: () -> Unit) -> Unit = {}) = change({ store.pin(id) }, done) + + /** The page [id] goes (a function becomes a tab again, a widget page's blocks go back to Start). */ + fun unpin(id: String, done: (undo: () -> Unit) -> Unit = {}) = change({ store.unpin(id) }, done) + + /** A new empty widget page; [done] gets its id. */ + fun addWidgets(done: (id: String, undo: () -> Unit) -> Unit = { _, _ -> }) { + if (access.value.locked) return + viewModelScope.launch { + val before = store.snapshot() + val id = store.addWidgets() + done(id) { undoScope.launch { store.restore(before) } } + } + } + + /** The pages in a new order (page ids). */ + fun reorder(order: List) { + if (access.value.locked) return + viewModelScope.launch { store.reorder(order) } + } + + fun setHideBar(value: Boolean) { + viewModelScope.launch { store.setHideBar(value) } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/vault/VaultScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/vault/VaultScreen.kt index 31582922..0a331712 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vault/VaultScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vault/VaultScreen.kt @@ -97,16 +97,22 @@ class VaultNav( fun VaultScreen(nav: VaultNav, viewModel: VaultViewModel = hiltViewModel()) { val status by viewModel.status.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.check() } - when (status) { - VaultStatus.LOADING -> VaultCentered { CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 2.dp) } - VaultStatus.UNAVAILABLE -> VaultCentered { - Text(stringResource(R.string.vault_unavailable), textAlign = TextAlign.Center, color = GateControlTheme.extraColors.muted) - Spacer(Modifier.height(16.dp)) - GcOutlineButton(stringResource(R.string.vault_retry), onClick = { viewModel.check() }, fillWidth = false) + Box(Modifier.fillMaxSize()) { + when (status) { + VaultStatus.LOADING -> VaultCentered { CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 2.dp) } + VaultStatus.UNAVAILABLE -> VaultCentered { + Text(stringResource(R.string.vault_unavailable), textAlign = TextAlign.Center, color = GateControlTheme.extraColors.muted) + Spacer(Modifier.height(16.dp)) + GcOutlineButton(stringResource(R.string.vault_retry), onClick = { viewModel.check() }, fillWidth = false) + } + VaultStatus.NOT_SET_UP -> VaultIntro(onStart = nav.setup) + VaultStatus.LOCKED -> VaultUnlock(viewModel, onForgot = nav.recover) + VaultStatus.UNLOCKED -> VaultList(viewModel, nav) + } + // "⋯" of the page when Vault is pinned to Start (its own title rows have it otherwise). + if (status == VaultStatus.LOADING || status == VaultStatus.UNAVAILABLE) { + com.gatecontrol.android.ui.start.StartPageMenuButton(Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 8.dp)) } - VaultStatus.NOT_SET_UP -> VaultIntro(onStart = nav.setup) - VaultStatus.LOCKED -> VaultUnlock(viewModel, onForgot = nav.recover) - VaultStatus.UNLOCKED -> VaultList(viewModel, nav) } } @@ -123,7 +129,10 @@ fun VaultCentered(content: @Composable () -> Unit) { private fun VaultIntro(onStart: () -> Unit) { val x = GateControlTheme.extraColors Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp)) { - Text(stringResource(R.string.vault_title), style = MaterialTheme.typography.headlineLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.vault_title), style = MaterialTheme.typography.headlineLarge, modifier = Modifier.weight(1f)) + com.gatecontrol.android.ui.start.StartPageMenuButton() + } Spacer(Modifier.weight(1f)) Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { VaultHeroIcon(GcIcons.Vault) @@ -177,6 +186,7 @@ private fun VaultUnlock(viewModel: VaultViewModel, onForgot: () -> Unit) { Row(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.vault_title), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.weight(1f)) if (sync == VaultRepository.Sync.Offline) GcChip(stringResource(R.string.vault_offline), small = true) + com.gatecontrol.android.ui.start.StartPageMenuButton() } Column( Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 28.dp), @@ -359,6 +369,7 @@ private fun VaultList(viewModel: VaultViewModel, nav: VaultNav) { } } } + com.gatecontrol.android.ui.start.StartPageMenuButton() } } item { diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/StartEditSheet.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/StartEditSheet.kt index 7360b8ce..0b783658 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/StartEditSheet.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/StartEditSheet.kt @@ -22,7 +22,8 @@ import com.gatecontrol.android.ui.start.StartIcons import com.gatecontrol.android.ui.theme.GateControlTheme /** - * Menu from below after a long press on a free spot of the start tab: + * Menu from below after a long press on a free spot of the start tab (or + * of a widget page beside it, [title] its own and no [onInfoCard]): * "Schnellaktionen und Widgets" (the list, pinning, widget or button) and * "Inhalte der Info-Karte" (the picker). */ @@ -30,11 +31,13 @@ import com.gatecontrol.android.ui.theme.GateControlTheme @Composable fun StartEditSheet( onActions: () -> Unit, - onInfoCard: () -> Unit, + onInfoCard: (() -> Unit)?, onDismiss: () -> Unit, /** "Start-Tab anordnen"; [arrangeLocked]: off, with the hint that the admin sets the order. */ onArrange: (() -> Unit)? = null, arrangeLocked: Boolean = false, + title: String = stringResource(R.string.start_edit_title), + arrangeTitle: String = stringResource(R.string.start_arrange), ) { val extra = GateControlTheme.extraColors ModalBottomSheet( @@ -44,7 +47,7 @@ fun StartEditSheet( ) { Column(Modifier.padding(bottom = 24.dp)) { Text( - stringResource(R.string.start_edit_title), + title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), @@ -56,17 +59,19 @@ fun StartEditSheet( leading = { Icon(GcIcons.Zap, contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) }, trailing = { GcChevron() }, ) - GcRowDivider(Modifier.padding(horizontal = 16.dp)) - GcListRow( - title = stringResource(R.string.start_edit_info), - onClick = onInfoCard, - leading = { Icon(GcIcons.Sliders, contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) }, - trailing = { GcChevron() }, - ) + if (onInfoCard != null) { + GcRowDivider(Modifier.padding(horizontal = 16.dp)) + GcListRow( + title = stringResource(R.string.start_edit_info), + onClick = onInfoCard, + leading = { Icon(GcIcons.Sliders, contentDescription = null, tint = extra.muted, modifier = Modifier.size(22.dp)) }, + trailing = { GcChevron() }, + ) + } if (onArrange != null) { GcRowDivider(Modifier.padding(horizontal = 16.dp)) GcListRow( - title = stringResource(R.string.start_arrange), + title = arrangeTitle, description = if (arrangeLocked) stringResource(R.string.arrange_locked) else stringResource(R.string.start_arrange_hint), onClick = if (arrangeLocked) null else onArrange, titleColor = if (arrangeLocked) extra.faint else MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt index 6ff02d92..df29b26c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt @@ -121,11 +121,11 @@ fun VpnScreen( /** Messenger 1.4: the own active stories (from the status sheet). */ onOpenOwnStories: () -> Unit = {}, /** - * "Erinnerung für dieses Gerät" in a widget's or button's settings: the - * reminder editor for the action `/` (Screen.ReminderNew); - * null: the row is not offered. + * "Start-Tab anordnen" (the edit sheet's row): the arranging screen + * replaces Start and its pages (StartPager), which also shows the pin + * sheets and results of the quick actions for all of them. */ - onNewReminder: ((actionId: String) -> Unit)? = null, + onArrange: () -> Unit = {}, locationViewModel: LocationShareViewModel = hiltViewModel(), avatarViewModel: MyAvatarViewModel = hiltViewModel(), ) { @@ -530,12 +530,6 @@ fun VpnScreen( val quickActionsViewModel: com.gatecontrol.android.ui.quickactions.QuickActionsViewModel = hiltViewModel() val startEntries by quickActionsViewModel.entries.collectAsState() val layoutAccess by quickActionsViewModel.layoutAccess.collectAsState() - var arranging by rememberSaveable { mutableStateOf(false) } - com.gatecontrol.android.ui.quickactions.QuickActionsEffects( - onArrange = { arranging = true }, - onNewReminder = onNewReminder, - viewModel = quickActionsViewModel, - ) val tilesSection: @Composable ColumnScope.() -> Unit = { com.gatecontrol.android.ui.quickactions.StartTiles(onOpenDoor = onOpenDoor, viewModel = quickActionsViewModel) } @@ -552,7 +546,7 @@ fun VpnScreen( onActions = { editOpen = false; onOpenQuickActions() }, onInfoCard = { editOpen = false; onOpenInfoCardPicker() }, onDismiss = { editOpen = false }, - onArrange = { editOpen = false; arranging = true }, + onArrange = { editOpen = false; onArrange() }, arrangeLocked = layoutAccess.locked, ) } @@ -582,16 +576,6 @@ fun VpnScreen( ) } - // "Start-Tab anordnen" replaces the tab until "Fertig". - if (arranging && !layoutAccess.locked) { - com.gatecontrol.android.ui.start.StartArrangeScreen( - onDone = { arranging = false }, - onAddTile = onOpenQuickActions, - viewModel = quickActionsViewModel, - ) - return - } - // Phones: one column. Wide windows: quick actions and Info card on the // left, usage on the right, split at the hinge of a half-opened foldable. // Tabletop posture: header and quick actions above the fold, the cards @@ -635,7 +619,7 @@ fun VpnScreen( /** A scrolling column of home sections; a long press on its free space calls [onLongPress]. */ @Composable -private fun HomeColumn( +internal fun HomeColumn( modifier: Modifier = Modifier, onLongPress: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, diff --git a/app/src/main/res/values-de/strings_pages.xml b/app/src/main/res/values-de/strings_pages.xml new file mode 100644 index 00000000..ea2536cc --- /dev/null +++ b/app/src/main/res/values-de/strings_pages.xml @@ -0,0 +1,66 @@ + + + + Widgets %1$d + Seite anheften + Eine Funktion aus der Leiste unten oder eine neue Seite für weitere Widgets + Seiten + Seite %1$d von %2$d: %3$s + Seite %1$d von %2$d: %3$s (%4$d) + Optionen für diese Seite + Als Tab anzeigen + Symbol kommt zurück in die Leiste + Seite entfernen + Seiten anordnen + Seite an Start anheften + Wische von Start nach links, um sie zu öffnen. Ihr Symbol verschwindet aus der Leiste. + Aus der Leiste + Symbol verschwindet aus der Leiste + Alle Funktionen sind schon an Start angeheftet. + Mehr Platz für Widgets + Neue Widget-Seite + Leere Seite für weitere Schnellaktionen und Widgets + „%1$s“ ist jetzt eine Seite neben Start + „%1$s“ ist wieder ein Tab unten + Seite entfernt + Rückgängig + An Start anheften + Dann: von Start nach links wischen + Leiste und Seiten … + Optionen + Noch leer. Lange drücken oder hier tippen, um Schnellaktionen und Widgets hinzuzufügen. + Widgets hinzufügen + Schnellaktion oder Widget hinzufügen + Funktioniert wie Start: lange auf eine freie Stelle drücken öffnet „Schnellaktionen und Widgets“ und „Anordnen“. + Seite bearbeiten + „%1$s“ anordnen + „%1$s“ entfernen? + + Die Schnellaktion oder das Widget dieser Seite kommt zurück auf Start. + Die %1$d Schnellaktionen und Widgets dieser Seite kommen zurück auf Start. + + Entfernen + Halten und ziehen. Die Reihenfolge gilt beim Wischen von Start nach links. + Seiten neben Start + Immer die erste Seite + Fest an erster Stelle + Wird verschoben … + Als Tab + Entfernen + Unten in der Leiste + Anheften + Leiste und Seiten + Funktionen als Tab oder als Seite neben Start + Vorschau + Wischen: %1$s → + + Keine Seiten neben Start + Jede Funktion als + Immer Tab und erste Seite + Tab + Seite + %1$s anzeigen als + „Seite“ heißt: an Start angeheftet, erreichbar durch Wischen nach links. Die Reihenfolge änderst du unter „Seiten anordnen“. + Leiste ausblenden, wenn nur Start bleibt + Mehr Platz für die Seiten + Noch nichts zum Anheften: außer Start ist keine Funktion verfügbar. + diff --git a/app/src/main/res/values/strings_pages.xml b/app/src/main/res/values/strings_pages.xml new file mode 100644 index 00000000..7c83b496 --- /dev/null +++ b/app/src/main/res/values/strings_pages.xml @@ -0,0 +1,66 @@ + + + + Widgets %1$d + Pin a page + A function from the bar below or a new page for more widgets + Pages + Page %1$d of %2$d: %3$s + Page %1$d of %2$d: %3$s (%4$d) + Options for this page + Show as tab + Its icon returns to the bar + Remove page + Arrange pages + Pin a page to Home + Swipe left from Home to open it. Its icon leaves the bar. + From the bar + Its icon leaves the bar + All functions are already pinned to Home. + More room for widgets + New widget page + Empty page for more quick actions and widgets + “%1$s” is now a page beside Home + “%1$s” is a tab below again + Page removed + Undo + Pin to Home + Then: swipe left from Home + Bar and pages … + Options + Still empty. Long-press or tap here to add quick actions and widgets. + Add widgets + Add a quick action or widget + Works like Home: long-press an empty spot to open “Quick actions and widgets” and “Arrange”. + Edit page + Arrange “%1$s” + Remove “%1$s”? + + The quick action or widget on this page goes back to Home. + The %1$d quick actions and widgets on this page go back to Home. + + Remove + Hold and drag. The order applies when swiping left from Home. + Pages beside Home + Always the first page + Fixed in first place + Moving … + As tab + Remove + In the bar below + Pin + Bar and pages + Functions as tabs or as pages beside Home + Preview + Swipe: %1$s → + + No pages beside Home + Each function as + Always a tab and the first page + Tab + Page + Show %1$s as + “Page” means: pinned to Home, reached by swiping left. Change the order under “Arrange pages”. + Hide the bar when only Home is left + More room for the pages + There is nothing to pin yet: no function besides Home is available. + diff --git a/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt b/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt new file mode 100644 index 00000000..37b20cc2 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/layout/StartPagesTest.kt @@ -0,0 +1,195 @@ +package com.gatecontrol.android.layout + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Pages beside Start kept as `page:` markers in the start tab's list. */ +class StartPagesTest { + + private val door = "gatecontrol-door/open:1" + private val light = "gatecontrol-ha/light:2" + private val car = "gatecontrol-skoda/car:1" + private val nas = "gatecontrol-nas/ds918" + + private fun widget(device: String, wide: Boolean = false) = StartEntry.Widget(device, wide) + + @Test + fun `the list splits at the markers and joins back`() { + val saved = listOf("tile:$door", "info", "page:network", "page:w2", "tile:$light", "widget:$nas", "page:familie", "usage") + val split = StartPages.split(saved) + // A function page opens no section: what follows its marker still belongs to the page before it. + assertEquals(listOf("tile:$door", "info"), split.start) + assertEquals(listOf("network", "w2", "familie"), split.ids) + assertEquals(listOf("tile:$light", "widget:$nas", "usage"), split.pages.first { it.id == "w2" }.entries) + assertEquals(emptyList(), split.pages.first { it.id == "network" }.entries) + assertEquals( + listOf("tile:$door", "info", "page:network", "page:w2", "tile:$light", "widget:$nas", "usage", "page:familie"), + StartPages.join(split), + ) + // Joined lists split the same way again. + assertEquals(split, StartPages.split(StartPages.join(split))) + } + + @Test + fun `entries after a function marker before any widget page stay on Start`() { + // What an older app writes: its own entries behind the markers it kept. + val split = StartPages.split(listOf("tile:$door", "page:chats", "widget:$car", "info")) + assertEquals(listOf("tile:$door", "widget:$car", "info"), split.start) + assertEquals(listOf("chats"), split.ids) + } + + @Test + fun `duplicate, empty and unknown markers`() { + val split = StartPages.split(listOf("page:", "page:vault", "page:vault", "page:future", "page:w1", "info")) + // The first marker of an id counts, one without id goes; unknown ids stay (saving keeps them) without entries. + assertEquals(listOf("vault", "future", "w1"), split.ids) + assertEquals(listOf("info"), split.start) + assertEquals(listOf("vault", "future", "w1"), StartPages.pages(StartPages.join(split))) + // Shown: known functions that are available and widget pages from 2 on. + assertEquals(listOf("vault"), StartPages.visible(listOf("page:vault", "page:future", "page:w1", "page:drive"), listOf("vault"))) + assertEquals(listOf("w2", "network"), StartPages.visible(listOf("page:w2", "page:network"), listOf("network", "chats"))) + } + + @Test + fun `widget page ids`() { + assertTrue(StartPages.isWidgets("w2")) + assertTrue(StartPages.isWidgets("w17")) + assertFalse(StartPages.isWidgets("w1")) + assertFalse(StartPages.isWidgets("w")) + assertFalse(StartPages.isWidgets("wx")) + assertFalse(StartPages.isWidgets("chats")) + assertEquals(3, StartPages.widgetsNumber("w3")) + assertNull(StartPages.widgetsNumber("network")) + assertEquals("w2", StartPages.nextWidgets(emptyList())) + assertEquals("w3", StartPages.nextWidgets(listOf("page:w2", "page:chats"))) + // A gap is filled first. + assertEquals("w3", StartPages.nextWidgets(listOf("page:w2", "page:w4"))) + } + + @Test + fun `pinning appends a page, taking it off keeps a widget page's blocks on Start`() { + val saved = listOf("tile:$door", "info", "usage") + val pinned = StartPages.pin(saved, "network") + assertEquals(listOf("tile:$door", "info", "usage", "page:network"), pinned) + assertEquals(pinned, StartPages.pin(pinned, "network")) + val withWidgets = StartPages.pin(pinned, "w2").let { StartPages.withEntries(it, "w2", listOf("widget:$nas", "tile:$light")) } + assertEquals(listOf("tile:$door", "info", "usage", "page:network", "page:w2", "widget:$nas", "tile:$light"), withWidgets) + assertEquals(listOf("tile:$door", "info", "usage", "page:w2", "widget:$nas", "tile:$light"), StartPages.unpin(withWidgets, "network")) + assertEquals(listOf("tile:$door", "info", "usage", "widget:$nas", "tile:$light", "page:network"), StartPages.unpin(withWidgets, "w2")) + // Taking off a page that is none changes nothing. + assertEquals(saved, StartPages.unpin(saved, "chats")) + } + + @Test + fun `the entries of one page are replaced, the others stay`() { + val saved = listOf("tile:$door", "page:w2", "tile:$light", "page:chats") + assertEquals(listOf("info", "page:w2", "tile:$light", "page:chats"), StartPages.withEntries(saved, null, listOf("info"))) + assertEquals(listOf("tile:$door", "page:w2", "widget:$nas", "page:chats"), StartPages.withEntries(saved, "w2", listOf("widget:$nas"))) + // A function page has no entries, a missing page none to replace. + assertSame(saved, StartPages.withEntries(saved, "chats", listOf("info"))) + assertSame(saved, StartPages.withEntries(saved, "w3", listOf("info"))) + assertEquals(listOf("tile:$light"), StartPages.entriesOf(saved, "w2")) + assertEquals(listOf("tile:$door"), StartPages.entriesOf(saved, null)) + } + + @Test + fun `pages are reordered with their entries`() { + val saved = listOf("info", "page:network", "page:w2", "tile:$light", "page:familie") + assertEquals(listOf("info", "page:familie", "page:w2", "tile:$light", "page:network"), StartPages.reorder(saved, listOf("familie", "w2", "network"))) + // Ids the order lacks keep theirs after the others. + assertEquals(listOf("info", "page:w2", "tile:$light", "page:network", "page:familie"), StartPages.reorder(saved, listOf("w2"))) + val ids = listOf("network", "w2", "familie") + assertEquals(listOf("w2", "network", "familie"), StartPages.moveBy(ids, "w2", -1)) + assertFalse(StartPages.canMove(ids, "network", -1)) + assertTrue(StartPages.canMove(ids, "network", 1)) + assertFalse(StartPages.canMove(ids, "familie", 1)) + assertEquals(listOf("w2", "familie", "network"), StartPages.move(ids, "network", "familie")) + assertSame(ids, StartPages.move(ids, "network", "chats")) + } + + @Test + fun `each pin stands on the page that names it, the rest on Start`() { + val saved = listOf("tile:$door", "widget:$car", "info", "usage", "page:network", "page:w2", "widget2:$nas", "tile:$light", "info", "page:w3") + val pages = StartPages.merge(saved, tiles = listOf(door, light, "new/tile"), widgets = listOf(widget(car), widget(nas, wide = true), widget("new/widget"))) + assertEquals( + listOf(StartEntry.Tile(door), StartEntry.Tile("new/tile"), widget(car), widget("new/widget"), StartEntry.Info, StartEntry.Usage), + pages[null], + ) + // No Info and usage card on a widget page; the width is the pin's. + assertEquals(listOf(StartEntry.Tile(light), widget(nas, wide = true)), pages["w2"]) + // An empty widget page is there, without blocks. + assertEquals(emptyList(), pages["w3"]) + assertEquals(setOf(null, "w2", "w3"), pages.keys) + } + + @Test + fun `without pages Start shows what it showed before`() { + val saved = listOf("widget:$car", "tile:$door", "usage", "info") + val tiles = listOf(door, light) + val widgets = listOf(widget(car)) + assertEquals(StartLayout.merge(saved, tiles, widgets), StartPages.merge(saved, tiles, widgets)[null]) + assertEquals(StartLayout.merge(emptyList(), tiles, widgets), StartPages.merge(listOf("page:network"), tiles, widgets)[null]) + } + + @Test + fun `a pin made on a widget page joins it, the pages stay as they are`() { + val saved = listOf("tile:$door", "info", "usage", "page:network", "page:w2", "widget:$nas") + val synced = StartPages.syncPins(saved, tiles = listOf(door, light), widgets = listOf(widget(nas), widget(car)), target = "w2") + assertEquals(listOf("tile:$door", "info", "usage", "page:network", "page:w2", "tile:$light", "widget:$nas", "widget:$car"), synced) + // Pinned from Start (or a page that is gone): Start. + assertEquals( + listOf("tile:$door", "tile:$light", "info", "usage", "page:network", "page:w2", "widget:$nas"), + StartPages.syncPins(saved, tiles = listOf(door, light), widgets = listOf(widget(nas)), target = null), + ) + assertEquals( + StartPages.syncPins(saved, listOf(door, light), listOf(widget(nas)), target = null), + StartPages.syncPins(saved, listOf(door, light), listOf(widget(nas)), target = "w9"), + ) + // Unpinned blocks drop out of their page; the page stays (empty). + assertEquals(listOf("tile:$door", "info", "usage", "page:network", "page:w2"), StartPages.syncPins(saved, listOf(door), emptyList(), target = null)) + } + + @Test + fun `markers are entries the server accepts`() { + (StartPages.FEATURES + listOf("w2", "w999")).forEach { assertTrue(TabLayout.isValidEntry(StartPages.marker(it)), it) } + val many = (1..120).map { "tile:p/$it" } + "page:network" + // The start list is cut at the server's limit like before (markers count as entries). + assertEquals(TabLayout.MAX_ENTRIES, TabLayout.clean(StartPages.pin(many, "chats")).size) + } + + @Test + fun `an older app skips the markers and keeps them when it saves`() { + val remote = listOf("tile:$door", "info", "page:network", "page:w2", "widget:$nas", "usage") + // Older apps: no block for a marker, every pin of the device back on Start. + StartPages.FEATURES.forEach { assertNull(StartLayout.decode(StartPages.marker(it))) } + assertNull(StartLayout.decode("page:w2")) + assertEquals( + listOf(StartEntry.Tile(door), StartEntry.Info, widget(nas), StartEntry.Usage), + StartLayout.merge(remote, listOf(door), listOf(widget(nas))), + ) + // Their saving (encode of what they show, then keepForeign over the server's copy) keeps the markers. + val oldLocal = StartLayout.encodeAll(StartLayout.merge(remote, listOf(door), listOf(widget(nas)))) + val pushed = TabLayout.keepForeign(oldLocal, remote, StartLayout::identity) + assertEquals(remote, pushed) + assertEquals(listOf("network", "w2"), StartPages.pages(pushed)) + } + + @Test + fun `saving here keeps another device's blocks but not a page taken off here`() { + val remote = listOf("tile:$door", "page:network", "page:w2", "widget:other/phone", "tile:$light") + val local = listOf("tile:$door", "page:w2", "tile:$light") + // "network" was taken off here: it must not come back from the server's copy. + val pushed = StartPages.keepForeign(local, remote) + assertEquals(listOf("tile:$door", "page:w2", "widget:other/phone", "tile:$light"), pushed) + // The other device's widget stays on its page. + assertEquals(listOf("widget:other/phone", "tile:$light"), StartPages.entriesOf(pushed, "w2")) + // A widget page taken off here: its foreign blocks fall back to the page before it. + val gone = StartPages.keepForeign(listOf("tile:$door", "tile:$light"), remote) + assertEquals(emptyList(), StartPages.pages(gone)) + assertTrue("widget:other/phone" in StartPages.entriesOf(gone, null)) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/layout/TabLayoutRepositoryTest.kt b/app/src/test/java/com/gatecontrol/android/layout/TabLayoutRepositoryTest.kt index ef8e54c9..624a9655 100644 --- a/app/src/test/java/com/gatecontrol/android/layout/TabLayoutRepositoryTest.kt +++ b/app/src/test/java/com/gatecontrol/android/layout/TabLayoutRepositoryTest.kt @@ -145,4 +145,28 @@ class TabLayoutRepositoryTest { assertTrue(state().access.locked) assertEquals(listOf("info", "usage"), state().layout.start) } + + @Test + fun `a page taken off here does not come back from the account's copy`() = runTest { + serves("user", layout = ClientLayoutDto(start = listOf("info", "page:network", "page:w2", "widget:other/phone"))) + coEvery { api.putClientLayout(capture(sent)) } returns Response.success(ClientLayoutPutResponse(ok = true)) + val repo = repo() + repo.refresh(force = true) + repo.updateStart { StartPages.unpin(it, "network") } + assertEquals(listOf("info", "page:w2", "widget:other/phone"), sent.captured.layout.start) + repo.updateStart { StartPages.pin(it, "chats") } + assertEquals(listOf("info", "page:w2", "widget:other/phone", "page:chats"), sent.captured.layout.start) + assertEquals(listOf("w2", "chats"), StartPages.pages(state().layout.start)) + } + + @Test + fun `pages stay as the admin fixed them`() = runTest { + serves("global", editable = false, layout = ClientLayoutDto(start = listOf("info", "page:network"))) + val repo = repo() + repo.refresh(force = true) + repo.updateStart { StartPages.unpin(it, "network") } + repo.updateStart { StartPages.pin(it, "w2") } + assertEquals(listOf("info", "page:network"), state().layout.start) + coVerify(exactly = 0) { api.putClientLayout(any()) } + } } diff --git a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt index 689433c9..74b097fb 100644 --- a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt +++ b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt @@ -117,4 +117,54 @@ class MainTabsTest { assertEquals("settings/profile", Screen.Profile.route) assertEquals("info-card", Screen.InfoCardPicker.route) } + + @Test + fun `tabs and pages beside Start name each other`() { + val all = PluginAvailability(familieTab = true, messengerTab = true, vault = true, drive = true) + val routes = MainTabs.routes(network = true, plugins = all) + assertEquals(listOf("chats", "vault", "drive", "network", "familie"), MainTabs.features(routes)) + routes.drop(1).forEach { assertEquals(it, MainTabs.routeOf(MainTabs.pageOf(it)!!)) } + assertNull(MainTabs.pageOf(Screen.Vpn.route)) + assertNull(MainTabs.routeOf("w2")) + } + + @Test + fun `routes to navigate to find their page`() { + assertEquals("network", MainTabs.pageFor(Screen.Network.create(NetworkTabs.REMOTE))) + assertEquals("network", MainTabs.pageFor(Screen.Network.create())) + assertEquals("chats", MainTabs.pageFor(Screen.Chats.create(conv = 7, share = true))) + assertEquals("familie", MainTabs.pageFor("familie?dest=tasks")) + assertEquals("vault", MainTabs.pageFor(Screen.Vault.route)) + assertEquals("drive", MainTabs.pageFor(Screen.Drive.route)) + // Screens on top of a tab and Start have no page. + assertNull(MainTabs.pageFor(Screen.ChatConversation.create(7))) + assertNull(MainTabs.pageFor("familie/page?dest=tasks")) + assertNull(MainTabs.pageFor(Screen.DriveTrash.route)) + assertNull(MainTabs.pageFor(Screen.Vpn.route)) + // The arguments go along. + assertEquals("remote", MainTabs.arg(Screen.Network.create(NetworkTabs.REMOTE), Screen.Network.ARG_TAB)) + assertEquals("7", MainTabs.arg(Screen.Chats.create(conv = 7, share = true), Screen.Chats.ARG_CONV)) + assertEquals("true", MainTabs.arg(Screen.Chats.create(conv = 7, share = true), Screen.Chats.ARG_SHARE)) + assertEquals("ride:12 a", MainTabs.arg("familie?dest=ride%3A12%20a", Screen.Familie.ARG_DEST)) + assertNull(MainTabs.arg(Screen.Network.create(), Screen.Network.ARG_TAB)) + assertNull(MainTabs.arg("network?tab=", Screen.Network.ARG_TAB)) + } + + @Test + fun `pinned functions leave the bar, Start stays`() { + val routes = MainTabs.routes(network = true, plugins = PluginAvailability(familieTab = true, messengerTab = true)) + assertEquals(listOf(Screen.Vpn.route, Screen.Chats.route), MainTabs.visible(routes, listOf("network", "familie", "w2"))) + assertEquals(listOf(Screen.Vpn.route), MainTabs.visible(routes, listOf("chats", "network", "familie"))) + assertEquals(routes, MainTabs.visible(routes, emptyList())) + } + + @Test + fun `the bar goes only when everything is a page and the person wants that`() { + val onlyStart = listOf(Screen.Vpn.route) + assertTrue(MainTabs.hideBar(onlyStart, pages = listOf("network"), hideWhenOnlyStart = true)) + assertFalse(MainTabs.hideBar(onlyStart, pages = listOf("network"), hideWhenOnlyStart = false)) + // Nothing pinned (a login with Start only): the bar stays as before. + assertFalse(MainTabs.hideBar(onlyStart, pages = emptyList(), hideWhenOnlyStart = true)) + assertFalse(MainTabs.hideBar(listOf(Screen.Vpn.route, Screen.Chats.route), pages = listOf("network"), hideWhenOnlyStart = true)) + } } diff --git a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt index a4b60946..e556cc96 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt @@ -64,6 +64,8 @@ class SettingsRepository @Inject constructor(private val dataStore: DataStore = dataStore.data.map { it[THEME] ?: "system" } @@ -183,6 +185,13 @@ class SettingsRepository @Inject constructor(private val dataStore: DataStore = dataStore.data.map { it[HIDE_BAR_ONLY_START] ?: true } + + suspend fun setHideBarOnlyStart(value: Boolean) { + dataStore.edit { it[HIDE_BAR_ONLY_START] = value } + } + /** Segment the Netzwerk tab opens with, null = never chosen (the first available). */ fun getNetworkDefaultTab(): Flow = dataStore.data.map { it[NETWORK_DEFAULT_TAB] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 288fd84a..f138499c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,7 +193,7 @@ Jeder Bildschirm besteht aus: - **State** — Immutable Data Classes via `collectAsState()` ### Navigation -Bottom Navigation Bar (auf breiten Fenstern eine Navigationsleiste links) mit den Tabs Start, Netzwerk und — mit dem Familie-Plugin — Familie (`navigation/MainTabs.kt`). Netzwerk hat die Segmente Dienste, Pi-hole und Remote (RDP; `ui/network/NetworkScreen.kt`, `NetworkTabs`) und erscheint, sobald das Token eines davon nutzen darf. Die Einstellungen sind seit 1.35 kein Tab mehr, sondern ein normales Ziel mit Zurück, erreichbar über das Zahnrad im Profil (Tippen auf das eigene Profilbild auf dem Start-Tab). Die alte Route `rdp` leitet auf Netzwerk → Remote um. Der Setup-Bildschirm wird als initialer Screen angezeigt, wenn noch keine Konfiguration vorliegt. +Bottom Navigation Bar (auf breiten Fenstern eine Navigationsleiste links) mit den Tabs Start, Netzwerk und — mit dem Familie-Plugin — Familie (`navigation/MainTabs.kt`). Netzwerk hat die Segmente Dienste, Pi-hole und Remote (RDP; `ui/network/NetworkScreen.kt`, `NetworkTabs`) und erscheint, sobald das Token eines davon nutzen darf. Die Einstellungen sind seit 1.35 kein Tab mehr, sondern ein normales Ziel mit Zurück, erreichbar über das Zahnrad im Profil (Tippen auf das eigene Profilbild auf dem Start-Tab). Die alte Route `rdp` leitet auf Netzwerk → Remote um. Jede Funktion außer Start kann statt Tab eine **Seite neben Start** sein (`ui/start/StartPager.kt`, ein `HorizontalPager` in der Route `vpn`; reine Logik in `layout/StartPages.kt`, gespeichert als `page:`-Einträge in der Start-Reihenfolge, `TabLayoutRepository`); `MainTabs.visible` nimmt angeheftete Funktionen aus der Leiste, `MainTabs.pageFor` leitet ihre Routen (Pushes, Links) auf die Seite um. Der Setup-Bildschirm wird als initialer Screen angezeigt, wenn noch keine Konfiguration vorliegt. ### Theming Eigenes `GateControlTheme` mit `CompositionLocal` für erweiterte Farben: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5059c6f3..52bf8e6c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -96,6 +96,63 @@ Familie-Plugin mit Tab anbietet — **Familie**. Seit dem Messenger-Plugin öffnen weiterhin direkt die jeweilige Seite; auf breiten Bildschirmen die Einstellungen mit der Seite daneben. +### Seiten neben Start statt Tabs +Jede Funktion, die sonst ein Tab ist (Chats, Vault, Drive, Netzwerk, +Familie – soweit vorhanden), kann wahlweise **Tab** in der Leiste oder +**Seite neben Start** sein. Start wird dann eine Reihe von Seiten: Start +bleibt immer die erste Seite (und immer in der Leiste), rechts davon die +angehefteten Seiten in ihrer Reihenfolge, ganz rechts die **Plus-Seite** +(„Seite anheften“). Wischen wechselt die Seite; die **Seitenpunkte** über +der Leiste (aktiver Punkt breit in Akzentfarbe, die Plus-Seite als kleines +„+“) sind antippbar und tragen die Zähler (ungelesene Chats, Remote-Hosts +online). Eine angeheftete Funktion zeigt ihren echten Bildschirm, ihr +Symbol verschwindet aus der Leiste (bzw. der Navigationsleiste links). + +- **Anheften:** Plus-Seite → „Seite an Start anheften“ (Abschnitte „Aus der + Leiste“ und „Mehr Platz für Widgets“); einen Tab unten **lange drücken** → + „An Start anheften“ oder „Leiste und Seiten …“; Einstellungen → Darstellung + → **„Leiste und Seiten“**; „Seiten anordnen“ → „Anheften“. Danach zeigt + Start die neue Seite, eine Snackbar meldet „„X“ ist jetzt eine Seite neben + Start“ mit **Rückgängig**. +- **Lösen:** „⋯“ in der Kopfzeile der Seite → „Als Tab anzeigen“ (bei einer + Widget-Seite „Seite entfernen“, mit Rückfrage, wenn sie etwas enthält – ihre + Schnellaktionen und Widgets kommen zurück auf Start) und „Seiten anordnen“. +- **Widget-Seiten** („Widgets 2“, „Widgets 3“ …) nehmen Schnellaktionen und + Widgets auf wie Start (ohne Info- und Verbrauchskarte): lange auf eine freie + Stelle drücken → „Schnellaktionen und Widgets“ (was dort angeheftet wird, + kommt auf diese Seite) und „Anordnen“. Leer zeigt sie einen gestrichelten + Rahmen mit „Widgets hinzufügen“. +- **Seiten anordnen:** Start fest oben, die Seiten halten und ziehen + (Screenreader: „Nach oben/unten“), je „Als Tab“ bzw. „Entfernen“; darunter + „Unten in der Leiste“ mit „Anheften“ und „Neue Widget-Seite“. +- **Leiste und Seiten** (Einstellungen): Vorschau der Leiste, je Funktion + „Tab | Seite“ und **„Leiste ausblenden, wenn nur Start bleibt“** (Standard + an). Ist alles angeheftet, gibt es dann keine Leiste mehr, nur die + Seitenpunkte mit einer Zeile der Seitennamen. Der Schalter gilt nur für + dieses Gerät (er ändert nur die Darstellung hier; die Seiten selbst gelten + mit dem Konto). +- **Regeln:** Tippen auf Start in der Leiste zeigt die erste Seite (auch wenn + Start schon gewählt ist). Die Zurück-Taste auf einer anderen Seite führt + erst zu Start, dann wie bisher aus der App. Auf einer Seite wechseln innere + Reiter nur per Tippen (Drive: Dateien/Fotos, Schule: die Tage); + Listen- und Kartenkarussells (Info-Karte, Chips-Leisten) scrollen weiter + selbst und geben am Ende an den Seitenwechsel ab. +- **Pushes und Links** auf eine angeheftete Funktion (Chat, Netzwerk → + Remote, Familie, Vault, Drive) öffnen ihre Seite bzw. den Unterbildschirm + darüber, Zurück führt auf die Seite. Die alten Routen bleiben gültig und + leiten auf die Seite um. +- **Speicherung:** in der Start-Reihenfolge (`start` von + `GET/PUT /api/v1/client/layout`, ohne Serveränderung) als Trenn-Einträge + `page:` (`page:chats`, `page:vault`, `page:drive`, `page:network`, + `page:familie`, `page:w2` …). Alles vor dem ersten `page:` gehört zu Start, + Einträge nach `page:wN` zu dieser Widget-Seite; Funktionsseiten haben keine + Einträge. Damit gelten die Seiten wie die Start-Reihenfolge mit dem Konto + (gleiche Seiten auf dem zweiten Handy); legt der Administrator die + Reihenfolge fest, sind Anheften, Lösen und Anordnen gesperrt („Die + Anordnung legt der Administrator fest.“), seine Seiten gelten trotzdem. + **Ältere App-Versionen** überspringen die `page:`-Einträge, zeigen alle + Widgets wieder auf Start und behalten die Einträge beim Speichern. + ### Startbildschirm (1.32, umgebaut in 1.35 und 1.36) Seit 1.36 passt die Standardansicht (Kopfzeile, Schnellaktionen, Info-Karte, Durchsatz-Karte, Tab-Leiste) ohne Scrollen auf ein Handy-Display. diff --git a/docs/LARGE_SCREENS.md b/docs/LARGE_SCREENS.md index 66798a63..5071b49f 100644 --- a/docs/LARGE_SCREENS.md +++ b/docs/LARGE_SCREENS.md @@ -31,6 +31,11 @@ Details: Remote as segments) and Familie; since 1.35 there is no settings tab and no remote tab. The settings are a normal destination with back, opened from the profile's gear; they hide the rail like any other sub page. +- Functions pinned to Start as pages (`StartPages`, docs/FEATURES.md + „Seiten neben Start statt Tabs“) leave the rail as they leave the bottom + bar; the pages and their dots work the same on wide windows (a page shows + its tab's two-pane screen). With everything pinned and „Leiste + ausblenden, wenn nur Start bleibt“ on, there is no rail either. - Links into a settings page (logs, location, notifications, diagnosis) open the settings (list with that page beside it) on wide windows (`settings?pane=…`), else the page's own route. The profile always opens