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

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.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,
)
}

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.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<EGroup> {
val bytes = readBytes("files/enumbers.json")
println("Loading")
val eNumbers = Json.decodeFromString<List<EGroup>>(bytes.decodeToString())
println("Loaded")
return eNumbers
}
class ENumberList(private val eNumbers: List<EGroup>) : Screen {
@OptIn(ExperimentalFoundationApi::class)
@Composable
override fun Content() {
val eNumbers = remember {
mutableStateListOf<EGroup>()
}
println("Starting")
LaunchedEffect(Unit) {
eNumbers.addAll(getENumbers())
}
LazyColumn(contentPadding = PaddingValues(6.dp)) {
eNumbers.forEach { eGroup ->
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"
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" }