Skip to content

Commit 1e23c09

Browse files
committed
ui: allow item removal
1 parent 3cc9ca3 commit 1e23c09

10 files changed

Lines changed: 118 additions & 14 deletions

File tree

features/library/data/src/main/java/org/mrlem/composesample/features/library/data/datasources/local/BookmarkDataSource.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ interface BookmarkDataSource {
1919

2020
@Query("SELECT * FROM bookmark WHERE id = :id")
2121
suspend fun get(id: Long): BookmarkEntity
22+
23+
@Query("DELETE FROM bookmark WHERE id = :id")
24+
suspend fun delete(id: Long)
25+
2226
}

features/library/data/src/main/java/org/mrlem/composesample/features/library/data/repositories/DefaultBookmarkRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ internal class DefaultBookmarkRepository @Inject constructor(
4040
bookmarkMapper
4141
.toDomain(bookmarkDataSource.get(id))
4242

43+
override suspend fun delete(id: Long) {
44+
bookmarkDataSource.delete(id)
45+
}
46+
4347
override suspend fun getRandom(): String =
4448
wikipediaMapper
4549
.toRandomName(wikipediaDataSource.findRandom())

features/library/domain/src/main/java/org/mrlem/composesample/features/library/domain/repositories/BookmarkRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface BookmarkRepository {
1010

1111
suspend fun add(bookmark: Bookmark): Long
1212
suspend fun get(id: Long): Bookmark
13+
suspend fun delete(id: Long)
1314
suspend fun getRandom(): String
1415
suspend fun import(name: String): Bookmark
1516
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.mrlem.composesample.features.library.domain.usecase
2+
3+
import org.mrlem.composesample.features.library.domain.repositories.BookmarkRepository
4+
import javax.inject.Inject
5+
6+
class RemoveBookmarkUseCase @Inject constructor(
7+
private val repository: BookmarkRepository,
8+
) {
9+
10+
suspend operator fun invoke(id: Long) = repository.delete(id)
11+
12+
}

features/library/ui/src/main/java/org/mrlem/composesample/features/library/ui/list/ListItem.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import org.mrlem.composesample.theme.Theme
1313
internal fun <Action> ListItem(
1414
viewState: ListItemViewState<Action>,
1515
onAction: (Action) -> Unit,
16+
modifier: Modifier = Modifier,
1617
) {
1718
Column(
18-
modifier = Modifier
19+
modifier = modifier
1920
.then(
2021
viewState.onClickAction
2122
?.let { Modifier.clickable { onAction(it) } }

features/library/ui/src/main/java/org/mrlem/composesample/features/library/ui/list/ListScreen.kt

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,27 @@ import androidx.compose.foundation.layout.fillMaxSize
1010
import androidx.compose.foundation.layout.fillMaxWidth
1111
import androidx.compose.foundation.layout.height
1212
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.wrapContentSize
1314
import androidx.compose.foundation.lazy.LazyColumn
1415
import androidx.compose.foundation.lazy.items
1516
import androidx.compose.foundation.lazy.rememberLazyListState
1617
import androidx.compose.foundation.shape.CircleShape
1718
import androidx.compose.material.icons.Icons
1819
import androidx.compose.material.icons.filled.Add
20+
import androidx.compose.material.icons.filled.Delete
21+
import androidx.compose.material.icons.filled.Search
22+
import androidx.compose.material3.ExperimentalMaterial3Api
1923
import androidx.compose.material3.FloatingActionButton
2024
import androidx.compose.material3.Icon
2125
import androidx.compose.material3.MaterialTheme
2226
import androidx.compose.material3.SnackbarHostState
2327
import androidx.compose.material3.Surface
28+
import androidx.compose.material3.SwipeToDismissBox
29+
import androidx.compose.material3.SwipeToDismissBoxValue
2430
import androidx.compose.material3.Text
2531
import androidx.compose.material3.TextField
2632
import androidx.compose.material3.TextFieldDefaults
33+
import androidx.compose.material3.rememberSwipeToDismissBoxState
2734
import androidx.compose.runtime.Composable
2835
import androidx.compose.runtime.LaunchedEffect
2936
import androidx.compose.runtime.collectAsState
@@ -36,9 +43,11 @@ import androidx.compose.ui.Alignment
3643
import androidx.compose.ui.Modifier
3744
import androidx.compose.ui.graphics.Brush
3845
import androidx.compose.ui.graphics.Color
46+
import androidx.compose.ui.res.stringResource
3947
import androidx.compose.ui.text.input.TextFieldValue
4048
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
4149
import org.mrlem.android.core.feature.ui.UiModePreviews
50+
import org.mrlem.composesample.features.library.ui.R
4251
import org.mrlem.composesample.theme.Theme
4352

4453
@Composable
@@ -48,6 +57,7 @@ internal fun ListScreen(
4857
onItemSelect: (id: Long) -> Unit,
4958
) {
5059
val state by viewModel.state.collectAsState()
60+
val errorMessage = stringResource(R.string.library_error)
5161

5262
LaunchedEffect(Unit) {
5363
viewModel.effects
@@ -57,7 +67,7 @@ internal fun ListScreen(
5767
onItemSelect(effect.id)
5868

5969
is ListViewEffect.ShowError ->
60-
snackbarHostState.showSnackbar("Failed to retrieve data")
70+
snackbarHostState.showSnackbar(errorMessage)
6171
}
6272
}
6373
}
@@ -76,8 +86,11 @@ internal fun ListScreen(
7686
private fun ListScreen(
7787
state: ListViewState,
7888
onAction: (ListViewAction) -> Unit,
89+
modifier: Modifier = Modifier,
7990
) {
80-
Column {
91+
Column(
92+
modifier = modifier,
93+
) {
8194
var fieldValue by remember {
8295
mutableStateOf(TextFieldValue(state.filter))
8396
}
@@ -96,7 +109,12 @@ private fun ListScreen(
96109
disabledIndicatorColor = Color.Transparent,
97110
errorIndicatorColor = Color.Transparent,
98111
),
99-
placeholder = { Text("Filter articles") },
112+
placeholder = {
113+
Text(
114+
text = stringResource(id = R.string.library_search_action),
115+
)
116+
},
117+
trailingIcon = { Icon(imageVector = Icons.Default.Search, contentDescription = null) },
100118
modifier = Modifier
101119
.fillMaxWidth()
102120
.padding(
@@ -115,6 +133,7 @@ private fun ListScreen(
115133
}
116134
}
117135

136+
@OptIn(ExperimentalMaterial3Api::class)
118137
@Composable
119138
private fun List(
120139
state: ListViewState,
@@ -139,11 +158,51 @@ private fun List(
139158
modifier = Modifier
140159
.fillMaxSize(),
141160
) {
142-
items(state.items) {
143-
ListItem(
144-
viewState = it,
145-
onAction = onAction,
161+
items(
162+
items = state.items,
163+
key = { item -> (item.onClickAction as ListViewAction.ItemClick).itemId },
164+
) { item ->
165+
val dismissState = rememberSwipeToDismissBoxState(
166+
confirmValueChange = { dismissValue ->
167+
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
168+
val action = item.onClickAction as ListViewAction.ItemClick
169+
onAction(ListViewAction.ItemDismiss(action.itemId))
170+
true
171+
} else {
172+
false
173+
}
174+
},
146175
)
176+
177+
SwipeToDismissBox(
178+
state = dismissState,
179+
enableDismissFromStartToEnd = false,
180+
backgroundContent = {
181+
when (dismissState.dismissDirection) {
182+
SwipeToDismissBoxValue.EndToStart ->
183+
Icon(
184+
imageVector = Icons.Default.Delete,
185+
contentDescription = stringResource(R.string.library_remove_action),
186+
tint = MaterialTheme.colorScheme.onErrorContainer,
187+
modifier = Modifier
188+
.fillMaxSize()
189+
.background(MaterialTheme.colorScheme.errorContainer)
190+
.wrapContentSize(Alignment.CenterEnd)
191+
.padding(Theme.size.small),
192+
)
193+
194+
else -> {}
195+
}
196+
},
197+
) {
198+
ListItem(
199+
viewState = item,
200+
onAction = onAction,
201+
modifier = Modifier
202+
.fillMaxSize()
203+
.background(MaterialTheme.colorScheme.background),
204+
)
205+
}
147206
}
148207
}
149208

@@ -176,7 +235,7 @@ private fun List(
176235
.padding(Theme.size.medium)
177236
.align(Alignment.BottomEnd),
178237
) {
179-
Icon(imageVector = Icons.Filled.Add, contentDescription = "Import random bookmark")
238+
Icon(imageVector = Icons.Filled.Add, contentDescription = stringResource(R.string.library_import_action))
180239
}
181240
}
182241
}
@@ -191,17 +250,20 @@ private fun Preview() {
191250
items = listOf(
192251
ListItemViewState(
193252
label = "Georges Brassens",
253+
onClickAction = ListViewAction.ItemClick(1L),
194254
),
195255
ListItemViewState(
196256
label = "Jacques Brel",
257+
onClickAction = ListViewAction.ItemClick(2L),
197258
),
198259
ListItemViewState(
199260
label = "Joe Dassin",
261+
onClickAction = ListViewAction.ItemClick(3L),
200262
),
201263
),
202264
),
203265
onAction = {},
204266
)
205267
}
206268
}
207-
}
269+
}

features/library/ui/src/main/java/org/mrlem/composesample/features/library/ui/list/ListViewAction.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ internal sealed interface ListViewAction {
66
val itemId: Long,
77
) : ListViewAction
88

9+
data class ItemDismiss(
10+
val itemId: Long,
11+
) : ListViewAction
12+
913
data object ImportRandomClick : ListViewAction
1014

1115
data class FilterChange(

features/library/ui/src/main/java/org/mrlem/composesample/features/library/ui/list/ListViewModel.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import kotlinx.coroutines.flow.combine
99
import kotlinx.coroutines.flow.launchIn
1010
import kotlinx.coroutines.flow.onEach
1111
import kotlinx.coroutines.flow.stateIn
12+
import kotlinx.coroutines.launch
1213
import org.mrlem.android.core.feature.ui.UnidirectionalViewModel
1314
import org.mrlem.composesample.features.library.domain.repositories.BookmarkRepository
1415
import org.mrlem.composesample.features.library.domain.usecase.ImportRandomBookmark
16+
import org.mrlem.composesample.features.library.domain.usecase.RemoveBookmarkUseCase
1517
import org.mrlem.composesample.features.library.nav.LibraryDestination
1618
import timber.log.Timber
1719
import java.io.IOException
@@ -23,6 +25,7 @@ internal class ListViewModel @Inject constructor(
2325
savedStateHandle: SavedStateHandle,
2426
repository: BookmarkRepository,
2527
importRandomBookmark: ImportRandomBookmark,
28+
private val removeBookmark: RemoveBookmarkUseCase,
2629
converter: ListViewStateConverter,
2730
) : UnidirectionalViewModel<ListViewState, ListViewAction, ListViewEffect>() {
2831

@@ -46,6 +49,17 @@ internal class ListViewModel @Inject constructor(
4649
trigger(ListViewEffect.GoToItem(action.itemId))
4750
}
4851

52+
is ListViewAction.ItemDismiss -> {
53+
viewModelScope.launch {
54+
try {
55+
removeBookmark(action.itemId)
56+
} catch (e: Exception) {
57+
Timber.e(e, "failed to remove bookmark")
58+
trigger(ListViewEffect.ShowError)
59+
}
60+
}
61+
}
62+
4963
is ListViewAction.ImportRandomClick -> {
5064
try {
5165
importRandomBookmark()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<resources>
2+
<string name="library_search_action">Search</string>
3+
<string name="library_remove_action">Remove</string>
4+
<string name="library_import_action">Import random bookmark</string>
5+
<string name="library_error">Failed to retrieve data</string>
6+
</resources>

readme.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,6 @@ Navigation is using dedicated modules for stronger feature isolation.
6464
* doc:
6565
- document appInit task
6666
- document features creation
67-
* sample:
68-
- add item removal
6967
* unit tests
70-
* room: database in the library module makes sense for this sample app, not for a real app. If
71-
you find a multi-module pattern for room, please call me (I'd say this is not possible, by design)
7268
* room: migration handling
7369
* di: investigate moving from hilt+autodagger to [metro](https://github.com/ZacSweers/metro) once it gets more mature

0 commit comments

Comments
 (0)