From a272431c37619b29e73d24dcd1ac578ac461e94a Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 6 Jul 2026 22:58:48 +0200 Subject: [PATCH] new: integrate pages for deepen and about --- androidApp/src/main/AndroidManifest.xml | 2 + .../composeResources/values-br/strings.xml | 7 ++ .../composeResources/values/strings.xml | 7 ++ gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 1 + .../web/ui/PlatformWebView.android.kt | 46 +++++++++ .../web/ui/WebClientDelegatingExtLinks.kt | 21 +++++ .../kotlin/fr/ajaury/gwenedeg/App.kt | 93 +++++++++++++++---- .../gwenedeg/navigation/MainBottomBar.kt | 63 +++++++++++++ .../ajaury/gwenedeg/navigation/MainTopBar.kt | 39 ++++++++ .../fr/ajaury/gwenedeg/navigation/Route.kt | 5 + .../fr/ajaury/gwenedeg/navigation/WebPage.kt | 15 +++ .../gwenedeg/records/ui/RecordsScreen.kt | 79 +++++----------- .../ajaury/gwenedeg/web/ui/PlatformWebView.kt | 16 ++++ .../ajaury/gwenedeg/web/ui/WebViewScreen.kt | 28 ++++++ .../gwenedeg/web/ui/WebViewUnavailable.kt | 58 ++++++++++++ .../gwenedeg/web/ui/PlatformWebView.ios.kt | 44 +++++++++ .../gwenedeg/web/ui/PlatformWebView.js.kt | 14 +++ .../gwenedeg/web/ui/PlatformWebView.jvm.kt | 14 +++ .../gwenedeg/web/ui/PlatformWebView.wasmJs.kt | 14 +++ 20 files changed, 493 insertions(+), 75 deletions(-) create mode 100644 shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.android.kt create mode 100644 shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebClientDelegatingExtLinks.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainBottomBar.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainTopBar.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/WebPage.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewScreen.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewUnavailable.kt create mode 100644 shared/src/iosMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.ios.kt create mode 100644 shared/src/jsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.js.kt create mode 100644 shared/src/jvmMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.jvm.kt create mode 100644 shared/src/wasmJsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.wasmJs.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index f92faf8..4ef8eef 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + Chom bev + Dastumoù + Donaat + Diwar-benn + + N\'haller ket diskouez ar bajenn-mañ amañ. + Digeriñ er merdeer + Istitloù Gwellvezioù istitloù Treuzskrivañ (BZH) diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 9102b6f..0f49516 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -2,6 +2,13 @@ Chom bev + Enregistrements + Approfondir + À propos + + Impossible d\'afficher cette page ici. + Ouvrir dans le navigateur + Sous-titres Préférences de sous-titrage Transcription (BZH) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd4131a..ca59f14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ androidMinSdk = "24" androidTargetSdk = "37" androidxActivity = "1.13.0" androidxLifecycle = "2.11.0-rc01" +androidxWebkit = "1.16.0" media3 = "1.10.1" navigation3 = "1.1.1" composeMultiplatform = "1.11.1" @@ -30,6 +31,7 @@ composeCompiler-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidxWebkit" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 3fb3786..0a8390e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -30,6 +30,7 @@ kotlin { sourceSets { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.webkit) } commonMain.dependencies { // Core modules diff --git a/shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.android.kt b/shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.android.kt new file mode 100644 index 0000000..cf90fcb --- /dev/null +++ b/shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.android.kt @@ -0,0 +1,46 @@ +package fr.ajaury.gwenedeg.web.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +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, + darkTheme: Boolean, + modifier: Modifier, +) { + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + // Keep navigation inside the embedded view instead of launching an external browser. + webViewClient = WebClientDelegatingExtLinks() + 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) + } +} diff --git a/shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebClientDelegatingExtLinks.kt b/shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebClientDelegatingExtLinks.kt new file mode 100644 index 0000000..6256f40 --- /dev/null +++ b/shared/src/androidMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebClientDelegatingExtLinks.kt @@ -0,0 +1,21 @@ +package fr.ajaury.gwenedeg.web.ui + +import android.content.Intent +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +class WebClientDelegatingExtLinks : WebViewClient() { + 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 + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt index d6dfc66..c1834c6 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt @@ -1,5 +1,8 @@ package fr.ajaury.gwenedeg +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.saveable.rememberSerializable @@ -10,11 +13,15 @@ import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSe import fr.ajaury.gwenedeg.core.coroutines.di.coroutinesModule import fr.ajaury.gwenedeg.core.logging.di.loggingModule import fr.ajaury.gwenedeg.di.sharedModule +import fr.ajaury.gwenedeg.navigation.MainBottomBar +import fr.ajaury.gwenedeg.navigation.MainTopBar import fr.ajaury.gwenedeg.navigation.Route +import fr.ajaury.gwenedeg.navigation.WebPage import fr.ajaury.gwenedeg.player.di.audioPlayerModule import fr.ajaury.gwenedeg.player.ui.PlayerScreen import fr.ajaury.gwenedeg.records.ui.RecordsScreen import fr.ajaury.gwenedeg.subtitle.di.subtitleModule +import fr.ajaury.gwenedeg.web.ui.WebViewScreen import fr.ajaury.gwenedeg.theme.ChomBevTheme import org.koin.compose.KoinApplication import org.koin.dsl.koinConfiguration @@ -44,29 +51,75 @@ fun App() { mutableStateListOf(Route.Records) } - ChomBevTheme { - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = { key -> - when (key) { - is Route.Records -> NavEntry(key) { - RecordsScreen( - onRecordClick = { record -> - backStack.add(Route.Player(recordId = record.id)) - }, - ) - } + // Selecting a main tab keeps Records as the root, so system Back always returns home. + fun selectMainTab(page: WebPage?) { + backStack.clear() + backStack.add(Route.Records) + if (page != null) { + backStack.add(Route.Web(page = page)) + } + } - is Route.Player -> NavEntry(key) { - PlayerScreen( - recordId = key.recordId, - onBackClicked = { backStack.removeLastOrNull() }, - ) - } + val darkTheme = isSystemInDarkTheme() + + // The current top destination drives the shared chrome: the top and bottom bars are shown + // only on the main screens (Records and the web pages), not on the Player detail screen. + val currentRoute = backStack.lastOrNull() + val selectedPage = (currentRoute as? Route.Web)?.page + val isMainScreen = currentRoute is Route.Records || currentRoute is Route.Web + + ChomBevTheme(darkTheme = darkTheme) { + Scaffold( + // Let each screen's bars/insets apply; keeps the Player's own scaffold from double-padding. + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + if (isMainScreen) { + MainTopBar() } }, - ) + bottomBar = { + if (isMainScreen) { + MainBottomBar( + selectedPage = selectedPage, + onRecordsClick = { selectMainTab(null) }, + onDeepenClick = { selectMainTab(WebPage.DEEPEN) }, + onAboutClick = { selectMainTab(WebPage.ABOUT) }, + ) + } + }, + ) { innerPadding -> + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = { key -> + when (key) { + is Route.Records -> NavEntry(key) { + RecordsScreen( + contentPadding = innerPadding, + onRecordClick = { record -> + backStack.add(Route.Player(recordId = record.id)) + }, + ) + } + + is Route.Player -> NavEntry(key) { + PlayerScreen( + recordId = key.recordId, + onBackClicked = { backStack.removeLastOrNull() }, + ) + } + + is Route.Web -> NavEntry(key) { + WebViewScreen( + page = key.page, + contentPadding = innerPadding, + darkTheme = darkTheme, + ) + } + } + }, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainBottomBar.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainBottomBar.kt new file mode 100644 index 0000000..76af23d --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainBottomBar.kt @@ -0,0 +1,63 @@ +package fr.ajaury.gwenedeg.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import fr.ajaury.gwenedeg.resources.generated.resources.Res +import fr.ajaury.gwenedeg.resources.generated.resources.nav_about +import fr.ajaury.gwenedeg.resources.generated.resources.nav_deepen +import fr.ajaury.gwenedeg.resources.generated.resources.nav_records +import fr.ajaury.gwenedeg.theme.ChomBevTheme +import org.jetbrains.compose.resources.stringResource + +/** + * Main navigation bar shown on the app's home and web screens. "Records" is the in-app home + * destination; "Deepen" and "About" open web pages shown inside the app. + * + * @param selectedPage the currently shown web page, or `null` when the Records home is selected. + */ +@Composable +fun MainBottomBar( + modifier: Modifier = Modifier, + selectedPage: WebPage? = null, + onRecordsClick: () -> Unit = {}, + onDeepenClick: () -> Unit = {}, + onAboutClick: () -> Unit = {}, +) { + NavigationBar(modifier = modifier) { + NavigationBarItem( + selected = selectedPage == null, + onClick = onRecordsClick, + icon = { Icon(imageVector = Icons.Filled.Mic, contentDescription = null) }, + label = { Text(text = stringResource(Res.string.nav_records)) }, + ) + NavigationBarItem( + selected = selectedPage == WebPage.DEEPEN, + onClick = onDeepenClick, + icon = { Icon(imageVector = Icons.Filled.School, contentDescription = null) }, + label = { Text(text = stringResource(Res.string.nav_deepen)) }, + ) + NavigationBarItem( + selected = selectedPage == WebPage.ABOUT, + onClick = onAboutClick, + icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) }, + label = { Text(text = stringResource(Res.string.nav_about)) }, + ) + } +} + +@Preview +@Composable +private fun MainBottomBarPreview() { + ChomBevTheme { + MainBottomBar(selectedPage = null) + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainTopBar.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainTopBar.kt new file mode 100644 index 0000000..086d20c --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/MainTopBar.kt @@ -0,0 +1,39 @@ +package fr.ajaury.gwenedeg.navigation + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import fr.ajaury.gwenedeg.resources.generated.resources.Res +import fr.ajaury.gwenedeg.resources.generated.resources.app_icon +import fr.ajaury.gwenedeg.resources.generated.resources.app_name +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** Shared top bar (app icon + name) shown across the main screens: Records, Deepen and About. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainTopBar() { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image( + painter = painterResource(Res.drawable.app_icon), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Text(text = stringResource(Res.string.app_name)) + } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt index 1d9138f..cd5b052 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt @@ -12,4 +12,9 @@ sealed interface Route : NavKey { data class Player( val recordId: Int, ) : Route + + @Serializable + data class Web( + val page: WebPage, + ) : Route } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/WebPage.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/WebPage.kt new file mode 100644 index 0000000..f822782 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/WebPage.kt @@ -0,0 +1,15 @@ +package fr.ajaury.gwenedeg.navigation + +import kotlinx.serialization.Serializable + +/** + * Web destinations reachable from the main bottom navigation bar and shown inside the app. + * Placeholder URLs for now. + */ +@Serializable +enum class WebPage( + val url: String, +) { + DEEPEN("https://ksk.bzh/fr/chom-bev-approfondir/"), + ABOUT("https://ksk.bzh/contact"), +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordsScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordsScreen.kt index 6aa81c2..b147769 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordsScreen.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordsScreen.kt @@ -1,22 +1,14 @@ package fr.ajaury.gwenedeg.records.ui -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -24,74 +16,50 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import fr.ajaury.gwenedeg.core.model.Phrase import fr.ajaury.gwenedeg.records.model.Record import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel -import fr.ajaury.gwenedeg.resources.generated.resources.Res -import fr.ajaury.gwenedeg.resources.generated.resources.app_icon -import fr.ajaury.gwenedeg.resources.generated.resources.app_name import fr.ajaury.gwenedeg.theme.ChomBevTheme -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @Composable fun RecordsScreen( - viewModel: RecordsViewModel = koinViewModel(), + contentPadding: PaddingValues, onRecordClick: (Record) -> Unit, + viewModel: RecordsViewModel = koinViewModel(), ) { val records by viewModel.uiState.collectAsStateWithLifecycle() RecordsScreen( records = records, + contentPadding = contentPadding, onRecordClick = onRecordClick, ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RecordsScreen( records: List, + contentPadding: PaddingValues, onRecordClick: (Record) -> Unit, ) { - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Image( - painter = painterResource(Res.drawable.app_icon), - contentDescription = null, - modifier = Modifier.size(32.dp), - ) - Text(text = stringResource(Res.string.app_name)) - } - }, + LazyColumn( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + ) { + itemsIndexed( + items = records, + key = { _, item -> item.id }, + ) { index, item -> + RecordView( + record = item, + onClick = { onRecordClick(item) }, ) - }, - ) { innerPadding -> - LazyColumn( - modifier = - Modifier - .padding(innerPadding) - .fillMaxSize(), - ) { - itemsIndexed( - items = records, - key = { _, item -> item.id }, - ) { index, item -> - RecordView( - record = item, - onClick = { onRecordClick(item) }, - ) - if (index < records.lastIndex) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ) - } + if (index < records.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) } } } @@ -120,6 +88,7 @@ private fun RecordsScreenPreview() { subtitleResourcePath = "files/Chom_bev_02_st_bzh.lrc", ), ), + contentPadding = PaddingValues(), onRecordClick = {}, ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.kt new file mode 100644 index 0000000..0c333af --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.kt @@ -0,0 +1,16 @@ +package fr.ajaury.gwenedeg.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. + */ +@Composable +expect fun PlatformWebView( + url: String, + darkTheme: Boolean, + modifier: Modifier = Modifier, +) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewScreen.kt new file mode 100644 index 0000000..cd29deb --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewScreen.kt @@ -0,0 +1,28 @@ +package fr.ajaury.gwenedeg.web.ui + +import androidx.compose.foundation.isSystemInDarkTheme +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.ui.Modifier +import fr.ajaury.gwenedeg.navigation.WebPage + +/** + * Displays a [WebPage] inside the app: the embedded page, matching the app's [darkTheme]. The + * surrounding top and bottom bars are provided by the app-level scaffold. + */ +@Composable +fun WebViewScreen( + page: WebPage, + contentPadding: PaddingValues, + darkTheme: Boolean = isSystemInDarkTheme(), +) { + PlatformWebView( + url = page.url, + darkTheme = darkTheme, + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + ) +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewUnavailable.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewUnavailable.kt new file mode 100644 index 0000000..49757f0 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/web/ui/WebViewUnavailable.kt @@ -0,0 +1,58 @@ +package fr.ajaury.gwenedeg.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 fr.ajaury.gwenedeg.resources.generated.resources.Res +import fr.ajaury.gwenedeg.resources.generated.resources.web_open_in_browser +import fr.ajaury.gwenedeg.resources.generated.resources.web_unavailable_message +import fr.ajaury.gwenedeg.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") + } +} diff --git a/shared/src/iosMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.ios.kt b/shared/src/iosMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.ios.kt new file mode 100644 index 0000000..c241877 --- /dev/null +++ b/shared/src/iosMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.ios.kt @@ -0,0 +1,44 @@ +package fr.ajaury.gwenedeg.web.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.interop.UIKitView +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreGraphics.CGRectZero +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.WKWebView +import platform.WebKit.WKWebViewConfiguration + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun PlatformWebView( + url: String, + darkTheme: Boolean, + modifier: Modifier, +) { + UIKitView( + modifier = modifier, + factory = { + WKWebView( + frame = CGRectZero.readValue(), + configuration = WKWebViewConfiguration(), + ).apply { + 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()) + }, + ) +} + +private fun Boolean.toInterfaceStyle(): UIUserInterfaceStyle = + if (this) UIUserInterfaceStyleDark else UIUserInterfaceStyleLight diff --git a/shared/src/jsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.js.kt b/shared/src/jsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.js.kt new file mode 100644 index 0000000..e7f9d6c --- /dev/null +++ b/shared/src/jsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.js.kt @@ -0,0 +1,14 @@ +package fr.ajaury.gwenedeg.web.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun PlatformWebView( + url: String, + darkTheme: Boolean, + modifier: Modifier, +) { + // The Compose canvas can't host an iframe cleanly; open the page in the browser instead. + WebViewUnavailable(url = url, modifier = modifier) +} diff --git a/shared/src/jvmMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.jvm.kt b/shared/src/jvmMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.jvm.kt new file mode 100644 index 0000000..5ce74e5 --- /dev/null +++ b/shared/src/jvmMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.jvm.kt @@ -0,0 +1,14 @@ +package fr.ajaury.gwenedeg.web.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun PlatformWebView( + url: String, + darkTheme: Boolean, + modifier: Modifier, +) { + // Desktop has no bundled web view; offer to open the page in the system browser instead. + WebViewUnavailable(url = url, modifier = modifier) +} diff --git a/shared/src/wasmJsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.wasmJs.kt b/shared/src/wasmJsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.wasmJs.kt new file mode 100644 index 0000000..e7f9d6c --- /dev/null +++ b/shared/src/wasmJsMain/kotlin/fr/ajaury/gwenedeg/web/ui/PlatformWebView.wasmJs.kt @@ -0,0 +1,14 @@ +package fr.ajaury.gwenedeg.web.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun PlatformWebView( + url: String, + darkTheme: Boolean, + modifier: Modifier, +) { + // The Compose canvas can't host an iframe cleanly; open the page in the browser instead. + WebViewUnavailable(url = url, modifier = modifier) +}