new: extract WebView feature module

This commit is contained in:
2026-07-08 14:20:32 +02:00
parent f6a12ad84a
commit 0151d92b6a
23 changed files with 116 additions and 32 deletions
+50
View File
@@ -0,0 +1,50 @@
plugins {
id("gwenedeg.kmp.library")
id("gwenedeg.compose")
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidLibrary {
namespace = "bzh.ajaury.chombev.webview"
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.webkit)
}
commonMain.dependencies {
// Core modules
implementation(projects.core.logging)
// Reads the bundled offline page snapshots and web strings
implementation(projects.data.resources)
// Serialization: WebPage is carried in the serializable navigation back stack
implementation(libs.kotlinx.serializationJson)
// UI - Compose
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.components.resources)
// Lifecycle
implementation(libs.lifecycle.viewmodelCompose)
implementation(libs.lifecycle.runtimeCompose)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.core.viewmodel)
implementation(libs.koin.compose.viewmodel)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
@@ -0,0 +1,53 @@
package bzh.ajaury.chombev.web.ui
import android.annotation.SuppressLint
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
@SuppressLint("SetJavaScriptEnabled")
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
// Keep navigation inside the embedded view instead of launching an external browser.
webViewClient = WebClientDelegatingExtLinks(
onLoadingStateChange = onLoadingStateChange,
onMainFrameError = {
// Swap in the bundled offline copy, keeping the real URL as base so links resolve.
offlineHtml?.let { html ->
loadDataWithBaseURL(url, html, "text/html", "utf-8", null)
}
},
)
settings.javaScriptEnabled = false
settings.domStorageEnabled = true
applyDarkTheme(darkTheme)
loadUrl(url)
}
},
// Re-apply on recomposition so a light/dark switch takes effect without reloading the page.
update = { webView -> webView.applyDarkTheme(darkTheme) },
)
}
/**
* Lets the web view darken content to match the app when [darkTheme] is on: the page's
* `prefers-color-scheme: dark` styles are used, falling back to algorithmic darkening.
*/
private fun WebView.applyDarkTheme(darkTheme: Boolean) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, darkTheme)
}
}
@@ -0,0 +1,62 @@
package bzh.ajaury.chombev.web.ui
import android.content.Intent
import android.graphics.Bitmap
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
/**
* Keeps the initial page inside the embedded web view but opens any link the user taps in the system
* browser. Reports page load start/finish through [onLoadingStateChange] so the UI can show a loader.
*
* [onMainFrameError] fires when the top-level page itself fails to load (not a sub-resource), so the
* caller can swap in an offline fallback.
*/
class WebClientDelegatingExtLinks(
private val onLoadingStateChange: (Boolean) -> Unit = {},
private val onMainFrameError: () -> Unit = {},
) : WebViewClient() {
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?,
) {
onLoadingStateChange(true)
}
override fun onPageFinished(
view: WebView?,
url: String?,
) {
onLoadingStateChange(false)
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
// Only fall back for the top-level page; a failed sub-resource (image, etc.) shouldn't
// replace the whole page.
if (request?.isForMainFrame == true) {
onMainFrameError()
}
// Stop the loader even if the page failed, so the user isn't stuck on the spinner.
onLoadingStateChange(false)
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val uri = request.url
// Ouvre tous les liens dans le navigateur
val intent = Intent(Intent.ACTION_VIEW, uri)
view.context.startActivity(intent)
return true // On indique que l'on a géré la navigation
}
}
@@ -0,0 +1,10 @@
package bzh.ajaury.chombev.web.di
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val webviewModule: Module = module {
viewModelOf(::WebViewModel)
}
@@ -0,0 +1,15 @@
package bzh.ajaury.chombev.web.model
import kotlinx.serialization.Serializable
/**
* Web page with a distant and local fallback.
*
* [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
data class WebPage(
val url: String,
val offlineAsset: String,
)
@@ -0,0 +1,24 @@
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,
)
@@ -0,0 +1,34 @@
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
/**
* 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() {
MaterialTheme {
WebViewLoader(modifier = Modifier.fillMaxSize())
}
}
@@ -0,0 +1,74 @@
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.web.model.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.url) { 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())
}
}
}
@@ -0,0 +1,57 @@
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 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() {
MaterialTheme {
WebViewUnavailable(url = "https://ksk.bzh")
}
}
@@ -0,0 +1,16 @@
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,
)
@@ -0,0 +1,42 @@
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.resources.domain.ResourceReader
import bzh.ajaury.chombev.web.model.WebPage
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) }
}
}
}
@@ -0,0 +1,124 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitView
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride
import kotlinx.cinterop.readValue
import platform.CoreGraphics.CGRectZero
import platform.Foundation.NSError
import platform.Foundation.NSURL
import platform.Foundation.NSURLRequest
import platform.UIKit.UIUserInterfaceStyle
import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleDark
import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleLight
import platform.WebKit.WKNavigation
import platform.WebKit.WKNavigationDelegateProtocol
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// WKWebView holds its navigationDelegate weakly, so keep a strong reference across recompositions.
val navigationDelegate = remember { WebViewNavigationDelegate() }
navigationDelegate.onLoadingStateChange = onLoadingStateChange
navigationDelegate.offlineHtml = offlineHtml
navigationDelegate.baseUrl = url
UIKitView(
modifier = modifier,
factory = {
WKWebView(
frame = CGRectZero.readValue(),
configuration = WKWebViewConfiguration(),
).apply {
// Qualify with `this` so it targets the web view's property, not the local val above.
this.navigationDelegate = navigationDelegate
setOverrideUserInterfaceStyle(darkTheme.toInterfaceStyle())
NSURL.URLWithString(url)?.let { nsUrl ->
loadRequest(NSURLRequest(uRL = nsUrl))
}
}
},
// Re-apply on recomposition so a light/dark switch takes effect without reloading the page.
update = { webView ->
webView.setOverrideUserInterfaceStyle(darkTheme.toInterfaceStyle())
},
)
}
/**
* Forwards WKWebView load lifecycle events to [onLoadingStateChange] so the UI can show a loader
* while a page is loading and hide it once loading finishes.
*
* When a navigation fails, it swaps in [offlineHtml] (loaded against [baseUrl] so links resolve) so
* the page still shows content offline. [showingOffline] guards against re-entering the fallback.
*/
private class WebViewNavigationDelegate :
NSObject(),
WKNavigationDelegateProtocol {
var onLoadingStateChange: (Boolean) -> Unit = {}
var offlineHtml: String? = null
var baseUrl: String = ""
private var showingOffline = false
// The four delegate callbacks below collapse to two Kotlin signatures — (WKWebView, WKNavigation?)
// and (WKWebView, WKNavigation?, NSError) — so every colliding override is marked
// @ObjCSignatureOverride.
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
didStartProvisionalNavigation: WKNavigation?,
) {
onLoadingStateChange(true)
}
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
didFinishNavigation: WKNavigation?,
) {
onLoadingStateChange(false)
}
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
didFailNavigation: WKNavigation?,
withError: NSError,
) {
handleFailure(webView)
}
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
didFailProvisionalNavigation: WKNavigation?,
withError: NSError,
) {
handleFailure(webView)
}
private fun handleFailure(webView: WKWebView) {
val html = offlineHtml
if (html != null && !showingOffline) {
showingOffline = true
webView.loadHTMLString(string = html, baseURL = NSURL.URLWithString(baseUrl))
} else {
onLoadingStateChange(false)
}
}
}
private fun Boolean.toInterfaceStyle(): UIUserInterfaceStyle = if (this) UIUserInterfaceStyleDark else UIUserInterfaceStyleLight
@@ -0,0 +1,18 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -0,0 +1,18 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// Desktop has no bundled web view; nothing loads, so report done and offer the system browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -0,0 +1,18 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}