From cd65ded352fc31eed28daa85cea8de6ac0eb9d40 Mon Sep 17 00:00:00 2001 From: GothwadTech <295316515+GothwadTech@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:14:12 +0000 Subject: [PATCH 1/3] Replace the Search tab with a Kiwi tab (web-store listings only) - Bottom bar is now Home | Apps | Kiwi | Games | Profile. - The Kiwi tab lists exactly the apps that are live on the Kiwi Store web (Worker catalogue only, no F-Droid/upstream mirrors) in the Apps-tab list form, with All/Apps/Games chips. - Search is no longer a tab: every tab header (Home, Apps, Kiwi, Games, Profile) carries a search action that opens the search screen, and no tab header shows a search bar. - The search screen is pushed as a sub-screen now, so the back arrow returns to the tab the user came from. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 6 ++ .../gothwad/store/data/model/StoreModels.kt | 9 ++ .../main/java/com/gothwad/store/ui/KiwiApp.kt | 2 + .../main/java/com/gothwad/store/ui/Routes.kt | 16 ++- .../com/gothwad/store/ui/StoreViewModel.kt | 25 +++++ .../com/gothwad/store/ui/components/Chrome.kt | 5 +- .../gothwad/store/ui/navigation/AppNavHost.kt | 19 ++-- .../store/ui/screens/AccountScreens.kt | 2 + .../gothwad/store/ui/screens/HomeScreen.kt | 2 + .../store/ui/screens/ListingScreens.kt | 100 +++++++++++++++++- .../gothwad/store/ui/screens/SearchScreen.kt | 10 +- .../java/com/gothwad/store/KiwiTabTest.kt | 44 ++++++++ task.md | 7 +- 13 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/gothwad/store/KiwiTabTest.kt diff --git a/README.md b/README.md index b046288..ad6070b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ The app never connects directly to Supabase and includes no backend credentials. ## Current implementation +- Bottom tabs: **Home | Apps | Kiwi | Games | Profile**. The **Kiwi** tab lists + exactly the apps that are live on the Kiwi Store web + ([kiwistore.gothwadtech.com](https://kiwistore.gothwadtech.com/)) — it reads + the Worker catalogue only, so no upstream mirror ever leaks into it. +- Search is not a tab any more: every tab header carries a search action that + opens the search screen, and no tab header shows a search bar. - Live published apps and games from the Worker, with category filters. - F-Droid's public repository index is merged into Home, Apps, and Search; Gothwad listings win when the same package is present in both sources. F-Droid metadata is cached in memory for six hours. - F-Droid APKs download directly from F-Droid through the existing foreground download/install flow; they are not proxied, hosted, or counted by Gothwad. A compact “From F-Droid” attribution is shown on list and detail screens. diff --git a/app/src/main/java/com/gothwad/store/data/model/StoreModels.kt b/app/src/main/java/com/gothwad/store/data/model/StoreModels.kt index 420fdc5..a608984 100644 --- a/app/src/main/java/com/gothwad/store/data/model/StoreModels.kt +++ b/app/src/main/java/com/gothwad/store/data/model/StoreModels.kt @@ -36,6 +36,15 @@ data class StoreApp( ) { val isGame: Boolean get() = kind == "game" val hasRating: Boolean get() = ratingCount > 0 + + /** + * True for the listings that are published on the Kiwi Store web + * (kiwistore.gothwadtech.com). F-Droid and other upstream mirrors carry a + * `fdroid:` id and are not part of the web store, so the Kiwi tab skips + * them. + */ + val isKiwiStoreListing: Boolean + get() = !id.startsWith("fdroid:") && !source.equals("F-Droid", ignoreCase = true) } data class StoreRelease( diff --git a/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt b/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt index 2fe886d..8fb528b 100644 --- a/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt +++ b/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt @@ -54,6 +54,8 @@ fun KiwiApp() { // init block; asking again here would only duplicate the traffic. vm.refreshApps() vm.refreshGames() + // The Kiwi tab is the web store: only the Worker catalogue, no F-Droid. + vm.refreshKiwi() // The optional F-Droid catalogue is downloaded in the background and // shows up as its own shelf once it arrives. vm.loadFdroid() diff --git a/app/src/main/java/com/gothwad/store/ui/Routes.kt b/app/src/main/java/com/gothwad/store/ui/Routes.kt index 8158cbc..e817982 100644 --- a/app/src/main/java/com/gothwad/store/ui/Routes.kt +++ b/app/src/main/java/com/gothwad/store/ui/Routes.kt @@ -7,11 +7,23 @@ object Routes { // Bottom navigation tabs const val HOME = "home" const val APPS = "apps" - const val SEARCH = "search" + + /** + * The Kiwi tab: the listings that are live on the Kiwi Store web + * (kiwistore.gothwadtech.com) — nothing else. + */ + const val KIWI = "kiwi" const val GAMES = "games" const val ACCOUNT = "account" - val tabs = listOf(HOME, APPS, SEARCH, GAMES, ACCOUNT) + /** + * Search is not a tab any more. Every tab header carries a search action + * that opens this destination, so there is no separate search tab and no + * search bar in the tab headers. + */ + const val SEARCH = "search" + + val tabs = listOf(HOME, APPS, KIWI, GAMES, ACCOUNT) // Catalogue const val CHARTS = "charts/{kind}" diff --git a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt index bdfbe17..5cf7f2c 100644 --- a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt +++ b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt @@ -62,6 +62,14 @@ class StoreViewModel : ViewModel() { private val _apps = MutableStateFlow>(UiState.Loading) val apps: StateFlow> = _apps.asStateFlow() + /** + * The Kiwi tab: the listings that are live on the Kiwi Store web. The + * optional upstream catalogue (F-Droid) is deliberately *not* merged in + * here — the Kiwi tab is the web store, nothing else. + */ + private val _kiwi = MutableStateFlow>(UiState.Loading) + val kiwi: StateFlow> = _kiwi.asStateFlow() + private val _games = MutableStateFlow>(UiState.Loading) val games: StateFlow> = _games.asStateFlow() @@ -120,6 +128,7 @@ class StoreViewModel : ViewModel() { private var homeJob: Job? = null private var appsJob: Job? = null + private var kiwiJob: Job? = null private var gamesJob: Job? = null private var chartsJob: Job? = null private var detailJob: Job? = null @@ -236,6 +245,22 @@ class StoreViewModel : ViewModel() { } } + /** + * Loads the listings published on the Kiwi Store web. Only the Worker + * catalogue is used here — no F-Droid, no upstream mirrors — so the Kiwi + * tab shows exactly what the website shows. + */ + fun refreshKiwi() { + kiwiJob?.cancel() + kiwiJob = viewModelScope.launch { + if (_kiwi.value !is UiState.Ready) _kiwi.value = UiState.Loading + _kiwi.value = load { + repository.firstPartyCatalog(null, null, "") + .copy(apps = it.apps.filter { app -> app.isKiwiStoreListing }) + } + } + } + fun refreshGames() { gamesJob?.cancel() gamesJob = viewModelScope.launch { diff --git a/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt b/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt index 70f964a..8088919 100644 --- a/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt +++ b/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt @@ -36,12 +36,13 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Storefront import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -279,7 +280,7 @@ private data class TabSpec( private val TAB_SPECS = listOf( TabSpec(Routes.HOME, "Home", Icons.Filled.Home, Icons.Outlined.Home), TabSpec(Routes.APPS, "Apps", Icons.Filled.Apps, Icons.Outlined.Apps), - TabSpec(Routes.SEARCH, "Search", Icons.Filled.Search, Icons.Outlined.Search), + TabSpec(Routes.KIWI, "Kiwi", Icons.Filled.Storefront, Icons.Outlined.Storefront), TabSpec(Routes.GAMES, "Games", Icons.Filled.SportsEsports, Icons.Outlined.SportsEsports), TabSpec(Routes.ACCOUNT, "Profile", Icons.Filled.Person, Icons.Outlined.Person), ) diff --git a/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt b/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt index 8d54fb5..16a8d4b 100644 --- a/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt @@ -50,6 +50,7 @@ import com.gothwad.store.ui.screens.InstallHelpScreen import com.gothwad.store.ui.screens.InstallMethodScreen import com.gothwad.store.ui.screens.InstallPermissionScreen import com.gothwad.store.ui.screens.InstallSettingsScreen +import com.gothwad.store.ui.screens.KiwiScreen import com.gothwad.store.ui.screens.LegalScreen import com.gothwad.store.ui.screens.LicensesScreen import com.gothwad.store.ui.screens.MyAppsScreen @@ -74,8 +75,9 @@ import com.gothwad.store.ui.screens.WriteReviewScreen /** * One NavHost for the whole app. The five tab destinations (Home, Apps, - * Search, Games, Profile) render the shared bottom bar; every other - * destination is pushed as a sub-screen with a back arrow. + * Kiwi, Games, Profile) render the shared bottom bar; every other + * destination — including the search screen, which the tab headers open — is + * pushed as a sub-screen with a back arrow. */ @Composable fun AppNavHost( @@ -88,9 +90,9 @@ fun AppNavHost( val currentRoute = backStackEntry?.destination?.route.orEmpty() val onTab: (String) -> Unit = { route -> - // Every tab (Search included) is a top-level destination, so they all - // use the same save/restore rules. Search used to be special-cased and - // was not wrapped in TabShell, which is why it had no bottom bar. + // Every tab is a top-level destination, so they all use the same + // save/restore rules. Search is not a tab any more: the tab headers + // push it as a normal sub-screen. navController.navigate(route) { popUpTo(Routes.HOME) { saveState = true } launchSingleTop = true @@ -118,14 +120,19 @@ fun AppNavHost( composable(Routes.APPS) { TabShell(currentRoute, onTab, snackbarHostState) { AppsScreen(vm, navController) } } + composable(Routes.KIWI) { + TabShell(currentRoute, onTab, snackbarHostState) { KiwiScreen(vm, navController) } + } composable(Routes.GAMES) { TabShell(currentRoute, onTab, snackbarHostState) { GamesScreen(vm, navController) } } composable(Routes.ACCOUNT) { TabShell(currentRoute, onTab, snackbarHostState) { AccountScreen(vm, navController) } } + // Search is not a tab any more — every tab header opens it, so it + // is pushed as a sub-screen (no bottom bar, back arrow returns). composable(Routes.SEARCH) { - TabShell(currentRoute, onTab, snackbarHostState) { SearchScreen(vm, navController) } + SearchScreen(vm, navController) } // ── Catalogue ─────────────────────────────────────────────────── diff --git a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt index 2a20d66..16e57f6 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt @@ -75,6 +75,7 @@ import com.gothwad.store.ui.components.KeyValueRow import com.gothwad.store.ui.components.OutlineButton import com.gothwad.store.ui.components.PillLabel import com.gothwad.store.ui.components.PrimaryButton +import com.gothwad.store.ui.components.SearchAction import com.gothwad.store.ui.components.SectionHeader import com.gothwad.store.ui.components.SettingRow import com.gothwad.store.ui.components.SettingsGroup @@ -107,6 +108,7 @@ fun AccountScreen(vm: StoreViewModel, nav: NavController) { title = "Profile", showBrandMark = true, actions = { + SearchAction { nav.navigate(Routes.SEARCH) } DownloadsAction { nav.navigate(Routes.DOWNLOADS) } IconButton(onClick = { nav.navigate(Routes.SETTINGS) }) { Icon( diff --git a/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt b/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt index e263df9..80d896f 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt @@ -65,6 +65,7 @@ import com.gothwad.store.ui.components.GothwadScaffold import com.gothwad.store.ui.components.InfoBanner import com.gothwad.store.ui.components.GothwadTopBar import com.gothwad.store.ui.components.PlayChip +import com.gothwad.store.ui.components.SearchAction import com.gothwad.store.ui.components.SectionHeader import com.gothwad.store.ui.components.SkeletonList import com.gothwad.store.ui.components.SmallAppCard @@ -90,6 +91,7 @@ fun HomeScreen(vm: StoreViewModel, nav: NavController) { title = "Kiwi Store", showBrandMark = true, actions = { + SearchAction { nav.navigate(Routes.SEARCH) } DownloadsAction { nav.navigate(Routes.DOWNLOADS) } AvatarAction { nav.navigate(Routes.ACCOUNT) } }, diff --git a/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt index 780e7b3..89adcce 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Category import androidx.compose.material.icons.filled.NewReleases import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Storefront import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.filled.Whatshot import androidx.compose.material3.Text @@ -51,6 +52,7 @@ import com.gothwad.store.ui.components.InfoBanner import com.gothwad.store.ui.components.GothwadScaffold import com.gothwad.store.ui.components.GothwadTopBar import com.gothwad.store.ui.components.PlayChip +import com.gothwad.store.ui.components.SearchAction import com.gothwad.store.ui.components.SegmentedRow import com.gothwad.store.ui.components.SectionHeader import com.gothwad.store.ui.components.SkeletonList @@ -92,6 +94,7 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { title = "Apps", showBrandMark = true, actions = { + SearchAction { nav.navigate(Routes.SEARCH) } DownloadsAction { nav.navigate(Routes.DOWNLOADS) } AvatarAction { nav.navigate(Routes.ACCOUNT) } }, @@ -135,6 +138,100 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { } } +/** + * The Kiwi tab: everything that is live on the Kiwi Store web + * (kiwistore.gothwadtech.com), listed exactly like the Apps tab. + * + * The optional upstream catalogue (F-Droid) is not part of the web store, so + * it never shows up here — the Worker catalogue is the single source. + */ +@Composable +fun KiwiScreen(vm: StoreViewModel, nav: NavController) { + val state by vm.kiwi.collectAsStateWithLifecycle() + val releases by vm.releases.collectAsStateWithLifecycle() + var kindFilter by rememberSaveable { mutableIntStateOf(0) } + val filters = listOf("All", "Apps", "Games") + + LaunchedEffect(Unit) { + if (state is UiState.Ready && (state as UiState.Ready).value.apps.isEmpty()) vm.refreshKiwi() + } + + GothwadScaffold( + topBar = { + GothwadTopBar( + title = "Kiwi", + subtitle = "Live on kiwistore.gothwadtech.com", + showBrandMark = true, + actions = { + SearchAction { nav.navigate(Routes.SEARCH) } + DownloadsAction { nav.navigate(Routes.DOWNLOADS) } + AvatarAction { nav.navigate(Routes.ACCOUNT) } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + LazyRow( + contentPadding = PaddingValues(horizontal = Spacing.screenH, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(filters.indices.toList()) { index -> + PlayChip( + label = filters[index], + selected = kindFilter == index, + onClick = { kindFilter = index }, + ) + } + } + when (val current = state) { + UiState.Loading -> SkeletonList(rows = 7) + is UiState.Failed -> ErrorState(current.message, onRetry = { vm.refreshKiwi() }) + is UiState.Ready -> { + val apps = current.value.apps + .filter { app -> app.isKiwiStoreListing } + .let { all -> + when (kindFilter) { + 1 -> all.filterNot { it.isGame } + 2 -> all.filter { it.isGame } + else -> all + } + } + if (apps.isEmpty()) { + EmptyState( + title = "Nothing on the Kiwi Store web yet", + message = "Apps show up here as soon as a developer publishes them on " + + "kiwistore.gothwadtech.com.", + icon = Icons.Filled.Storefront, + actionLabel = "Open Developer Console", + onAction = { nav.navigate(Routes.DEVELOPER_CONSOLE) }, + ) + } else { + LazyColumn(contentPadding = PaddingValues(bottom = Spacing.bottomContentPadding)) { + item { + SectionHeader( + title = "On the Kiwi Store web", + subtitle = "${apps.size} app${if (apps.size == 1) "" else "s"}", + ) + } + itemsIndexed(apps, key = { _, app -> app.id }) { index, app -> + Column { + if (index > 0) DividerLine(startIndent = 64.dp) + AppRowItem( + app = app, + release = releases[app.id], + onOpen = { nav.navigate(Routes.app(app.id)) }, + onAction = { vm.onAppAction(app) }, + ) + } + } + } + } + } + } + } + } +} + @Composable fun GamesScreen(vm: StoreViewModel, nav: NavController) { val state by vm.games.collectAsStateWithLifecycle() @@ -149,6 +246,7 @@ fun GamesScreen(vm: StoreViewModel, nav: NavController) { title = "Games", showBrandMark = true, actions = { + SearchAction { nav.navigate(Routes.SEARCH) } DownloadsAction { nav.navigate(Routes.DOWNLOADS) } AvatarAction { nav.navigate(Routes.ACCOUNT) } }, @@ -280,7 +378,7 @@ fun CategoriesScreen(vm: StoreViewModel, nav: NavController) { title = "Categories", onBack = { nav.popBackStack() }, actions = { - com.gothwad.store.ui.components.SearchAction { nav.navigate(Routes.SEARCH) } + SearchAction { nav.navigate(Routes.SEARCH) } }, ) }, diff --git a/app/src/main/java/com/gothwad/store/ui/screens/SearchScreen.kt b/app/src/main/java/com/gothwad/store/ui/screens/SearchScreen.kt index 6f280fa..2c3d616 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/SearchScreen.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/SearchScreen.kt @@ -7,6 +7,7 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -50,8 +51,9 @@ import com.gothwad.store.ui.theme.GothwadTheme import com.gothwad.store.ui.theme.Spacing /** - * Search is a bottom-tab destination, so it uses the shared [GothwadScaffold]: - * the tab bar stays visible while the search field and results scroll. + * Search is not a tab any more: it is opened from the search action in every + * tab header and pushed as a sub-screen, so the back arrow returns to the tab + * the user came from. * * Idle screen order (top → bottom): recent searches, five suggested apps, then * "Recently viewed" at the very bottom — exactly the Play-style stacking. @@ -91,7 +93,9 @@ fun SearchScreen(vm: StoreViewModel, nav: NavController) { ) }, ) { padding -> - Column(Modifier.fillMaxSize().padding(padding)) { + // The tab bar is gone on this screen, so the list has to keep clear of + // the system navigation bar itself. + Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) { if (query.isBlank()) { val catalogue = when (val state = home) { is UiState.Ready -> state.value.apps diff --git a/app/src/test/java/com/gothwad/store/KiwiTabTest.kt b/app/src/test/java/com/gothwad/store/KiwiTabTest.kt new file mode 100644 index 0000000..3f84896 --- /dev/null +++ b/app/src/test/java/com/gothwad/store/KiwiTabTest.kt @@ -0,0 +1,44 @@ +package com.gothwad.store + +import com.gothwad.store.data.model.StoreApp +import com.gothwad.store.ui.Routes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The bottom bar has five tabs: Home, Apps, Kiwi, Games, Profile. Search is + * not a tab any more — every tab header opens it — and the Kiwi tab shows the + * listings that are live on the Kiwi Store web. + */ +class KiwiTabTest { + + @Test + fun `kiwi replaces search in the tab bar`() { + assertEquals( + listOf(Routes.HOME, Routes.APPS, Routes.KIWI, Routes.GAMES, Routes.ACCOUNT), + Routes.tabs, + ) + assertFalse(Routes.tabs.contains(Routes.SEARCH)) + assertEquals(5, Routes.tabs.size) + } + + @Test + fun `only web store listings belong to the kiwi tab`() { + val webListing = StoreApp( + id = "app-44e4ba84", + packageName = "com.gothwad.browser", + name = "Gothwad Browser", + ) + val fdroidListing = StoreApp( + id = "fdroid:org.fdroid.fdroid", + packageName = "org.fdroid.fdroid", + name = "F-Droid", + source = "F-Droid", + ) + + assertTrue(webListing.isKiwiStoreListing) + assertFalse(fdroidListing.isKiwiStoreListing) + } +} diff --git a/task.md b/task.md index 3cea1f1..f7f9307 100644 --- a/task.md +++ b/task.md @@ -3,10 +3,11 @@ > **App Name:** Kiwi Store > **Look:** EXACTLY like Google Play Store (2024 new UI) > **Brand colour:** sky blue `#0494F4` (the `#01875F` green values below are historical — take colours from `ui/theme/Color.kt`). -> **Tabs (5):** Home | Apps | Search | Games | Profile +> **Tabs (5):** Home | Apps | Kiwi | Games | Profile > **Top Bar Rule:** -> - Home, Apps, Games, Profile → "Kiwi Store" title (bold, 20sp, #202124) + Profile avatar (32dp green circle, white "G") on right. NO search bar. -> - Search tab → ONLY clean search bar (pill shape, grey bg, back arrow, text input, mic icon). No title, no avatar. +> - Home, Apps, Kiwi, Games, Profile → tab title (bold, 20sp, #202124) + search action + downloads + Profile avatar (32dp green circle, white "G") on right. NO search bar. +> - Search → NOT a tab. It is opened from the search action in every tab header and shows ONLY a clean search bar (pill shape, grey bg, back arrow, text input, mic icon). No title, no avatar. +> - Kiwi tab → the apps that are live on the Kiwi Store web, in the Apps-tab list form (no F-Droid / upstream mirrors). > **Data Source:** ONLY Aptoide API (https://ws75.aptoide.com/api/7/). No F-Droid, no backend, no Supabase, no Firebase. > **Status:** Phase 1 & 2 already DONE. Continue from Phase 3. From 5fcf3b7fc8298d8e81a678fc203d83e7b152bae2 Mon Sep 17 00:00:00 2001 From: GothwadTech <295316515+GothwadTech@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:15:59 +0000 Subject: [PATCH 2/3] Fix the Kiwi catalogue load: the loader lambda takes no receiver Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt index 5cf7f2c..5decac0 100644 --- a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt +++ b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt @@ -255,8 +255,8 @@ class StoreViewModel : ViewModel() { kiwiJob = viewModelScope.launch { if (_kiwi.value !is UiState.Ready) _kiwi.value = UiState.Loading _kiwi.value = load { - repository.firstPartyCatalog(null, null, "") - .copy(apps = it.apps.filter { app -> app.isKiwiStoreListing }) + val response = repository.firstPartyCatalog(null, null, "") + response.copy(apps = response.apps.filter { app -> app.isKiwiStoreListing }) } } } From 8c8b8540cfd95fb4ded77b5a22225bf4239f5db7 Mon Sep 17 00:00:00 2001 From: GothwadTech <295316515+GothwadTech@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:19 +0000 Subject: [PATCH 3/3] One shared header for all tabs + an App sources screen in Settings - Every tab (Home, Apps, Kiwi, Games, Profile) now uses the very same header: Kiwi Store icon on the left, a tappable search field in the middle and the profile avatar on the right. Tapping the search field opens the search screen, so the search tab stays gone and no tab shows a separate search bar. - Apps and Games keep their section heading inside the list, since the header no longer carries a per-tab title. - New Settings -> "App sources" screen (also reachable from the Profile tab): the Kiwi Store web catalogue, the F-Droid and IzzyOnDroid repositories with their URLs, index sizes, listing/release counts, last sync time and failover mirrors, the download counters (including the APKs that came from F-Droid) and a live "running right now" view (catalogue sync, download service, installer back-ends, network, account). - FdroidCatalog now publishes a live FdroidStatus (per repo: apps, releases, index size, last sync, syncing/error) so the UI can show it. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 10 +- .../store/data/remote/FdroidCatalog.kt | 126 ++++++++++ .../store/data/remote/StoreRepository.kt | 6 + .../main/java/com/gothwad/store/ui/Routes.kt | 1 + .../com/gothwad/store/ui/StoreViewModel.kt | 7 + .../com/gothwad/store/ui/components/Chrome.kt | 69 ++++++ .../gothwad/store/ui/navigation/AppNavHost.kt | 2 + .../store/ui/screens/AccountScreens.kt | 33 ++- .../gothwad/store/ui/screens/HomeScreen.kt | 19 +- .../store/ui/screens/ListingScreens.kt | 56 ++--- .../store/ui/screens/SettingsScreens.kt | 28 +++ .../gothwad/store/ui/screens/SourcesScreen.kt | 221 ++++++++++++++++++ .../com/gothwad/store/SourcesStatusTest.kt | 64 +++++ task.md | 8 +- 14 files changed, 588 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/gothwad/store/ui/screens/SourcesScreen.kt create mode 100644 app/src/test/java/com/gothwad/store/SourcesStatusTest.kt diff --git a/README.md b/README.md index ad6070b..171d103 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,14 @@ The app never connects directly to Supabase and includes no backend credentials. exactly the apps that are live on the Kiwi Store web ([kiwistore.gothwadtech.com](https://kiwistore.gothwadtech.com/)) — it reads the Worker catalogue only, so no upstream mirror ever leaks into it. -- Search is not a tab any more: every tab header carries a search action that - opens the search screen, and no tab header shows a search bar. +- All five tabs share one header: the Kiwi Store icon on the left, the search + field in the middle and the profile avatar on the right. Tapping the search + field opens the search screen, so there is no separate search tab. +- Settings → **App sources** shows everything the app runs on: the Kiwi Store + web catalogue, the F-Droid and IzzyOnDroid repositories with their URLs, + index sizes, listing counts, sync times and failover mirrors — plus a live + view of what is running right now (catalogue sync, download jobs, installer + back-ends, network and account). - Live published apps and games from the Worker, with category filters. - F-Droid's public repository index is merged into Home, Apps, and Search; Gothwad listings win when the same package is present in both sources. F-Droid metadata is cached in memory for six hours. - F-Droid APKs download directly from F-Droid through the existing foreground download/install flow; they are not proxied, hosted, or counted by Gothwad. A compact “From F-Droid” attribution is shown on list and detail screens. diff --git a/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt b/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt index 454f90a..8e8449d 100644 --- a/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt +++ b/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt @@ -33,6 +33,47 @@ import java.io.IOException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger +/** + * What the app knows about one upstream repository: which repo it is, how big + * its index is, how many apps/releases it carries and when it was last + * synced. Shown in Settings so the user can see exactly what is mirrored. + */ +data class FdroidRepoStatus( + val name: String, + val repoUrl: String, + val indexUrl: String, + val apps: Int = 0, + val releases: Int = 0, + val indexBytes: Long = 0L, + val lastSyncedAt: Long = 0L, + val syncing: Boolean = false, + val error: String? = null, +) + +/** + * Live status of the optional upstream catalogue (F-Droid + IzzyOnDroid). + */ +data class FdroidStatus( + val fdroid: FdroidRepoStatus = FdroidRepoStatus( + name = "F-Droid", + repoUrl = FdroidCatalog.REPO_URL, + indexUrl = FdroidCatalog.INDEX_URL, + ), + val izzy: FdroidRepoStatus = FdroidRepoStatus( + name = "IzzyOnDroid", + repoUrl = FdroidCatalog.IZZY_REPO_URL, + indexUrl = FdroidCatalog.IZZY_INDEX_URL, + ), + val mirrors: List = FdroidCatalog.FDROID_MIRRORS, + val lastError: String? = null, +) { + val repos: List get() = listOf(fdroid, izzy) + val totalApps: Int get() = fdroid.apps + izzy.apps + val totalReleases: Int get() = fdroid.releases + izzy.releases + val totalIndexBytes: Long get() = fdroid.indexBytes + izzy.indexBytes + val syncing: Boolean get() = fdroid.syncing || izzy.syncing +} + /** * Read-only client for F-Droid's public repository index (and IzzyOnDroid, the * standard companion F-Droid repository bundled in Droid-ify). APK bytes stay @@ -96,6 +137,19 @@ class FdroidCatalog( */ val catalogFlow: StateFlow = _catalogFlow.asStateFlow() + private val _status = MutableStateFlow(FdroidStatus()) + + /** + * Live status of both upstream repositories: sizes, sync times, mirrors + * and the current error. Settings shows this so the user can see what the + * app is actually running on. + */ + val status: StateFlow = _status.asStateFlow() + + private fun updateStatus(transform: (FdroidStatus) -> FdroidStatus) { + _status.value = transform(_status.value) + } + private val effectiveCacheDir: File by lazy { val dir = cacheDir ?: File(System.getProperty("java.io.tmpdir", "/tmp"), "kiwistore-fdroid") runCatching { dir.mkdirs() } @@ -184,6 +238,25 @@ class FdroidCatalog( if (diskFdroid != null) fdroidCached = diskFdroid if (diskIzzy != null) izzyCached = diskIzzy + // The disk cache is a catalogue in its own right: report it right away + // so Settings shows the repo sizes even before a fresh sync finishes. + updateStatus { current -> + current.copy( + fdroid = current.fdroid.copy( + apps = fdroidCached?.apps?.size ?: 0, + releases = fdroidCached?.releases?.size ?: 0, + indexBytes = fdroidDiskFile.takeIf { it.exists() }?.length() ?: 0L, + lastSyncedAt = fdroidDiskFile.takeIf { it.exists() }?.lastModified() ?: 0L, + ), + izzy = current.izzy.copy( + apps = izzyCached?.apps?.size ?: 0, + releases = izzyCached?.releases?.size ?: 0, + indexBytes = izzyDiskFile.takeIf { it.exists() }?.length() ?: 0L, + lastSyncedAt = izzyDiskFile.takeIf { it.exists() }?.lastModified() ?: 0L, + ), + ) + } + val merged = mergeCatalogs(fdroidCached, izzyCached) if (merged.apps.isNotEmpty()) { cached = merged @@ -195,6 +268,7 @@ class FdroidCatalog( else -> izzyMod } _lastError.value = null + updateStatus { current -> current.copy(lastError = null) } _catalogFlow.value = merged Log.i(TAG, "Loaded ${merged.apps.size} F-Droid apps from disk cache.") } @@ -211,6 +285,15 @@ class FdroidCatalog( val firstReady = CompletableDeferred() activeSyncDeferred = firstReady + // Both repositories are about to be fetched: say so in the status + // so Settings can show that a sync is running. + updateStatus { current -> + current.copy( + fdroid = current.fdroid.copy(syncing = true, error = null), + izzy = current.izzy.copy(syncing = true, error = null), + ) + } + catalogScope.launch { val errors = mutableListOf() try { @@ -223,6 +306,10 @@ class FdroidCatalog( .onFailure { e -> if (e is CancellationException) throw e Log.w(TAG, "IzzyOnDroid sync failed: ${e.message}") + val reason = e.message ?: e::class.java.simpleName + updateStatus { current -> + current.copy(izzy = current.izzy.copy(syncing = false, error = reason)) + } synchronized(errors) { errors += "IzzyOnDroid: ${e.message ?: e::class.java.simpleName}" } @@ -237,6 +324,10 @@ class FdroidCatalog( .onFailure { e -> if (e is CancellationException) throw e Log.e(TAG, "Official F-Droid sync failed across mirrors: ${e.message}", e) + val reason = e.message ?: e::class.java.simpleName + updateStatus { current -> + current.copy(fdroid = current.fdroid.copy(syncing = false, error = reason)) + } synchronized(errors) { errors += "F-Droid: ${e.message ?: e::class.java.simpleName}" } @@ -251,6 +342,7 @@ class FdroidCatalog( if (finalCatalog != null && finalCatalog.apps.isNotEmpty()) { cachedAt = System.currentTimeMillis() _lastError.value = null + updateStatus { current -> current.copy(lastError = null) } if (!firstReady.isCompleted) { firstReady.complete(finalCatalog) } @@ -259,6 +351,7 @@ class FdroidCatalog( errors.joinToString(" | ").ifBlank { "F-Droid is unavailable right now." } } _lastError.value = combinedMessage + updateStatus { current -> current.copy(lastError = combinedMessage) } if (!firstReady.isCompleted) { firstReady.completeExceptionally(IOException(combinedMessage)) } @@ -267,6 +360,7 @@ class FdroidCatalog( if (e !is CancellationException) { val msg = e.message ?: "F-Droid is unavailable right now." _lastError.value = msg + updateStatus { current -> current.copy(lastError = msg) } if (!firstReady.isCompleted) { firstReady.completeExceptionally(e) } @@ -277,6 +371,13 @@ class FdroidCatalog( activeSyncDeferred = null } } + // Nothing is being fetched any more. + updateStatus { current -> + current.copy( + fdroid = current.fdroid.copy(syncing = false), + izzy = current.izzy.copy(syncing = false), + ) + } } } @@ -291,15 +392,40 @@ class FdroidCatalog( stateMutex.withLock { if (newFdroid != null && newFdroid.apps.isNotEmpty()) { fdroidCached = newFdroid + updateStatus { current -> + current.copy( + fdroid = current.fdroid.copy( + apps = newFdroid.apps.size, + releases = newFdroid.releases.size, + indexBytes = fdroidDiskFile.takeIf { it.exists() }?.length() ?: 0L, + lastSyncedAt = System.currentTimeMillis(), + syncing = false, + error = null, + ), + ) + } } if (newIzzy != null && newIzzy.apps.isNotEmpty()) { izzyCached = newIzzy + updateStatus { current -> + current.copy( + izzy = current.izzy.copy( + apps = newIzzy.apps.size, + releases = newIzzy.releases.size, + indexBytes = izzyDiskFile.takeIf { it.exists() }?.length() ?: 0L, + lastSyncedAt = System.currentTimeMillis(), + syncing = false, + error = null, + ), + ) + } } val merged = mergeCatalogs(fdroidCached, izzyCached) if (merged.apps.isNotEmpty()) { cached = merged cachedAt = System.currentTimeMillis() _lastError.value = null + updateStatus { current -> current.copy(lastError = null) } _catalogFlow.value = merged if (!firstReady.isCompleted) { firstReady.complete(merged) diff --git a/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt b/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt index d4b4dd5..e6e54a4 100644 --- a/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt +++ b/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt @@ -47,6 +47,12 @@ class StoreRepository( */ val fdroidUpdates: StateFlow = fdroid.catalogFlow + /** + * Live status of the upstream catalogue: which repos are mirrored, how + * big they are, when they were last synced and what is running now. + */ + val fdroidStatus: StateFlow = fdroid.status + /** * The Kiwi Store catalogue only. * diff --git a/app/src/main/java/com/gothwad/store/ui/Routes.kt b/app/src/main/java/com/gothwad/store/ui/Routes.kt index e817982..5e0346c 100644 --- a/app/src/main/java/com/gothwad/store/ui/Routes.kt +++ b/app/src/main/java/com/gothwad/store/ui/Routes.kt @@ -79,6 +79,7 @@ object Routes { const val APPEARANCE = "settings/appearance" const val NOTIFICATIONS = "settings/notifications" const val NETWORK = "settings/network" + const val SOURCES = "settings/sources" const val PRIVACY = "settings/privacy" const val ADVANCED = "settings/advanced" const val DEVICE_INFO = "settings/device" diff --git a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt index 5decac0..069d3b7 100644 --- a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt +++ b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt @@ -15,6 +15,7 @@ import com.gothwad.store.data.model.CatalogResponse import com.gothwad.store.data.model.StoreApp import com.gothwad.store.data.model.StoreCategory import com.gothwad.store.data.model.StoreRelease +import com.gothwad.store.data.remote.FdroidStatus import com.gothwad.store.data.remote.StoreException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -110,6 +111,12 @@ class StoreViewModel : ViewModel() { /** Why the F-Droid catalogue is missing, if it is. */ val fdroidError: StateFlow = repository.fdroidError + /** + * Live status of the upstream catalogue (F-Droid + IzzyOnDroid): repo + * sizes, sync times and what is running right now. Settings shows it. + */ + val fdroidStatus: StateFlow = repository.fdroidStatus + val downloads: StateFlow> = downloader.tasks val installPhases: StateFlow> = installer.phases diff --git a/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt b/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt index 8088919..b8d3a1d 100644 --- a/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt +++ b/app/src/main/java/com/gothwad/store/ui/components/Chrome.kt @@ -1,6 +1,7 @@ package com.gothwad.store.ui.components import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -62,12 +63,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.gothwad.store.R import com.gothwad.store.ui.Routes import com.gothwad.store.ui.theme.GothwadTheme @@ -176,6 +179,72 @@ fun GothwadTopBar( } } +/** + * The one Kiwi Store header. Every tab uses the very same bar: the Kiwi Store + * icon on the left, the search field in the middle and the profile avatar on + * the right — the way the Play Store does it. Tapping the search field opens + * the search screen, the avatar opens the profile. + */ +@Composable +fun KiwiStoreTopBar( + onSearch: () -> Unit, + onProfile: () -> Unit, + onLogo: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .statusBarsPadding(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Image( + painter = painterResource(R.mipmap.ic_launcher), + contentDescription = "Kiwi Store", + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onLogo), + ) + Row( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(50)) + .background(GothwadTheme.extra.searchBar) + .clickable(onClick = onSearch) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Search, + contentDescription = null, + tint = GothwadTheme.extra.textSecondary, + modifier = Modifier.size(19.dp), + ) + Text( + "Search apps & games", + color = GothwadTheme.extra.textTertiary, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 10.dp), + ) + } + AvatarAction(onProfile) + } + DividerLine() + } +} + /** Search-tab top bar: pill search field with back, clear and voice actions. */ @Composable fun SearchTopBar( diff --git a/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt b/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt index 16a8d4b..8732945 100644 --- a/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/com/gothwad/store/ui/navigation/AppNavHost.kt @@ -66,6 +66,7 @@ import com.gothwad.store.ui.screens.SearchScreen import com.gothwad.store.ui.screens.SettingsScreen import com.gothwad.store.ui.screens.ShizukuScreen import com.gothwad.store.ui.screens.SignInScreen +import com.gothwad.store.ui.screens.SourcesScreen import com.gothwad.store.ui.screens.StorageScreen import com.gothwad.store.ui.screens.TopChartsScreen import com.gothwad.store.ui.screens.WelcomeScreen @@ -226,6 +227,7 @@ fun AppNavHost( composable(Routes.APPEARANCE) { AppearanceScreen(vm, navController) } composable(Routes.NOTIFICATIONS) { NotificationsScreen(vm, navController) } composable(Routes.NETWORK) { NetworkScreen(vm, navController) } + composable(Routes.SOURCES) { SourcesScreen(vm, navController) } composable(Routes.PRIVACY) { PrivacyScreen(vm, navController) } composable(Routes.ADVANCED) { AdvancedScreen(vm, navController) } composable(Routes.DEVICE_INFO) { DeviceInfoScreen(vm, navController) } diff --git a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt index 16e57f6..a8f736c 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt @@ -33,11 +33,10 @@ import androidx.compose.material.icons.filled.PrivacyTip import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Storefront import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -67,15 +66,13 @@ import com.gothwad.store.ui.StoreEvent import com.gothwad.store.ui.StoreViewModel import com.gothwad.store.ui.components.BannerTone import com.gothwad.store.ui.components.BrandAvatar -import com.gothwad.store.ui.components.DownloadsAction import com.gothwad.store.ui.components.GothwadScaffold -import com.gothwad.store.ui.components.GothwadTopBar +import com.gothwad.store.ui.components.KiwiStoreTopBar import com.gothwad.store.ui.components.InfoBanner import com.gothwad.store.ui.components.KeyValueRow import com.gothwad.store.ui.components.OutlineButton import com.gothwad.store.ui.components.PillLabel import com.gothwad.store.ui.components.PrimaryButton -import com.gothwad.store.ui.components.SearchAction import com.gothwad.store.ui.components.SectionHeader import com.gothwad.store.ui.components.SettingRow import com.gothwad.store.ui.components.SettingsGroup @@ -104,20 +101,12 @@ fun AccountScreen(vm: StoreViewModel, nav: NavController) { // was missing entirely. GothwadScaffold( topBar = { - GothwadTopBar( - title = "Profile", - showBrandMark = true, - actions = { - SearchAction { nav.navigate(Routes.SEARCH) } - DownloadsAction { nav.navigate(Routes.DOWNLOADS) } - IconButton(onClick = { nav.navigate(Routes.SETTINGS) }) { - Icon( - Icons.Filled.Settings, - contentDescription = "Settings", - tint = GothwadTheme.extra.textSecondary, - ) - } - }, + // Every tab shares the very same header: Kiwi Store icon, search + // field, profile avatar. + KiwiStoreTopBar( + onSearch = { nav.navigate(Routes.SEARCH) }, + onProfile = { nav.navigate(Routes.SETTINGS) }, + onLogo = { nav.navigate(Routes.HOME) { launchSingleTop = true } }, ) }, ) { padding -> @@ -272,6 +261,12 @@ fun AccountScreen(vm: StoreViewModel, nav: NavController) { icon = Icons.Filled.Storage, onClick = { nav.navigate(Routes.STORAGE) }, ) + SettingRow( + title = "App sources", + subtitle = "F-Droid repos, catalogue and what is running", + icon = Icons.Filled.Storefront, + onClick = { nav.navigate(Routes.SOURCES) }, + ) SettingRow( title = "Privacy", subtitle = "What Kiwi Store collects and stores", diff --git a/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt b/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt index 80d896f..93957bb 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt @@ -53,19 +53,16 @@ import com.gothwad.store.ui.UiState import com.gothwad.store.ui.components.AppGridCard import com.gothwad.store.ui.components.AppRowItem import com.gothwad.store.ui.components.BannerTone -import com.gothwad.store.ui.components.AvatarAction import com.gothwad.store.ui.components.BannerCard import com.gothwad.store.ui.components.CategoryTile import com.gothwad.store.ui.components.DividerLine -import com.gothwad.store.ui.components.DownloadsAction import com.gothwad.store.ui.components.EmptyState import com.gothwad.store.ui.components.ErrorState import com.gothwad.store.ui.components.GameCard import com.gothwad.store.ui.components.GothwadScaffold +import com.gothwad.store.ui.components.KiwiStoreTopBar import com.gothwad.store.ui.components.InfoBanner -import com.gothwad.store.ui.components.GothwadTopBar import com.gothwad.store.ui.components.PlayChip -import com.gothwad.store.ui.components.SearchAction import com.gothwad.store.ui.components.SectionHeader import com.gothwad.store.ui.components.SkeletonList import com.gothwad.store.ui.components.SmallAppCard @@ -87,14 +84,12 @@ fun HomeScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Kiwi Store", - showBrandMark = true, - actions = { - SearchAction { nav.navigate(Routes.SEARCH) } - DownloadsAction { nav.navigate(Routes.DOWNLOADS) } - AvatarAction { nav.navigate(Routes.ACCOUNT) } - }, + // Every tab shares the very same header: Kiwi Store icon, search + // field, profile avatar. + KiwiStoreTopBar( + onSearch = { nav.navigate(Routes.SEARCH) }, + onProfile = { nav.navigate(Routes.ACCOUNT) }, + onLogo = { nav.navigate(Routes.HOME) { launchSingleTop = true } }, ) }, ) { padding -> diff --git a/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt index 89adcce..adf6b05 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt @@ -42,7 +42,6 @@ import com.gothwad.store.ui.UiState import com.gothwad.store.ui.components.AppGridCard import com.gothwad.store.ui.components.AppRowItem import com.gothwad.store.ui.components.BannerTone -import com.gothwad.store.ui.components.AvatarAction import com.gothwad.store.ui.components.CategoryTile import com.gothwad.store.ui.components.DividerLine import com.gothwad.store.ui.components.DownloadsAction @@ -50,6 +49,7 @@ import com.gothwad.store.ui.components.EmptyState import com.gothwad.store.ui.components.ErrorState import com.gothwad.store.ui.components.InfoBanner import com.gothwad.store.ui.components.GothwadScaffold +import com.gothwad.store.ui.components.KiwiStoreTopBar import com.gothwad.store.ui.components.GothwadTopBar import com.gothwad.store.ui.components.PlayChip import com.gothwad.store.ui.components.SearchAction @@ -90,14 +90,12 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Apps", - showBrandMark = true, - actions = { - SearchAction { nav.navigate(Routes.SEARCH) } - DownloadsAction { nav.navigate(Routes.DOWNLOADS) } - AvatarAction { nav.navigate(Routes.ACCOUNT) } - }, + // Every tab shares the very same header: Kiwi Store icon, search + // field, profile avatar. + KiwiStoreTopBar( + onSearch = { nav.navigate(Routes.SEARCH) }, + onProfile = { nav.navigate(Routes.ACCOUNT) }, + onLogo = { nav.navigate(Routes.HOME) { launchSingleTop = true } }, ) }, ) { padding -> @@ -132,6 +130,7 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { vm = vm, nav = nav, emptyTitle = "No apps published yet", + headerTitle = "Apps", ) } } @@ -158,15 +157,12 @@ fun KiwiScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Kiwi", - subtitle = "Live on kiwistore.gothwadtech.com", - showBrandMark = true, - actions = { - SearchAction { nav.navigate(Routes.SEARCH) } - DownloadsAction { nav.navigate(Routes.DOWNLOADS) } - AvatarAction { nav.navigate(Routes.ACCOUNT) } - }, + // Every tab shares the very same header: Kiwi Store icon, search + // field, profile avatar. + KiwiStoreTopBar( + onSearch = { nav.navigate(Routes.SEARCH) }, + onProfile = { nav.navigate(Routes.ACCOUNT) }, + onLogo = { nav.navigate(Routes.HOME) { launchSingleTop = true } }, ) }, ) { padding -> @@ -242,14 +238,12 @@ fun GamesScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Games", - showBrandMark = true, - actions = { - SearchAction { nav.navigate(Routes.SEARCH) } - DownloadsAction { nav.navigate(Routes.DOWNLOADS) } - AvatarAction { nav.navigate(Routes.ACCOUNT) } - }, + // Every tab shares the very same header: Kiwi Store icon, search + // field, profile avatar. + KiwiStoreTopBar( + onSearch = { nav.navigate(Routes.SEARCH) }, + onProfile = { nav.navigate(Routes.ACCOUNT) }, + onLogo = { nav.navigate(Routes.HOME) { launchSingleTop = true } }, ) }, ) { padding -> @@ -266,6 +260,7 @@ fun GamesScreen(vm: StoreViewModel, nav: NavController) { emptyTitle = "No games published yet", emptyMessage = "Games appear here as soon as a developer publishes one from the " + "Gothwad Developer Console.", + headerTitle = "Games", ) } } @@ -280,6 +275,7 @@ private fun AppList( nav: NavController, emptyTitle: String, emptyMessage: String = "Nothing is published in this section yet.", + headerTitle: String? = null, ) { if (apps.isEmpty()) { EmptyState( @@ -292,6 +288,14 @@ private fun AppList( return } LazyColumn(contentPadding = PaddingValues(bottom = Spacing.bottomContentPadding)) { + if (headerTitle != null) { + item { + SectionHeader( + title = headerTitle, + subtitle = "${apps.size} listing${if (apps.size == 1) "" else "s"}", + ) + } + } itemsIndexed(apps, key = { _, app -> app.id }) { index, app -> Column { if (index > 0) DividerLine(startIndent = 64.dp) diff --git a/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt index 753f089..67ad256 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Storefront import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.Text @@ -132,6 +133,18 @@ fun SettingsScreen(vm: StoreViewModel, nav: NavController) { } } + item { SectionHeader(title = "Catalogue & sources") } + item { + SettingsGroup { + SettingRow( + title = "App sources", + subtitle = "Kiwi Store web, F-Droid repos and what is running", + icon = Icons.Filled.Storefront, + onClick = { nav.navigate(Routes.SOURCES) }, + ) + } + } + item { SectionHeader(title = "Notifications & network") } item { SettingsGroup { @@ -609,11 +622,26 @@ fun AdvancedScreen(vm: StoreViewModel, nav: NavController) { Column { val fdroid by vm.fdroid.collectAsStateWithLifecycle() val fdroidError by vm.fdroidError.collectAsStateWithLifecycle() + val sources by vm.fdroidStatus.collectAsStateWithLifecycle() + KeyValueRow("Kiwi Store web", BuildConfig.STORE_WEB_URL) + KeyValueRow("F-Droid repository", FdroidCatalog.REPO_URL) KeyValueRow("F-Droid index", FdroidCatalog.INDEX_URL) KeyValueRow("F-Droid apps", "${fdroid.apps.size}") + KeyValueRow("F-Droid releases", "${sources.totalReleases}") + KeyValueRow("IzzyOnDroid repository", FdroidCatalog.IZZY_REPO_URL) + KeyValueRow("Mirrors", "${sources.mirrors.size} HTTPS hosts") fdroidError?.let { KeyValueRow("F-Droid problem", it) } } } + item { + Column(Modifier.padding(Spacing.screenH)) { + OutlineButton( + label = "Open app sources", + onClick = { nav.navigate(Routes.SOURCES) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } item { SectionHeader(title = "Maintenance") } item { SettingsGroup { diff --git a/app/src/main/java/com/gothwad/store/ui/screens/SourcesScreen.kt b/app/src/main/java/com/gothwad/store/ui/screens/SourcesScreen.kt new file mode 100644 index 0000000..76babdc --- /dev/null +++ b/app/src/main/java/com/gothwad/store/ui/screens/SourcesScreen.kt @@ -0,0 +1,221 @@ +package com.gothwad.store.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.gothwad.store.BuildConfig +import com.gothwad.store.core.AppGraph +import com.gothwad.store.core.Format +import com.gothwad.store.data.auth.AuthState +import com.gothwad.store.data.download.isActive +import com.gothwad.store.data.remote.FdroidRepoStatus +import com.gothwad.store.ui.NetworkStatus +import com.gothwad.store.ui.Routes +import com.gothwad.store.ui.StoreViewModel +import com.gothwad.store.ui.UiState +import com.gothwad.store.ui.components.BannerTone +import com.gothwad.store.ui.components.GothwadScaffold +import com.gothwad.store.ui.components.InfoBanner +import com.gothwad.store.ui.components.KeyValueRow +import com.gothwad.store.ui.components.OutlineButton +import com.gothwad.store.ui.components.SectionHeader +import com.gothwad.store.ui.components.StatTile +import com.gothwad.store.ui.components.SubScreenHeader +import com.gothwad.store.ui.theme.GothwadTheme +import com.gothwad.store.ui.theme.Spacing + +/** + * "App sources" — everything the app is running on, in one place: the Kiwi + * Store web catalogue, the F-Droid / IzzyOnDroid mirrors with their repo + * URLs, index sizes, listing counts and sync times, plus a live view of what + * is running right now (catalogue sync, download jobs, installer back-ends, + * network and account). + */ +@Composable +fun SourcesScreen(vm: StoreViewModel, nav: NavController) { + val status by vm.fdroidStatus.collectAsStateWithLifecycle() + val fdroid by vm.fdroid.collectAsStateWithLifecycle() + val kiwi by vm.kiwi.collectAsStateWithLifecycle() + val tasks by vm.downloads.collectAsStateWithLifecycle() + val capabilities by AppGraph.installer.capabilities.collectAsStateWithLifecycle() + val authState by AppGraph.auth.state.collectAsStateWithLifecycle() + var notice by rememberSaveable { mutableStateOf(null) } + + val webApps = (kiwi as? UiState.Ready)?.value?.apps?.size ?: 0 + val mergedApps = fdroid.apps.size + val activeJobs = tasks.count { it.isActive } + val fdroidJobs = tasks.count { it.appId.startsWith("fdroid:") } + val fdroidBytes = tasks.filter { it.appId.startsWith("fdroid:") }.sumOf { it.bytesDownloaded } + val signedIn = authState as? AuthState.SignedIn + + GothwadScaffold( + topBar = { + SubScreenHeader( + title = "App sources", + subtitle = "What Kiwi Store is running on", + onBack = { nav.popBackStack() }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(bottom = Spacing.bottomContentPadding), + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.screenH), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + StatTile("Web store", "$webApps", modifier = Modifier.weight(1f)) + StatTile("F-Droid", "$mergedApps", modifier = Modifier.weight(1f)) + StatTile("APKs", Format.bytes(AppGraph.downloader.usedBytes()), modifier = Modifier.weight(1f)) + } + } + + item { SectionHeader(title = "Kiwi Store (first-party)") } + item { + Column { + KeyValueRow("Web store", BuildConfig.STORE_WEB_URL) + KeyValueRow("API", BuildConfig.STORE_API_BASE_URL) + KeyValueRow("Listings", "$webApps") + KeyValueRow("Status", "Connected — this is the Kiwi tab") + } + } + + item { SectionHeader(title = "F-Droid") } + item { RepoRows(status.fdroid) } + item { SectionHeader(title = "IzzyOnDroid") } + item { RepoRows(status.izzy) } + + item { SectionHeader(title = "Failover mirrors") } + item { + Column { + KeyValueRow("Mirrors", "${status.mirrors.size} HTTPS hosts") + status.mirrors.forEach { mirror -> + KeyValueRow("·", mirror) + } + } + } + + item { SectionHeader(title = "Downloads") } + item { + Column { + KeyValueRow("Jobs", "${tasks.size}") + KeyValueRow("Running", "$activeJobs") + KeyValueRow("F-Droid APKs", "$fdroidJobs · ${Format.bytes(fdroidBytes)}") + KeyValueRow("APK bytes on device", Format.bytes(AppGraph.downloader.usedBytes())) + KeyValueRow("Where the bytes come from", "F-Droid / IzzyOnDroid host the APKs") + } + } + + item { SectionHeader(title = "Running right now") } + item { + Column { + KeyValueRow( + "Catalogue sync", + if (status.syncing) "Syncing the F-Droid index…" else "Idle", + ) + KeyValueRow("Download service", "$activeJobs active job(s)") + KeyValueRow("Install back-ends", capabilities.healthSummary()) + KeyValueRow("Network", NetworkStatus.networkLabel()) + KeyValueRow( + "Account", + signedIn?.user?.displayName?.let { "Signed in as $it" } ?: "Not signed in", + ) + } + } + + status.lastError?.let { error -> + item { + Column(Modifier.padding(Spacing.screenH)) { + InfoBanner( + title = "F-Droid problem", + message = error, + tone = BannerTone.Warning, + ) + } + } + } + + notice?.let { text -> + item { + Column(Modifier.padding(Spacing.screenH)) { + InfoBanner(title = "Done", message = text) + } + } + } + + item { + Column(Modifier.padding(Spacing.screenH)) { + Spacer(Modifier.height(10.dp)) + OutlineButton( + label = "Refresh the F-Droid catalogue", + onClick = { + vm.retryFdroid() + notice = "F-Droid and IzzyOnDroid are syncing again." + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlineButton( + label = "Open download history", + onClick = { nav.navigate(Routes.DOWNLOAD_HISTORY) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Text( + "F-Droid APKs are downloaded straight from the repositories above — " + + "they are not hosted, proxied or counted by Gothwad.", + fontSize = 12.sp, + color = GothwadTheme.extra.textTertiary, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } + } +} + +@Composable +private fun RepoRows(repo: FdroidRepoStatus) { + Column { + KeyValueRow("Repository", repo.repoUrl) + KeyValueRow("Index", repo.indexUrl) + KeyValueRow("Apps", "${repo.apps}") + KeyValueRow("Releases", "${repo.releases}") + KeyValueRow("Index size", Format.bytes(repo.indexBytes)) + KeyValueRow( + "Last sync", + if (repo.lastSyncedAt > 0L) Format.relative(repo.lastSyncedAt) else "Never", + ) + KeyValueRow( + "Status", + when { + repo.syncing -> "Syncing…" + !repo.error.isNullOrBlank() -> repo.error.orEmpty() + repo.apps > 0 -> "Ready" + else -> "Not downloaded yet" + }, + ) + } +} diff --git a/app/src/test/java/com/gothwad/store/SourcesStatusTest.kt b/app/src/test/java/com/gothwad/store/SourcesStatusTest.kt new file mode 100644 index 0000000..33e9d4b --- /dev/null +++ b/app/src/test/java/com/gothwad/store/SourcesStatusTest.kt @@ -0,0 +1,64 @@ +package com.gothwad.store + +import com.gothwad.store.data.remote.FdroidCatalog +import com.gothwad.store.data.remote.FdroidRepoStatus +import com.gothwad.store.data.remote.FdroidStatus +import com.gothwad.store.ui.Routes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The "App sources" screen tells the user which repositories Kiwi Store + * mirrors, how big they are and what is running right now — so the status + * model has to know both repos and add their numbers up. + */ +class SourcesStatusTest { + + @Test + fun `both upstream repositories are known`() { + val status = FdroidStatus() + + assertEquals("F-Droid", status.fdroid.name) + assertEquals(FdroidCatalog.REPO_URL, status.fdroid.repoUrl) + assertEquals(FdroidCatalog.INDEX_URL, status.fdroid.indexUrl) + assertEquals("IzzyOnDroid", status.izzy.name) + assertEquals(FdroidCatalog.IZZY_REPO_URL, status.izzy.repoUrl) + assertEquals(2, status.repos.size) + assertTrue(status.mirrors.isNotEmpty()) + assertEquals(0, status.totalApps) + assertFalse(status.syncing) + } + + @Test + fun `totals add up over both repositories`() { + val status = FdroidStatus( + fdroid = FdroidRepoStatus( + name = "F-Droid", + repoUrl = FdroidCatalog.REPO_URL, + indexUrl = FdroidCatalog.INDEX_URL, + apps = 3000, + releases = 2900, + indexBytes = 9_000_000L, + ), + izzy = FdroidRepoStatus( + name = "IzzyOnDroid", + repoUrl = FdroidCatalog.IZZY_REPO_URL, + indexUrl = FdroidCatalog.IZZY_INDEX_URL, + apps = 200, + releases = 190, + indexBytes = 1_600_000L, + ), + ) + + assertEquals(3200, status.totalApps) + assertEquals(3090, status.totalReleases) + assertEquals(10_600_000L, status.totalIndexBytes) + } + + @Test + fun `the sources screen has its own route`() { + assertEquals("settings/sources", Routes.SOURCES) + } +} diff --git a/task.md b/task.md index f7f9307..4a43391 100644 --- a/task.md +++ b/task.md @@ -4,10 +4,12 @@ > **Look:** EXACTLY like Google Play Store (2024 new UI) > **Brand colour:** sky blue `#0494F4` (the `#01875F` green values below are historical — take colours from `ui/theme/Color.kt`). > **Tabs (5):** Home | Apps | Kiwi | Games | Profile -> **Top Bar Rule:** -> - Home, Apps, Kiwi, Games, Profile → tab title (bold, 20sp, #202124) + search action + downloads + Profile avatar (32dp green circle, white "G") on right. NO search bar. -> - Search → NOT a tab. It is opened from the search action in every tab header and shows ONLY a clean search bar (pill shape, grey bg, back arrow, text input, mic icon). No title, no avatar. +> **Top Bar Rule (ONE header for all 5 tabs):** +> - Left: Kiwi Store icon (launcher logo, 28dp). Centre: search field (pill, grey bg, magnifier + "Search apps & games", tap → search screen). Right: Profile avatar (32dp, white "G"). +> - No per-tab titles, no separate search bar per tab, no Search tab. +> - Search screen → clean search bar (pill shape, grey bg, back arrow, text input, mic icon). No title, no avatar. > - Kiwi tab → the apps that are live on the Kiwi Store web, in the Apps-tab list form (no F-Droid / upstream mirrors). +> **Settings:** "App sources" shows the Kiwi web catalogue, the F-Droid / IzzyOnDroid repos (URL, index size, listing counts, last sync, mirrors), the download counters and what is running right now. > **Data Source:** ONLY Aptoide API (https://ws75.aptoide.com/api/7/). No F-Droid, no backend, no Supabase, no Firebase. > **Status:** Phase 1 & 2 already DONE. Continue from Phase 3.