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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 * 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 @Preview
@Composable @Composable
private fun WebViewLoaderPreview() { private fun WebViewLoaderPreview() {
ChomBevTheme { MaterialTheme {
WebViewLoader(modifier = Modifier.fillMaxSize()) WebViewLoader(modifier = Modifier.fillMaxSize())
} }
} }
@@ -12,7 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.WebUiState
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -27,7 +27,7 @@ fun WebViewScreen(
page: WebPage, page: WebPage,
contentPadding: PaddingValues, contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(), 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() 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.Res
import bzh.ajaury.chombev.resources.generated.resources.web_open_in_browser 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.resources.generated.resources.web_unavailable_message
import bzh.ajaury.chombev.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
/** /**
@@ -52,7 +51,7 @@ internal fun WebViewUnavailable(
@Preview @Preview
@Composable @Composable
private fun WebViewUnavailablePreview() { private fun WebViewUnavailablePreview() {
ChomBevTheme { MaterialTheme {
WebViewUnavailable(url = "https://ksk.bzh") WebViewUnavailable(url = "https://ksk.bzh")
} }
} }
@@ -3,8 +3,8 @@ package bzh.ajaury.chombev.web.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.core.logging.domain.Logger 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.resources.domain.ResourceReader
import bzh.ajaury.chombev.web.model.WebPage
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitView import androidx.compose.ui.interop.UIKitView
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride import kotlinx.cinterop.ObjCSignatureOverride
import kotlinx.cinterop.readValue
import platform.CoreGraphics.CGRectZero import platform.CoreGraphics.CGRectZero
import platform.Foundation.NSError import platform.Foundation.NSError
import platform.Foundation.NSURL import platform.Foundation.NSURL
@@ -41,7 +42,8 @@ actual fun PlatformWebView(
frame = CGRectZero.readValue(), frame = CGRectZero.readValue(),
configuration = WKWebViewConfiguration(), configuration = WKWebViewConfiguration(),
).apply { ).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()) setOverrideUserInterfaceStyle(darkTheme.toInterfaceStyle())
NSURL.URLWithString(url)?.let { nsUrl -> NSURL.URLWithString(url)?.let { nsUrl ->
loadRequest(NSURLRequest(uRL = nsUrl)) loadRequest(NSURLRequest(uRL = nsUrl))
@@ -71,6 +73,10 @@ private class WebViewNavigationDelegate :
private var showingOffline = false 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( override fun webView(
webView: WKWebView, webView: WKWebView,
didStartProvisionalNavigation: WKNavigation?, didStartProvisionalNavigation: WKNavigation?,
@@ -78,7 +84,6 @@ private class WebViewNavigationDelegate :
onLoadingStateChange(true) onLoadingStateChange(true)
} }
// Shares the (WKWebView, WKNavigation?) Kotlin signature with didStartProvisionalNavigation above.
@ObjCSignatureOverride @ObjCSignatureOverride
override fun webView( override fun webView(
webView: WKWebView, webView: WKWebView,
@@ -87,6 +92,7 @@ private class WebViewNavigationDelegate :
onLoadingStateChange(false) onLoadingStateChange(false)
} }
@ObjCSignatureOverride
override fun webView( override fun webView(
webView: WKWebView, webView: WKWebView,
didFailNavigation: WKNavigation?, didFailNavigation: WKNavigation?,
@@ -95,7 +101,6 @@ private class WebViewNavigationDelegate :
handleFailure(webView) handleFailure(webView)
} }
// Shares the (WKWebView, WKNavigation?, NSError) Kotlin signature with didFailNavigation above.
@ObjCSignatureOverride @ObjCSignatureOverride
override fun webView( override fun webView(
webView: WKWebView, webView: WKWebView,
+3
View File
@@ -50,3 +50,6 @@ include(":data:preferences")
include(":data:records") include(":data:records")
include(":data:resources") include(":data:resources")
include(":data:subtitle") include(":data:subtitle")
// Feature modules
include(":feature:webview")
+3 -1
View File
@@ -30,7 +30,6 @@ kotlin {
sourceSets { sourceSets {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.webkit)
} }
commonMain.dependencies { commonMain.dependencies {
// Core modules // Core modules
@@ -46,6 +45,9 @@ kotlin {
implementation(projects.data.resources) implementation(projects.data.resources)
implementation(projects.data.subtitle) implementation(projects.data.subtitle)
// Feature modules
implementation(projects.feature.webview)
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
implementation(libs.compose.material3) 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.MainBottomBar
import bzh.ajaury.chombev.navigation.MainTopBar import bzh.ajaury.chombev.navigation.MainTopBar
import bzh.ajaury.chombev.navigation.Route 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.di.audioPlayerModule
import bzh.ajaury.chombev.player.ui.PlayerScreen import bzh.ajaury.chombev.player.ui.PlayerScreen
import bzh.ajaury.chombev.records.ui.RecordsScreen import bzh.ajaury.chombev.records.ui.RecordsScreen
import bzh.ajaury.chombev.subtitle.di.subtitleModule import bzh.ajaury.chombev.subtitle.di.subtitleModule
import bzh.ajaury.chombev.theme.ChomBevTheme import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage
import bzh.ajaury.chombev.web.ui.WebViewScreen import bzh.ajaury.chombev.web.ui.WebViewScreen
import org.koin.compose.KoinApplication import org.koin.compose.KoinApplication
import org.koin.dsl.koinConfiguration import org.koin.dsl.koinConfiguration
@@ -81,8 +82,8 @@ fun App() {
MainBottomBar( MainBottomBar(
selectedPage = selectedPage, selectedPage = selectedPage,
onRecordsClick = { selectMainTab(null) }, onRecordsClick = { selectMainTab(null) },
onDeepenClick = { selectMainTab(WebPage.DEEPEN) }, onDeepenClick = { selectMainTab(WebPages.DEEPEN) },
onAboutClick = { selectMainTab(WebPage.ABOUT) }, 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.di.recordsModule
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
import bzh.ajaury.chombev.resources.di.resourcesModule 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.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
@@ -17,7 +17,7 @@ val sharedModule = module {
includes(preferencesModule) includes(preferencesModule)
includes(recordsModule) includes(recordsModule)
includes(resourcesModule) includes(resourcesModule)
includes(webviewModule)
viewModelOf(::RecordsViewModel) viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel) 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_deepen
import bzh.ajaury.chombev.resources.generated.resources.nav_records import bzh.ajaury.chombev.resources.generated.resources.nav_records
import bzh.ajaury.chombev.theme.ChomBevTheme import bzh.ajaury.chombev.theme.ChomBevTheme
import bzh.ajaury.chombev.web.model.WebPage
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
/** /**
@@ -40,13 +41,13 @@ fun MainBottomBar(
label = { Text(text = stringResource(Res.string.nav_records)) }, label = { Text(text = stringResource(Res.string.nav_records)) },
) )
NavigationBarItem( NavigationBarItem(
selected = selectedPage == WebPage.DEEPEN, selected = selectedPage == WebPages.DEEPEN,
onClick = onDeepenClick, onClick = onDeepenClick,
icon = { Icon(imageVector = Icons.Filled.School, contentDescription = null) }, icon = { Icon(imageVector = Icons.Filled.School, contentDescription = null) },
label = { Text(text = stringResource(Res.string.nav_deepen)) }, label = { Text(text = stringResource(Res.string.nav_deepen)) },
) )
NavigationBarItem( NavigationBarItem(
selected = selectedPage == WebPage.ABOUT, selected = selectedPage == WebPages.ABOUT,
onClick = onAboutClick, onClick = onAboutClick,
icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) }, icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
label = { Text(text = stringResource(Res.string.nav_about)) }, label = { Text(text = stringResource(Res.string.nav_about)) },
@@ -1,6 +1,7 @@
package bzh.ajaury.chombev.navigation package bzh.ajaury.chombev.navigation
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import bzh.ajaury.chombev.web.model.WebPage
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -1,24 +1,17 @@
package bzh.ajaury.chombev.navigation 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. * 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 object WebPages {
enum class WebPage( val DEEPEN = WebPage(
val url: String,
val offlineAsset: String,
) {
DEEPEN(
url = "https://ksk.bzh/fr/chom-bev-approfondir/", url = "https://ksk.bzh/fr/chom-bev-approfondir/",
offlineAsset = "files/offline_deepen.html", offlineAsset = "files/offline_deepen.html",
), )
ABOUT( val ABOUT = WebPage(
url = "https://ksk.bzh/fr/chom-bev-a-propos/", url = "https://ksk.bzh/fr/chom-bev-a-propos/",
offlineAsset = "files/offline_about.html", offlineAsset = "files/offline_about.html",
), )
} }
@@ -2,6 +2,7 @@ package bzh.ajaury.chombev.di
import bzh.ajaury.chombev.appModule import bzh.ajaury.chombev.appModule
import bzh.ajaury.chombev.player.di.audioPlayerModule import bzh.ajaury.chombev.player.di.audioPlayerModule
import bzh.ajaury.chombev.web.model.WebPage
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.test.verify.verify import org.koin.test.verify.verify
import kotlin.test.Test import kotlin.test.Test
@@ -18,6 +19,10 @@ class KoinModulesTest {
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@Test @Test
fun koinConfigurationIsValid() { 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),
)
} }
} }