new: integrate pages for deepen and about

This commit is contained in:
2026-07-06 22:58:48 +02:00
parent a74e9396e6
commit a272431c37
20 changed files with 493 additions and 75 deletions
+1
View File
@@ -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)
}
}
@@ -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)
}