feat(app): add ScreenModel for list and search bar

This commit is contained in:
Anthony 2024-06-11 13:48:10 +02:00
parent f21c70746a
commit 351223826b
11 changed files with 278 additions and 123 deletions

View File

@ -34,6 +34,7 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(compose.preview) implementation(compose.preview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(compose.runtime) implementation(compose.runtime)
@ -43,8 +44,13 @@ kotlin {
implementation(compose.ui) implementation(compose.ui)
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview) implementation(compose.components.uiToolingPreview)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.voyager.navigator) implementation(libs.voyager.navigator)
implementation(libs.voyager.tab)
implementation(libs.voyager.screenmodel)
} }
} }
} }
@ -85,4 +91,7 @@ android {
debugImplementation(compose.uiTooling) debugImplementation(compose.uiTooling)
} }
} }
dependencies {
implementation(libs.androidx.material3.android)
}

View File

@ -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()

View File

@ -1,78 +1,13 @@
import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme
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.runtime.Composable 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 cafe.adriel.voyager.navigator.Navigator
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
import tab.ENumberList import tab.list.ListMain
@Composable @Composable
@Preview @Preview
fun App() { fun App() {
MaterialTheme { 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,
)
}

View File

@ -1,7 +0,0 @@
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View File

@ -1,5 +0,0 @@
interface Platform {
val name: String
}
expect fun getPlatform(): Platform

View File

@ -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<Boolean>
) {
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,
)
}

View File

@ -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 )
}
}
}

View File

@ -1,4 +1,4 @@
package tab package tab.list
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background 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.material.icons.outlined.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import data.EGroup import data.EGroup
import data.ENumber import data.ENumber
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.ExperimentalResourceApi
import veganenumbers.composeapp.generated.resources.Res.readBytes
class ENumberList : Screen { class ENumberList(private val eNumbers: List<EGroup>) : 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<EGroup> {
val bytes = readBytes("files/enumbers.json")
println("Loading")
val eNumbers = Json.decodeFromString<List<EGroup>>(bytes.decodeToString())
println("Loaded")
return eNumbers
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
override fun Content() { override fun Content() {
val eNumbers = remember {
mutableStateListOf<EGroup>()
}
println("Starting")
LaunchedEffect(Unit) {
eNumbers.addAll(getENumbers())
}
LazyColumn(contentPadding = PaddingValues(6.dp)) { LazyColumn(contentPadding = PaddingValues(6.dp)) {
eNumbers.forEach { eGroup -> eNumbers.forEach { eGroup ->
stickyHeader { stickyHeader {

View File

@ -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<Boolean>) {
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)
)
}
}
}
}
}

View File

@ -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<ListScreenModel.State>(State.Loading) {
sealed class State {
object Loading : State()
data class Result(val eNumbers: List<EGroup>) : 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<List<EGroup>>(bytes.decodeToString())
mutableState.value = State.Result(eNumbers = eNumbers)
}
}
}
}

View File

@ -13,15 +13,19 @@ androidx-material3 = "1.6.11"
androidx-test-junit = "1.1.5" androidx-test-junit = "1.1.5"
compose-plugin = "1.6.10" compose-plugin = "1.6.10"
junit = "4.13.2" junit = "4.13.2"
koin-bom = "3.5.6"
kotlin = "2.0.0" kotlin = "2.0.0"
kotlinx = "1.7.0" kotlinx-serialization = "1.7.0"
kotlinx-coroutines = "1.9.0-RC"
material3Android = "1.2.1" material3Android = "1.2.1"
voyager = "1.1.0-beta02" voyager = "1.1.0-beta02"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } 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" } 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-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } 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
voyager-navigator = { group = "cafe.adriel.voyager", name = "voyager-navigator", version.ref = "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] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }