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,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,
)
@@ -9,7 +9,6 @@ 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
@@ -29,7 +28,7 @@ internal fun WebViewLoader(modifier: Modifier = Modifier) {
@Preview
@Composable
private fun WebViewLoaderPreview() {
ChomBevTheme {
MaterialTheme {
WebViewLoader(modifier = Modifier.fillMaxSize())
}
}
@@ -12,7 +12,7 @@ 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.model.WebPage
import bzh.ajaury.chombev.web.ui.viewmodel.WebUiState
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import org.koin.compose.viewmodel.koinViewModel
@@ -27,7 +27,7 @@ fun WebViewScreen(
page: WebPage,
contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(),
viewModel: WebViewModel = koinViewModel(key = page.name) { parametersOf(page) },
viewModel: WebViewModel = koinViewModel(key = page.url) { parametersOf(page) },
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -17,7 +17,6 @@ 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
/**
@@ -52,7 +51,7 @@ internal fun WebViewUnavailable(
@Preview
@Composable
private fun WebViewUnavailablePreview() {
ChomBevTheme {
MaterialTheme {
WebViewUnavailable(url = "https://ksk.bzh")
}
}
@@ -3,8 +3,8 @@ 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 bzh.ajaury.chombev.web.model.WebPage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -6,6 +6,7 @@ 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
@@ -41,7 +42,8 @@ actual fun PlatformWebView(
frame = CGRectZero.readValue(),
configuration = WKWebViewConfiguration(),
).apply {
navigationDelegate = navigationDelegate
// 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))
@@ -71,6 +73,10 @@ private class WebViewNavigationDelegate :
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?,
@@ -78,7 +84,6 @@ private class WebViewNavigationDelegate :
onLoadingStateChange(true)
}
// Shares the (WKWebView, WKNavigation?) Kotlin signature with didStartProvisionalNavigation above.
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
@@ -87,6 +92,7 @@ private class WebViewNavigationDelegate :
onLoadingStateChange(false)
}
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
didFailNavigation: WKNavigation?,
@@ -95,7 +101,6 @@ private class WebViewNavigationDelegate :
handleFailure(webView)
}
// Shares the (WKWebView, WKNavigation?, NSError) Kotlin signature with didFailNavigation above.
@ObjCSignatureOverride
override fun webView(
webView: WKWebView,
+3
View File
@@ -50,3 +50,6 @@ include(":data:preferences")
include(":data:records")
include(":data:resources")
include(":data:subtitle")
// Feature modules
include(":feature:webview")
+3 -1
View File
@@ -30,7 +30,6 @@ kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.webkit)
}
commonMain.dependencies {
// Core modules
@@ -46,6 +45,9 @@ kotlin {
implementation(projects.data.resources)
implementation(projects.data.subtitle)
// Feature modules
implementation(projects.feature.webview)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
@@ -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",
),
)
}
@@ -2,6 +2,7 @@ package bzh.ajaury.chombev.di
import bzh.ajaury.chombev.appModule
import bzh.ajaury.chombev.player.di.audioPlayerModule
import bzh.ajaury.chombev.web.model.WebPage
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.test.verify.verify
import kotlin.test.Test
@@ -18,6 +19,10 @@ class KoinModulesTest {
@OptIn(KoinExperimentalAPI::class)
@Test
fun koinConfigurationIsValid() {
appModule.verify()
// WebPage is supplied at call time via parametersOf (WebViewModel), so declare it as a known
// extra type rather than a resolvable definition.
appModule.verify(
extraTypes = listOf(WebPage::class),
)
}
}