new: integrate pages for deepen and about
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".ChomBevApp"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<!-- Breton (br) -->
|
||||
<string name="app_name">Chom bev</string>
|
||||
|
||||
<string name="nav_records">Dastumoù</string>
|
||||
<string name="nav_deepen">Donaat</string>
|
||||
<string name="nav_about">Diwar-benn</string>
|
||||
|
||||
<string name="web_unavailable_message">N\'haller ket diskouez ar bajenn-mañ amañ.</string>
|
||||
<string name="web_open_in_browser">Digeriñ er merdeer</string>
|
||||
|
||||
<string name="subtitle_preferences_title">Istitloù</string>
|
||||
<string name="subtitle_preferences_action">Gwellvezioù istitloù</string>
|
||||
<string name="subtitle_transcription_label">Treuzskrivañ (BZH)</string>
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<!-- Default locale: French -->
|
||||
<string name="app_name">Chom bev</string>
|
||||
|
||||
<string name="nav_records">Enregistrements</string>
|
||||
<string name="nav_deepen">Approfondir</string>
|
||||
<string name="nav_about">À propos</string>
|
||||
|
||||
<string name="web_unavailable_message">Impossible d\'afficher cette page ici.</string>
|
||||
<string name="web_open_in_browser">Ouvrir dans le navigateur</string>
|
||||
|
||||
<string name="subtitle_preferences_title">Sous-titres</string>
|
||||
<string name="subtitle_preferences_action">Préférences de sous-titrage</string>
|
||||
<string name="subtitle_transcription_label">Transcription (BZH)</string>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -30,6 +30,7 @@ kotlin {
|
||||
sourceSets {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.webkit)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
// Core modules
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -12,4 +12,9 @@ sealed interface Route : NavKey {
|
||||
data class Player(
|
||||
val recordId: Int,
|
||||
) : Route
|
||||
|
||||
@Serializable
|
||||
data class Web(
|
||||
val page: WebPage,
|
||||
) : Route
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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<Record>,
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user