From 351223826beaa59223f9e0c1f3e6beeaa482488e Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Jun 2024 13:48:10 +0200 Subject: [PATCH] feat(app): add ScreenModel for list and search bar --- composeApp/build.gradle.kts | 9 ++ .../androidMain/kotlin/Platform.android.kt | 7 - composeApp/src/commonMain/kotlin/App.kt | 71 +--------- composeApp/src/commonMain/kotlin/Greeting.kt | 7 - composeApp/src/commonMain/kotlin/Platform.kt | 5 - .../src/commonMain/kotlin/component/TopBar.kt | 51 ++++++++ .../commonMain/kotlin/tab/LoadingScreen.kt | 42 ++++++ .../kotlin/tab/{ => list}/ENumberList.kt | 36 +---- .../commonMain/kotlin/tab/list/ListMain.kt | 123 ++++++++++++++++++ .../kotlin/tab/list/ListScreenModel.kt | 34 +++++ gradle/libs.versions.toml | 16 ++- 11 files changed, 278 insertions(+), 123 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/Platform.android.kt delete mode 100644 composeApp/src/commonMain/kotlin/Greeting.kt delete mode 100644 composeApp/src/commonMain/kotlin/Platform.kt create mode 100644 composeApp/src/commonMain/kotlin/component/TopBar.kt create mode 100644 composeApp/src/commonMain/kotlin/tab/LoadingScreen.kt rename composeApp/src/commonMain/kotlin/tab/{ => list}/ENumberList.kt (73%) create mode 100644 composeApp/src/commonMain/kotlin/tab/list/ListMain.kt create mode 100644 composeApp/src/commonMain/kotlin/tab/list/ListScreenModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a713b8a..858ce8c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) + implementation(libs.kotlinx.coroutines.android) } commonMain.dependencies { implementation(compose.runtime) @@ -43,8 +44,13 @@ kotlin { implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.voyager.navigator) + implementation(libs.voyager.tab) + implementation(libs.voyager.screenmodel) } } } @@ -85,4 +91,7 @@ android { debugImplementation(compose.uiTooling) } } +dependencies { + implementation(libs.androidx.material3.android) +} diff --git a/composeApp/src/androidMain/kotlin/Platform.android.kt b/composeApp/src/androidMain/kotlin/Platform.android.kt deleted file mode 100644 index 4f3ea05..0000000 --- a/composeApp/src/androidMain/kotlin/Platform.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 8e92a1f..d8e440b 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,78 +1,13 @@ -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.style.TextOverflow import cafe.adriel.voyager.navigator.Navigator import org.jetbrains.compose.ui.tooling.preview.Preview -import tab.ENumberList +import tab.list.ListMain @Composable @Preview fun App() { MaterialTheme { - AppScaffold() + Navigator(ListMain) } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppScaffold() { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - TopBar(scrollBehavior) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - ) { - Navigator(ENumberList()) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TopBar( - scrollBehavior: TopAppBarScrollBehavior -) { - - CenterAlignedTopAppBar( - title = { - Text( - text = "Vegan E Numbers", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - navigationIcon = { - IconButton( - onClick = {/* TODO Implement Navigation Drawer */} - ) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Navigation Menu", - ) - } - }, - actions = { - IconButton( - onClick = {/* TODO Implement Search Function */} - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = "Search for E Number", - ) - } - }, - scrollBehavior = scrollBehavior, - ) -} diff --git a/composeApp/src/commonMain/kotlin/Greeting.kt b/composeApp/src/commonMain/kotlin/Greeting.kt deleted file mode 100644 index 887d835..0000000 --- a/composeApp/src/commonMain/kotlin/Greeting.kt +++ /dev/null @@ -1,7 +0,0 @@ -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/Platform.kt b/composeApp/src/commonMain/kotlin/Platform.kt deleted file mode 100644 index 87ca3ff..0000000 --- a/composeApp/src/commonMain/kotlin/Platform.kt +++ /dev/null @@ -1,5 +0,0 @@ -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/component/TopBar.kt b/composeApp/src/commonMain/kotlin/component/TopBar.kt new file mode 100644 index 0000000..94c374f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/component/TopBar.kt @@ -0,0 +1,51 @@ +package component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.text.style.TextOverflow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBar( + scrollBehavior: TopAppBarScrollBehavior, + searchActive: MutableState +) { + + CenterAlignedTopAppBar( + title = { + Text( + text = "Vegan E Numbers", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = {/* TODO Implement Navigation Drawer */} + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Navigation Menu", + ) + } + }, + actions = { + IconButton( + onClick = { + println("Clicked on Search") + searchActive.value = true + } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search for E Number", + ) + } + }, + scrollBehavior = scrollBehavior, + ) +} diff --git a/composeApp/src/commonMain/kotlin/tab/LoadingScreen.kt b/composeApp/src/commonMain/kotlin/tab/LoadingScreen.kt new file mode 100644 index 0000000..14e354b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/tab/LoadingScreen.kt @@ -0,0 +1,42 @@ +package tab + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +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.unit.dp +import cafe.adriel.voyager.core.screen.Screen + +/** + * Loading screen for use when using a ScreenModel in a loading state. + * Includes a CircularProgressIndicator with text below: "Loading *loadingTitle*" + * + * @param loadingTitle `String` of the page that is being loaded. + */ +class LoadingScreen (private val loadingTitle: String) : Screen { + + @Composable + override fun Content() { + Column ( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.width(64.dp), + color = MaterialTheme.colorScheme.onSurface, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + + Spacer(Modifier.size(64.dp)) + + Text("Loading $loadingTitle", style = MaterialTheme.typography.displaySmall ) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/tab/ENumberList.kt b/composeApp/src/commonMain/kotlin/tab/list/ENumberList.kt similarity index 73% rename from composeApp/src/commonMain/kotlin/tab/ENumberList.kt rename to composeApp/src/commonMain/kotlin/tab/list/ENumberList.kt index 4bc93f5..0211376 100644 --- a/composeApp/src/commonMain/kotlin/tab/ENumberList.kt +++ b/composeApp/src/commonMain/kotlin/tab/list/ENumberList.kt @@ -1,4 +1,4 @@ -package tab +package tab.list import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -14,49 +14,17 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import data.EGroup import data.ENumber -import kotlinx.serialization.json.Json -import org.jetbrains.compose.resources.ExperimentalResourceApi -import veganenumbers.composeapp.generated.resources.Res.readBytes -class ENumberList : Screen { - - /** - * Reads JSON file containing all e numbers - * @return List of grouped E Numbers in `EGroup` type - */ - @OptIn(ExperimentalResourceApi::class) - private suspend fun getENumbers(): List { - val bytes = readBytes("files/enumbers.json") - - println("Loading") - - val eNumbers = Json.decodeFromString>(bytes.decodeToString()) - - println("Loaded") - - return eNumbers - } +class ENumberList(private val eNumbers: List) : Screen { @OptIn(ExperimentalFoundationApi::class) @Composable override fun Content() { - val eNumbers = remember { - mutableStateListOf() - } - println("Starting") - - LaunchedEffect(Unit) { - eNumbers.addAll(getENumbers()) - } - LazyColumn(contentPadding = PaddingValues(6.dp)) { eNumbers.forEach { eGroup -> stickyHeader { diff --git a/composeApp/src/commonMain/kotlin/tab/list/ListMain.kt b/composeApp/src/commonMain/kotlin/tab/list/ListMain.kt new file mode 100644 index 0000000..0485763 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/tab/list/ListMain.kt @@ -0,0 +1,123 @@ +package tab.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.rememberNavigatorScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import component.TopBar +import tab.LoadingScreen + +object ListMain : Tab { + override val options: TabOptions + @Composable + get() { + val title = "E Numbers List" + + return remember { + TabOptions( + index = 0u, + title = title + ) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val screenModel = navigator.rememberNavigatorScreenModel { ListScreenModel() } + val state by screenModel.state.collectAsState() + + val searchActive = rememberSaveable { mutableStateOf(false) } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + if (!searchActive.value) { + TopBar(scrollBehavior = scrollBehavior, searchActive = searchActive) + } else { + Search(searchActive = searchActive) + } + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + ) { + when (val s = state) { + is ListScreenModel.State.Loading -> LoadingScreen("E Numbers").Content() + is ListScreenModel.State.Result -> ENumberList(s.eNumbers).Content() + } + } + } + + LaunchedEffect(currentCompositeKeyHash) { + screenModel.getENumbers() + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun Search(searchActive: MutableState) { + var text by rememberSaveable { mutableStateOf("") } + + SearchBar( + modifier = Modifier + .semantics { traversalIndex = 0f }, + query = text, + onQueryChange = { text = it }, + onSearch = { searchActive.value = false }, + onActiveChange = { searchActive.value = it }, + active = searchActive.value, + leadingIcon = { + IconButton(onClick = { searchActive.value = false }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Exit Search") + } + }, + trailingIcon = { + IconButton(onClick = { text = "" }) { + Icon(imageVector = Icons.Filled.Clear, contentDescription = "Clear Search") + } + } + ) { + Column(Modifier.verticalScroll(rememberScrollState())) { + repeat(4) { idx -> + val resultText = "Suggestion $idx" + ListItem( + headlineContent = { Text(resultText) }, + supportingContent = { Text("Additional info") }, + leadingContent = { Icon(Icons.Filled.Star, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier + .clickable { + text = resultText + searchActive.value = false + } + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/tab/list/ListScreenModel.kt b/composeApp/src/commonMain/kotlin/tab/list/ListScreenModel.kt new file mode 100644 index 0000000..7af8fc7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/tab/list/ListScreenModel.kt @@ -0,0 +1,34 @@ +package tab.list + +import cafe.adriel.voyager.core.model.StateScreenModel +import data.EGroup +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.ExperimentalResourceApi +import veganenumbers.composeapp.generated.resources.Res.readBytes + +class ListScreenModel : StateScreenModel(State.Loading) { + sealed class State { + object Loading : State() + data class Result(val eNumbers: List) : State() + } + + /** + * Reads JSON file containing all e numbers + * @return List of grouped E Numbers in `EGroup` type + */ + @OptIn(ExperimentalResourceApi::class) + suspend fun getENumbers() { + coroutineScope { + launch { + mutableState.value = State.Loading + + val bytes = readBytes("files/enumbers.json") + val eNumbers = Json.decodeFromString>(bytes.decodeToString()) + + mutableState.value = State.Result(eNumbers = eNumbers) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 160480d..387af7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,15 +13,19 @@ androidx-material3 = "1.6.11" androidx-test-junit = "1.1.5" compose-plugin = "1.6.10" junit = "4.13.2" +koin-bom = "3.5.6" kotlin = "2.0.0" -kotlinx = "1.7.0" +kotlinx-serialization = "1.7.0" +kotlinx-coroutines = "1.9.0-RC" material3Android = "1.2.1" voyager = "1.1.0-beta02" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx"} +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization"} +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } @@ -33,8 +37,16 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } +# Koin +koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } +koin-core = { module = "io.insert-koin:koin-core" } +koin-compose = { module = "io.insert-koin:koin-compose" } + # Voyager voyager-navigator = { group = "cafe.adriel.voyager", name = "voyager-navigator", version.ref = "voyager" } +voyager-tab = { group = "cafe.adriel.voyager", name = "voyager-tab-navigator", version.ref = "voyager" } +voyager-screenmodel = { group = "cafe.adriel.voyager", name = "voyager-screenmodel", version.ref = "voyager" } +voyager-koin = { group = "cafe.adriel.voyager", name = "voyager-koin", version.ref = "voyager" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }