Skip to content

Commit 46a0ba6

Browse files
Merge pull request #68 from SimonSchubert/compose-best-practices
Compose best practices
2 parents e2c59c8 + c1ab0dc commit 46a0ba6

File tree

12 files changed

+407
-58
lines changed

12 files changed

+407
-58
lines changed

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/MainActivity.kt

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.systemBarsPadding
2020
import androidx.compose.material.MaterialTheme
2121
import androidx.compose.material.Scaffold
2222
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.derivedStateOf
24+
import androidx.compose.runtime.getValue
2325
import androidx.compose.runtime.mutableStateOf
2426
import androidx.compose.runtime.remember
2527
import androidx.compose.ui.Modifier
@@ -109,9 +111,7 @@ fun LinuxApp() {
109111
)
110112
}
111113
val showSearch = remember { mutableStateOf(false) }
112-
val onNavigate: (String) -> Unit = {
113-
navController.navigate(it)
114-
}
114+
val onNavigate: (String) -> Unit = remember(navController) { { route -> navController.navigate(route) } }
115115

116116
Scaffold(
117117
topBar = {
@@ -220,12 +220,11 @@ fun LinuxApp() {
220220
}
221221
}
222222

223-
val isSearchVisible = remember(
224-
searchTextValue.value.text,
225-
navBackStackEntry.value?.destination?.route,
226-
) {
227-
searchTextValue.value.text.isNotEmpty() &&
228-
navBackStackEntry.value?.destination?.route?.startsWith("command?") == false
223+
val isSearchVisible by remember(navBackStackEntry, searchTextValue) {
224+
derivedStateOf {
225+
searchTextValue.value.text.isNotEmpty() &&
226+
navBackStackEntry.value?.destination?.route?.startsWith("command?") == false
227+
}
229228
}
230229
AnimatedVisibility(
231230
visible = isSearchVisible,
@@ -234,9 +233,7 @@ fun LinuxApp() {
234233
) {
235234
SearchScreen(
236235
searchText = searchTextValue.value.text,
237-
onNavigate = {
238-
navController.navigate(it)
239-
},
236+
onNavigate = remember(navController) { { route -> navController.navigate(route) } },
240237
)
241238
}
242239
}

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/CommandView.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,10 @@ fun CommandView(
107107
)
108108

109109
val context = LocalContext.current
110+
val shareAction = remember(context, command) { { shareCommand(context, command) } }
110111
IconButton(
111112
modifier = Modifier.align(Alignment.CenterVertically),
112-
onClick = {
113-
shareCommand(context, command)
114-
},
113+
onClick = shareAction,
115114
) {
116115
Icon(
117116
imageVector = Icons.Filled.Share,

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsScreen.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import androidx.compose.material.ExperimentalMaterialApi
1111
import androidx.compose.material.Icon
1212
import androidx.compose.material.ListItem
1313
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.collectAsState
15+
import androidx.compose.runtime.getValue
1416
import androidx.compose.runtime.remember
1517
import androidx.compose.ui.Modifier
1618
import androidx.compose.ui.res.painterResource
@@ -48,15 +50,17 @@ fun BasicGroupsScreen(
4850
),
4951
onNavigate: (String) -> Unit = {},
5052
) {
53+
val uiState by viewModel.uiState.collectAsState()
54+
5155
LazyColumn(Modifier.fillMaxSize()) {
5256
items(
53-
items = viewModel.basicGroups,
57+
items = uiState.basicGroups,
5458
key = { it.id },
5559
contentType = { "basic_group_item" },
5660
) { basicGroup ->
5761
BasicGroupColumn(
5862
basicGroup = basicGroup,
59-
isExpanded = !viewModel.isGroupCollapsed(basicGroup.id),
63+
isExpanded = !uiState.collapsedMap.getOrDefault(basicGroup.id, true),
6064
onToggleCollapse = { viewModel.toggleCollapse(basicGroup.id) },
6165
onNavigate = onNavigate,
6266
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups
2+
3+
import databases.BasicGroup
4+
import kotlinx.collections.immutable.ImmutableList
5+
import kotlinx.collections.immutable.ImmutableMap
6+
import kotlinx.collections.immutable.persistentListOf
7+
import kotlinx.collections.immutable.persistentMapOf
8+
9+
data class BasicGroupsUiState(
10+
val basicGroups: ImmutableList<BasicGroup> = persistentListOf(),
11+
val collapsedMap: ImmutableMap<Long, Boolean> = persistentMapOf(),
12+
)

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModel.kt

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups
22

3-
import androidx.compose.runtime.mutableStateMapOf
43
import androidx.lifecycle.ViewModel
54
import com.linuxcommandlibrary.shared.databaseHelper
6-
import databases.BasicGroup
7-
import kotlinx.collections.immutable.ImmutableList
85
import kotlinx.collections.immutable.toImmutableList
6+
import kotlinx.collections.immutable.toPersistentMap
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.asStateFlow
9+
import kotlinx.coroutines.flow.update
910

1011
/* Copyright 2022 Simon Schubert
1112
*
@@ -24,14 +25,21 @@ import kotlinx.collections.immutable.toImmutableList
2425

2526
class BasicGroupsViewModel(categoryId: Long) : ViewModel() {
2627

27-
private val collapsedMap = mutableStateMapOf<Long, Boolean>()
28+
private val _uiState = MutableStateFlow(BasicGroupsUiState())
29+
val uiState = _uiState.asStateFlow()
2830

29-
var basicGroups: ImmutableList<BasicGroup> =
30-
databaseHelper.getBasicGroupsByQuery(categoryId).toImmutableList()
31+
init {
32+
val groups = databaseHelper.getBasicGroupsByQuery(categoryId).toImmutableList()
33+
_uiState.value = BasicGroupsUiState(basicGroups = groups)
34+
}
3135

32-
fun isGroupCollapsed(id: Long): Boolean = collapsedMap[id] == true
36+
fun isGroupCollapsed(id: Long): Boolean = _uiState.value.collapsedMap.getOrDefault(id, true)
3337

3438
fun toggleCollapse(id: Long) {
35-
collapsedMap[id] = !collapsedMap.getOrDefault(id, false)
39+
_uiState.update { currentState ->
40+
val newMap = currentState.collapsedMap.toMutableMap()
41+
newMap[id] = !currentState.collapsedMap.getOrDefault(id, true)
42+
currentState.copy(collapsedMap = newMap.toPersistentMap())
43+
}
3644
}
3745
}

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailUiState.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail
22

33
import databases.CommandSection
4+
import kotlinx.collections.immutable.ImmutableList
5+
import kotlinx.collections.immutable.ImmutableMap
46

57
data class CommandDetailUiState(
6-
val sections: List<CommandSection>,
7-
val expandedSectionsMap: Map<Long, Boolean>,
8+
val sections: ImmutableList<CommandSection>,
9+
val expandedSectionsMap: ImmutableMap<Long, Boolean>,
810
val isBookmarked: Boolean = false,
911
val showBookmarkDialog: Boolean = false,
1012
) {

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListScreen.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import androidx.compose.material.ListItem
1515
import androidx.compose.runtime.Composable
1616
import androidx.compose.runtime.collectAsState
1717
import androidx.compose.runtime.getValue
18+
import androidx.compose.runtime.remember
1819
import androidx.compose.ui.Modifier
1920
import androidx.compose.ui.res.painterResource
2021
import androidx.compose.ui.res.stringResource
@@ -90,9 +91,11 @@ fun CommandListItem(
9091
pattern = searchText,
9192
)
9293
},
93-
modifier = Modifier.clickable {
94-
onNavigate("command?commandId=${command.id}&commandName=${command.name}")
95-
},
94+
modifier = Modifier.clickable(
95+
onClick = remember(command.id, command.name, onNavigate) {
96+
{ onNavigate("command?commandId=${command.id}&commandName=${command.name}") }
97+
},
98+
),
9699
)
97100
}
98101

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchScreen.kt

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,8 @@ fun SearchScreen(
2727
viewModel: SearchViewModel = koinViewModel(),
2828
onNavigate: (String) -> Unit,
2929
) {
30-
if (searchText.isEmpty()) {
31-
return
32-
}
33-
val commands by viewModel.filteredCommands.collectAsState()
34-
val basicGroups by viewModel.filteredBasicGroups.collectAsState()
30+
// Removed the early return for searchText.isEmpty() as ViewModel now handles it by emitting an empty state.
31+
val uiState by viewModel.uiState.collectAsState()
3532

3633
LaunchedEffect(searchText) {
3734
viewModel.search(searchText)
@@ -40,13 +37,14 @@ fun SearchScreen(
4037
val keyboardController = LocalSoftwareKeyboardController.current
4138
val lazyListState = rememberLazyListState()
4239

43-
val showEmptyMessage by remember {
40+
val showEmptyMessage by remember(uiState.filteredCommands, uiState.filteredBasicGroups) {
4441
derivedStateOf {
45-
commands.isEmpty() && basicGroups.isEmpty()
42+
uiState.filteredCommands.isEmpty() && uiState.filteredBasicGroups.isEmpty()
4643
}
4744
}
4845

49-
if (showEmptyMessage) {
46+
// Only show "404" if search text is not empty and results are empty
47+
if (searchText.isNotEmpty() && showEmptyMessage) {
5048
Box(
5149
modifier = Modifier
5250
.fillMaxSize()
@@ -62,21 +60,20 @@ fun SearchScreen(
6260
state = lazyListState,
6361
) {
6462
items(
65-
items = basicGroups,
63+
items = uiState.filteredBasicGroups,
6664
key = { "basicGroup_${it.id}" },
6765
contentType = { "search_basic_group_item" },
6866
) { basicGroup ->
6967
BasicGroupColumn(
7068
basicGroup = basicGroup,
7169
searchText = searchText,
7270
onNavigate = onNavigate,
73-
// Assuming isGroupCollapsed(id) == true means content is hidden
74-
isExpanded = !viewModel.isGroupCollapsed(basicGroup.id),
71+
isExpanded = !uiState.collapsedMap.getOrDefault(basicGroup.id, false),
7572
onToggleCollapse = { viewModel.toggleCollapse(basicGroup.id) },
7673
)
7774
}
7875
items(
79-
items = commands,
76+
items = uiState.filteredCommands,
8077
key = { "command_${it.id}" },
8178
contentType = { "search_command_item" },
8279
) { command ->
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search
2+
3+
import databases.BasicGroup
4+
import databases.Command
5+
import kotlinx.collections.immutable.ImmutableList
6+
import kotlinx.collections.immutable.ImmutableMap
7+
import kotlinx.collections.immutable.persistentListOf
8+
import kotlinx.collections.immutable.persistentMapOf
9+
10+
data class SearchUiState(
11+
val filteredCommands: ImmutableList<Command> = persistentListOf(),
12+
val filteredBasicGroups: ImmutableList<BasicGroup> = persistentListOf(),
13+
val collapsedMap: ImmutableMap<Long, Boolean> = persistentMapOf(),
14+
)
Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search
22

3-
import androidx.compose.runtime.mutableStateMapOf
43
import androidx.lifecycle.ViewModel
54
import androidx.lifecycle.viewModelScope
65
import com.linuxcommandlibrary.shared.databaseHelper
76
import com.linuxcommandlibrary.shared.sortedSearch
8-
import databases.BasicGroup
9-
import databases.Command
10-
import kotlinx.collections.immutable.ImmutableList
117
import kotlinx.collections.immutable.persistentListOf
128
import kotlinx.collections.immutable.toImmutableList
9+
import kotlinx.collections.immutable.toPersistentMap
1310
import kotlinx.coroutines.Dispatchers
1411
import kotlinx.coroutines.Job
1512
import kotlinx.coroutines.ensureActive
@@ -21,23 +18,31 @@ import kotlin.coroutines.cancellation.CancellationException
2118

2219
class SearchViewModel : ViewModel() {
2320

24-
private val collapsedMap = mutableStateMapOf<Long, Boolean>()
21+
private val _uiState = MutableStateFlow(SearchUiState())
22+
val uiState = _uiState.asStateFlow()
2523

26-
fun isGroupCollapsed(id: Long): Boolean = collapsedMap[id] == true
24+
fun isGroupCollapsed(id: Long): Boolean = _uiState.value.collapsedMap.getOrDefault(id, false)
2725

2826
fun toggleCollapse(id: Long) {
29-
collapsedMap[id] = !collapsedMap.getOrDefault(id, false)
27+
_uiState.update { currentState ->
28+
val newMap = currentState.collapsedMap.toMutableMap()
29+
newMap[id] = !currentState.collapsedMap.getOrDefault(id, false)
30+
currentState.copy(collapsedMap = newMap.toPersistentMap())
31+
}
3032
}
3133

32-
private val _filteredCommands = MutableStateFlow<ImmutableList<Command>>(persistentListOf())
33-
val filteredCommands = _filteredCommands.asStateFlow()
34-
35-
private val _filteredBasicGroups = MutableStateFlow<ImmutableList<BasicGroup>>(persistentListOf())
36-
val filteredBasicGroups = _filteredBasicGroups.asStateFlow()
37-
3834
private var searchJob: Job? = null
3935
fun search(searchText: String) {
4036
searchJob?.cancel()
37+
if (searchText.isBlank()) {
38+
_uiState.update {
39+
it.copy(
40+
filteredCommands = persistentListOf(),
41+
filteredBasicGroups = persistentListOf(),
42+
)
43+
}
44+
return
45+
}
4146
searchJob = viewModelScope.launch(Dispatchers.IO) {
4247
try {
4348
ensureActive()
@@ -47,9 +52,15 @@ class SearchViewModel : ViewModel() {
4752

4853
ensureActive()
4954

50-
_filteredCommands.update { commands.toImmutableList() }
51-
_filteredBasicGroups.update { basicGroups.toImmutableList() }
52-
} catch (ignore: CancellationException) { }
55+
_uiState.update { currentState ->
56+
currentState.copy(
57+
filteredCommands = commands.toImmutableList(),
58+
filteredBasicGroups = basicGroups.toImmutableList(),
59+
)
60+
}
61+
} catch (ignore: CancellationException) {
62+
// Preserve previous results on cancellation
63+
}
5364
}
5465
}
5566
}

0 commit comments

Comments
 (0)