new: extract WebView feature module

This commit is contained in:
Antoine Jaury
2026-07-08 17:08:02 +02:00
parent f6a12ad84a
commit 0151d92b6a
23 changed files with 116 additions and 32 deletions
@@ -16,12 +16,13 @@ import bzh.ajaury.chombev.di.sharedModule
import bzh.ajaury.chombev.navigation.MainBottomBar
import bzh.ajaury.chombev.navigation.MainTopBar
import bzh.ajaury.chombev.navigation.Route
import bzh.ajaury.chombev.navigation.WebPage
import bzh.ajaury.chombev.navigation.WebPages
import bzh.ajaury.chombev.player.di.audioPlayerModule
import bzh.ajaury.chombev.player.ui.PlayerScreen
import bzh.ajaury.chombev.records.ui.RecordsScreen
import bzh.ajaury.chombev.subtitle.di.subtitleModule
import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage
import bzh.ajaury.chombev.web.ui.WebViewScreen
import org.koin.compose.KoinApplication
import org.koin.dsl.koinConfiguration
@@ -81,8 +82,8 @@ fun App() {
MainBottomBar(
selectedPage = selectedPage,
onRecordsClick = { selectMainTab(null) },
onDeepenClick = { selectMainTab(WebPage.DEEPEN) },
onAboutClick = { selectMainTab(WebPage.ABOUT) },
onDeepenClick = { selectMainTab(WebPages.DEEPEN) },
onAboutClick = { selectMainTab(WebPages.ABOUT) },
)
}
},
@@ -7,7 +7,7 @@ import bzh.ajaury.chombev.preferences.di.preferencesModule
import bzh.ajaury.chombev.records.di.recordsModule
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
import bzh.ajaury.chombev.resources.di.resourcesModule
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import bzh.ajaury.chombev.web.di.webviewModule
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
@@ -17,7 +17,7 @@ val sharedModule = module {
includes(preferencesModule)
includes(recordsModule)
includes(resourcesModule)
includes(webviewModule)
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
viewModelOf(::WebViewModel)
}
@@ -16,6 +16,7 @@ import bzh.ajaury.chombev.resources.generated.resources.nav_about
import bzh.ajaury.chombev.resources.generated.resources.nav_deepen
import bzh.ajaury.chombev.resources.generated.resources.nav_records
import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage
import org.jetbrains.compose.resources.stringResource
/**
@@ -40,13 +41,13 @@ fun MainBottomBar(
label = { Text(text = stringResource(Res.string.nav_records)) },
)
NavigationBarItem(
selected = selectedPage == WebPage.DEEPEN,
selected = selectedPage == WebPages.DEEPEN,
onClick = onDeepenClick,
icon = { Icon(imageVector = Icons.Filled.School, contentDescription = null) },
label = { Text(text = stringResource(Res.string.nav_deepen)) },
)
NavigationBarItem(
selected = selectedPage == WebPage.ABOUT,
selected = selectedPage == WebPages.ABOUT,
onClick = onAboutClick,
icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
label = { Text(text = stringResource(Res.string.nav_about)) },
@@ -1,6 +1,7 @@
package bzh.ajaury.chombev.navigation
import androidx.navigation3.runtime.NavKey
import bzh.ajaury.chombev.web.model.WebPage
import kotlinx.serialization.Serializable
@Serializable
@@ -1,24 +1,17 @@
package bzh.ajaury.chombev.navigation
import kotlinx.serialization.Serializable
import bzh.ajaury.chombev.web.model.WebPage
/**
* Web destinations reachable from the main bottom navigation bar and shown inside the app.
*
* [offlineAsset] is the resource path of a bundled HTML snapshot shown when the online [url] cannot
* be reached, so the page still has content offline.
*/
@Serializable
enum class WebPage(
val url: String,
val offlineAsset: String,
) {
DEEPEN(
object WebPages {
val DEEPEN = WebPage(
url = "https://ksk.bzh/fr/chom-bev-approfondir/",
offlineAsset = "files/offline_deepen.html",
),
ABOUT(
)
val ABOUT = WebPage(
url = "https://ksk.bzh/fr/chom-bev-a-propos/",
offlineAsset = "files/offline_about.html",
),
)
}
@@ -1,24 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Embeds a live web page. Platforms with a native web view (Android, iOS) render the page inline,
* honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to
* [WebViewUnavailable], which offers to open the page in the system browser.
*
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline
* snapshot — so the page still shows content. When it is `null` no fallback is loaded.
*
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
* loader. Fallback platforms report `false` immediately since there is nothing to load.
*/
@Composable
expect fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
)
@@ -1,35 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import bzh.ajaury.chombev.theme.ChomBevTheme
/**
* Full-surface loading overlay shown on top of the embedded web view while a page is loading. The
* opaque [MaterialTheme.colorScheme.surface] background hides the blank web view until the page is
* ready, so the transition reads cleanly in both light and dark themes.
*/
@Composable
internal fun WebViewLoader(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
private fun WebViewLoaderPreview() {
ChomBevTheme {
WebViewLoader(modifier = Modifier.fillMaxSize())
}
}
@@ -1,74 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.navigation.WebPage
import bzh.ajaury.chombev.web.ui.viewmodel.WebUiState
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/**
* Displays a [WebPage] inside the app. Injects a [WebViewModel] keyed by the page so each page keeps
* its own online URL and bundled offline snapshot.
*/
@Composable
fun WebViewScreen(
page: WebPage,
contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(),
viewModel: WebViewModel = koinViewModel(key = page.name) { parametersOf(page) },
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
WebViewScreen(
uiState = uiState,
contentPadding = contentPadding,
darkTheme = darkTheme,
)
}
/**
* Renders the embedded page for [uiState], matching the app's [darkTheme]. A [WebViewLoader] covers
* the page while it loads; if the online page fails, the web view swaps in the offline snapshot. The
* surrounding top and bottom bars are provided by the app-level scaffold.
*/
@Composable
fun WebViewScreen(
uiState: WebUiState?,
contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(),
) {
// Start in the loading state; each distinct page loads afresh.
var isLoading by remember(uiState) { mutableStateOf(true) }
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
) {
if (uiState != null) {
PlatformWebView(
url = uiState.url,
offlineHtml = uiState.offlineHtml,
darkTheme = darkTheme,
onLoadingStateChange = { isLoading = it },
modifier = Modifier.fillMaxSize(),
)
}
if (isLoading) {
WebViewLoader(modifier = Modifier.fillMaxSize())
}
}
}
@@ -1,58 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.web_open_in_browser
import bzh.ajaury.chombev.resources.generated.resources.web_unavailable_message
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
/**
* Shown on platforms without an embedded web view: explains the page can't be displayed inline and
* offers to open it in the system browser.
*/
@Composable
internal fun WebViewUnavailable(
url: String,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = stringResource(Res.string.web_unavailable_message),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Button(onClick = { uriHandler.openUri(url) }) {
Text(text = stringResource(Res.string.web_open_in_browser))
}
}
}
@Preview
@Composable
private fun WebViewUnavailablePreview() {
ChomBevTheme {
WebViewUnavailable(url = "https://ksk.bzh")
}
}
@@ -1,16 +0,0 @@
package bzh.ajaury.chombev.web.ui.viewmodel
import androidx.compose.runtime.Immutable
/**
* UI state for [bzh.ajaury.chombev.web.ui.WebViewScreen].
*
* [url] is the online page to display. [offlineHtml] is the bundled fallback shown when [url] fails
* to load; it is `null` until the snapshot has been read (or if reading it failed), in which case no
* offline fallback is available.
*/
@Immutable
data class WebUiState(
val url: String = "",
val offlineHtml: String? = null,
)
@@ -1,42 +0,0 @@
package bzh.ajaury.chombev.web.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.navigation.WebPage
import bzh.ajaury.chombev.resources.domain.ResourceReader
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Owns the state for a single [page]: the online URL to display and its bundled offline snapshot,
* read from resources on init so it is ready as a fallback the moment the online page fails to load.
*/
class WebViewModel(
private val page: WebPage,
private val resourceReader: ResourceReader,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableStateFlow<WebUiState?>(null)
val uiState: StateFlow<WebUiState?> = _uiState.asStateFlow()
init {
loadOfflineSnapshot()
}
private fun loadOfflineSnapshot() {
viewModelScope.launch {
val html = runCatching { resourceReader.read(page.offlineAsset) }
.onFailure {
logger.error(
"Failed to read offline snapshot ${page.offlineAsset}",
it,
)
}.getOrNull()
_uiState.update { WebUiState(url = page.url, offlineHtml = html) }
}
}
}