diff --git a/README.md b/README.md index b046288..171d103 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ 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. +- 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/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/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/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..5e0346c 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}" @@ -67,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 bdfbe17..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 @@ -62,6 +63,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() @@ -102,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 @@ -120,6 +135,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 +252,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 { + val response = repository.firstPartyCatalog(null, null, "") + response.copy(apps = response.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..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 @@ -36,12 +37,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 @@ -61,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 @@ -175,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( @@ -279,7 +349,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..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 @@ -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 @@ -65,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 @@ -74,8 +76,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 +91,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 +121,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 ─────────────────────────────────────────────────── @@ -219,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 2a20d66..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,9 +66,8 @@ 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 @@ -103,19 +101,12 @@ fun AccountScreen(vm: StoreViewModel, nav: NavController) { // was missing entirely. GothwadScaffold( topBar = { - GothwadTopBar( - title = "Profile", - showBrandMark = true, - actions = { - 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 -> @@ -270,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 e263df9..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,17 +53,15 @@ 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.SectionHeader import com.gothwad.store.ui.components.SkeletonList @@ -86,13 +84,12 @@ fun HomeScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Kiwi Store", - showBrandMark = true, - actions = { - 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 780e7b3..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 @@ -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 @@ -41,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 @@ -49,8 +49,10 @@ 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 import com.gothwad.store.ui.components.SegmentedRow import com.gothwad.store.ui.components.SectionHeader import com.gothwad.store.ui.components.SkeletonList @@ -88,13 +90,12 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Apps", - showBrandMark = true, - actions = { - 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 -> @@ -129,12 +130,104 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { vm = vm, nav = nav, emptyTitle = "No apps published yet", + headerTitle = "Apps", ) } } } } +/** + * 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 = { + // 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 -> + 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() @@ -145,13 +238,12 @@ fun GamesScreen(vm: StoreViewModel, nav: NavController) { GothwadScaffold( topBar = { - GothwadTopBar( - title = "Games", - showBrandMark = true, - actions = { - 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 -> @@ -168,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", ) } } @@ -182,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( @@ -194,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) @@ -280,7 +382,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/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/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/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 3cea1f1..4a43391 100644 --- a/task.md +++ b/task.md @@ -3,10 +3,13 @@ > **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 -> **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. +> **Tabs (5):** Home | Apps | Kiwi | Games | Profile +> **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.