Skip to content

Commit b2b835f

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

9 files changed

Lines changed: 160 additions & 52 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ 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)
2225
}

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
}

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: 128 additions & 47 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,74 +133,134 @@ private fun ListScreen(
115133
}
116134
}
117135

136+
@OptIn(ExperimentalMaterial3Api::class)
118137
@Composable
119138
private fun List(
120139
state: ListViewState,
121140
modifier: Modifier = Modifier,
122141
onAction: (ListViewAction) -> Unit = {},
123142
) {
143+
val listState = rememberLazyListState()
144+
val showShadow by remember {
145+
derivedStateOf { listState.canScrollBackward }
146+
}
147+
124148
Box(
125149
modifier = modifier,
126150
) {
127-
val listState = rememberLazyListState()
128-
129-
val showShadow by remember {
130-
derivedStateOf { listState.canScrollBackward }
131-
}
132-
133-
Box(
134-
modifier = Modifier
135-
.fillMaxWidth(),
151+
LazyColumn(
152+
state = listState,
153+
modifier = Modifier.fillMaxSize(),
136154
) {
137-
LazyColumn(
138-
state = listState,
139-
modifier = Modifier
140-
.fillMaxSize(),
141-
) {
142-
items(state.items) {
155+
items(
156+
items = state.items,
157+
key = { item -> (item.onClickAction as ListViewAction.ItemClick).itemId },
158+
) { item ->
159+
val dismissState = rememberSwipeToDismissBoxState(
160+
confirmValueChange = { dismissValue ->
161+
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
162+
val action = item.onClickAction as ListViewAction.ItemClick
163+
onAction(ListViewAction.ItemDismiss(action.itemId))
164+
true
165+
} else {
166+
false
167+
}
168+
},
169+
)
170+
171+
SwipeToDismissBox(
172+
state = dismissState,
173+
enableDismissFromStartToEnd = false,
174+
backgroundContent = {
175+
if (dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart) {
176+
RemoveIcon()
177+
}
178+
},
179+
) {
143180
ListItem(
144-
viewState = it,
181+
viewState = item,
145182
onAction = onAction,
183+
modifier = Modifier
184+
.fillMaxSize()
185+
.background(MaterialTheme.colorScheme.background),
146186
)
147187
}
148188
}
149-
150-
AnimatedVisibility(
151-
visible = showShadow,
152-
enter = fadeIn(),
153-
exit = fadeOut(),
154-
modifier = Modifier.align(Alignment.TopCenter),
155-
) {
156-
Box(
157-
modifier = Modifier
158-
.fillMaxWidth()
159-
.height(Theme.size.medium)
160-
.background(
161-
brush = Brush.verticalGradient(
162-
colors = listOf(
163-
MaterialTheme.colorScheme.background,
164-
Color.Transparent,
165-
),
166-
),
167-
),
168-
)
169-
}
170189
}
171190

172-
FloatingActionButton(
173-
onClick = { onAction(ListViewAction.ImportRandomClick) },
174-
shape = CircleShape,
191+
Shadow(
192+
visible = showShadow,
193+
modifier = Modifier
194+
.align(Alignment.TopCenter),
195+
)
196+
197+
ImportButton(
198+
onAction = onAction,
175199
modifier = Modifier
176200
.padding(Theme.size.medium)
177201
.align(Alignment.BottomEnd),
178-
) {
179-
Icon(imageVector = Icons.Filled.Add, contentDescription = "Import random bookmark")
180-
}
202+
)
203+
}
204+
}
205+
206+
@Composable
207+
private fun ImportButton(
208+
onAction: (ListViewAction) -> Unit,
209+
modifier: Modifier = Modifier,
210+
) {
211+
FloatingActionButton(
212+
onClick = { onAction(ListViewAction.ImportRandomClick) },
213+
shape = CircleShape,
214+
modifier = modifier,
215+
) {
216+
Icon(imageVector = Icons.Filled.Add, contentDescription = stringResource(R.string.library_import_action))
217+
}
218+
}
219+
220+
@Composable
221+
private fun RemoveIcon() {
222+
Icon(
223+
imageVector = Icons.Default.Delete,
224+
contentDescription = stringResource(R.string.library_remove_action),
225+
tint = MaterialTheme.colorScheme.onErrorContainer,
226+
modifier = Modifier
227+
.fillMaxSize()
228+
.background(MaterialTheme.colorScheme.errorContainer)
229+
.wrapContentSize(Alignment.CenterEnd)
230+
.padding(Theme.size.small),
231+
)
232+
}
233+
234+
@Composable
235+
private fun Shadow(
236+
visible: Boolean,
237+
modifier: Modifier = Modifier,
238+
) {
239+
AnimatedVisibility(
240+
visible = visible,
241+
enter = fadeIn(),
242+
exit = fadeOut(),
243+
modifier = modifier,
244+
) {
245+
Box(
246+
modifier = Modifier
247+
.fillMaxWidth()
248+
.height(Theme.size.medium)
249+
.background(
250+
brush = Brush.verticalGradient(
251+
colors = listOf(
252+
MaterialTheme.colorScheme.background,
253+
Color.Transparent,
254+
),
255+
),
256+
),
257+
)
181258
}
182259
}
183260

184261
@UiModePreviews
185262
@Composable
263+
@Suppress("MagicNumber")
186264
private fun Preview() {
187265
Theme {
188266
Surface {
@@ -191,17 +269,20 @@ private fun Preview() {
191269
items = listOf(
192270
ListItemViewState(
193271
label = "Georges Brassens",
272+
onClickAction = ListViewAction.ItemClick(1L),
194273
),
195274
ListItemViewState(
196275
label = "Jacques Brel",
276+
onClickAction = ListViewAction.ItemClick(2L),
197277
),
198278
ListItemViewState(
199279
label = "Joe Dassin",
280+
onClickAction = ListViewAction.ItemClick(3L),
200281
),
201282
),
202283
),
203284
onAction = {},
204285
)
205286
}
206287
}
207-
}
288+
}

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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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
@@ -46,6 +47,17 @@ internal class ListViewModel @Inject constructor(
4647
trigger(ListViewEffect.GoToItem(action.itemId))
4748
}
4849

50+
is ListViewAction.ItemDismiss -> {
51+
viewModelScope.launch {
52+
try {
53+
repository.delete(action.itemId)
54+
} catch (e: IOException) {
55+
Timber.e(e, "failed to remove bookmark")
56+
trigger(ListViewEffect.ShowError)
57+
}
58+
}
59+
}
60+
4961
is ListViewAction.ImportRandomClick -> {
5062
try {
5163
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)