Skip to content

Commit b1c1731

Browse files
committed
feat: Add pull-to-refresh and improve navigation in settings
- **Settings Categories**: - Added `Refresh` and `NavBackDetail` actions to `SettingsCategoriesViewModel`. - Implemented `navBackDetail` to clear the selected category and navigate back via the router. - Updated `SettingsCategoriesResult` to include a `loading` state. - Wrapped `SettingsMasterScreenBody` content in a `PullToRefreshBox`. - **Settings Detail**: - Added a `Refresh` action to `SettingsViewModel` to trigger switch updates. - Integrated `PullToRefreshBox` into `SettingsDetailScreen` for manual updates. - Optimized state collection using `derivedStateOf` for refresh and loading indicators. - **Adaptive UI & Navigation**: - Updated `AdaptiveSettingsScreen` to use the view model's `NavBackDetail` action for back navigation, ensuring state synchronization. - Replaced local coroutine-based navigation in the UI with view model actions. - **Testing**: - Enabled the navigation back test case in `AdaptiveInteractorTest`.
1 parent 30596e8 commit b1c1731

File tree

8 files changed

+94
-29
lines changed

8 files changed

+94
-29
lines changed

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,7 @@ class AdaptiveInteractorTest {
221221
val selected: SecurityResult = awaitItem()
222222
assertEquals(SettingsCategory.Theme, selected.selectedCategory)
223223

224-
//FIXME
225-
// settingsCategoriesViewModel.onAction(SettingsCategoriesAction.NavBackDetail)
224+
settingsCategoriesViewModel.onAction(SettingsCategoriesAction.NavBackDetail)
226225

227226
val cleared: SecurityResult = awaitItem()
228227
assertNull(cleared.selectedCategory)

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SecurityResult.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ data class SecurityResult(
1818

1919
sealed interface SettingsAction {
2020
data object NavBack : SettingsAction
21+
data object Refresh : SettingsAction
2122
data object ChangeTheme : SettingsAction
2223
data object ChangeLanguage : SettingsAction
2324
data class ChangeEncryption(val checked: Boolean) : SettingsAction

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsCategoriesResult.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ import com.softartdev.notedelight.model.SettingsCategory
44

55
data class SettingsCategoriesResult(
66
val selectedCategoryId: Long? = null,
7-
)
7+
val loading: Boolean = false,
8+
) {
9+
fun showLoading(): SettingsCategoriesResult = copy(loading = true)
10+
fun hideLoading(): SettingsCategoriesResult = copy(loading = false)
11+
}
812

913
sealed interface SettingsCategoriesAction {
1014
data class SelectCategory(val category: SettingsCategory) : SettingsCategoriesAction
15+
data object Refresh : SettingsCategoriesAction
16+
data object NavBackDetail : SettingsCategoriesAction
1117
data object NavBack : SettingsCategoriesAction
1218
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsCategoriesViewModel.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class SettingsCategoriesViewModel(
3434

3535
fun onAction(action: SettingsCategoriesAction) = when (action) {
3636
is SettingsCategoriesAction.SelectCategory -> selectCategory(action.category)
37+
is SettingsCategoriesAction.Refresh -> refresh()
38+
is SettingsCategoriesAction.NavBackDetail -> navBackDetail()
3739
is SettingsCategoriesAction.NavBack -> navBack()
3840
}
3941

@@ -42,6 +44,19 @@ class SettingsCategoriesViewModel(
4244
router.adaptiveNavigateToDetail(contentKey = category.id)
4345
}
4446

47+
private fun refresh() = viewModelScope.launch {
48+
mutableStateFlow.update(SettingsCategoriesResult::showLoading)
49+
mutableStateFlow.update { result ->
50+
result.copy(selectedCategoryId = adaptiveInteractor.selectedSettingsCategoryIdStateFlow.value)
51+
}
52+
mutableStateFlow.update(SettingsCategoriesResult::hideLoading)
53+
}
54+
55+
private fun navBackDetail() = viewModelScope.launch {
56+
adaptiveInteractor.selectedSettingsCategoryIdStateFlow.value = null
57+
router.adaptiveNavigateBack()
58+
}
59+
4560
private fun navBack() {
4661
if (!router.popBackStack()) router.navigate(route = AppNavGraph.Splash)
4762
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class SettingsViewModel(
5555

5656
fun onAction(action: SettingsAction) = when (action) {
5757
is SettingsAction.NavBack -> navBack()
58+
is SettingsAction.Refresh -> updateSwitches()
5859
is SettingsAction.ChangeTheme -> changeTheme()
5960
is SettingsAction.ChangeLanguage -> changeLanguage()
6061
is SettingsAction.ChangeEncryption -> changeEncryption(action.checked)

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/AdaptiveSettingsScreen.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
1313
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
1414
import androidx.compose.runtime.Composable
1515
import androidx.compose.runtime.DisposableEffect
16-
import androidx.compose.runtime.rememberCoroutineScope
1716
import androidx.compose.ui.Modifier
1817
import androidx.compose.ui.tooling.preview.Preview
1918
import com.softartdev.notedelight.di.PreviewKoin
@@ -26,7 +25,6 @@ import com.softartdev.notedelight.ui.VerticalPaneExpansionDragHandle
2625
import com.softartdev.notedelight.ui.settings.detail.SettingsDetailScreen
2726
import com.softartdev.notedelight.ui.settings.master.SettingsMasterScreen
2827
import com.softartdev.theme.material3.PreferableMaterialTheme
29-
import kotlinx.coroutines.launch
3028
import org.koin.compose.koinInject
3129
import org.koin.compose.viewmodel.koinViewModel
3230

@@ -36,7 +34,6 @@ fun AdaptiveSettingsScreen(
3634
categoriesViewModel: SettingsCategoriesViewModel = koinViewModel(),
3735
settingsViewModel: SettingsViewModel = koinViewModel(),
3836
) {
39-
val coroutineScope = rememberCoroutineScope()
4037
val navigator: ThreePaneScaffoldNavigator<Long> = rememberListDetailPaneScaffoldNavigator<Long>()
4138
val paneExpansionState: PaneExpansionState = rememberPaneExpansionState()
4239
DisposableEffect(key1 = router, key2 = navigator) {
@@ -53,13 +50,13 @@ fun AdaptiveSettingsScreen(
5350
detailPane = {
5451
SettingsDetailScreen(
5552
settingsViewModel = settingsViewModel,
56-
onBackClick = { coroutineScope.launch { navigator.navigateBack() } },
53+
onBackClick = { categoriesViewModel.onAction(SettingsCategoriesAction.NavBackDetail) },
5754
)
5855
},
5956
paneExpansionDragHandle = ThreePaneScaffoldScope::VerticalPaneExpansionDragHandle,
6057
paneExpansionState = paneExpansionState
6158
)
62-
BackHandler(enabled = navigator.canNavigateBack()) { coroutineScope.launch { navigator.navigateBack() } }
59+
BackHandler(enabled = navigator.canNavigateBack()) { categoriesViewModel.onAction(SettingsCategoriesAction.NavBackDetail) }
6360
BackHandler(enabled = !navigator.canNavigateBack()) { categoriesViewModel.onAction(SettingsCategoriesAction.NavBack) }
6461
}
6562

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/detail/SettingsDetailScreen.kt

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ import androidx.compose.material3.Scaffold
2929
import androidx.compose.material3.Switch
3030
import androidx.compose.material3.Text
3131
import androidx.compose.material3.TopAppBar
32+
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
33+
import androidx.compose.material3.pulltorefresh.PullToRefreshState
34+
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
3235
import androidx.compose.runtime.Composable
3336
import androidx.compose.runtime.LaunchedEffect
37+
import androidx.compose.runtime.State
3438
import androidx.compose.runtime.collectAsState
35-
import androidx.compose.runtime.getValue
39+
import androidx.compose.runtime.derivedStateOf
40+
import androidx.compose.runtime.remember
3641
import androidx.compose.ui.Modifier
3742
import androidx.compose.ui.graphics.vector.ImageVector
3843
import androidx.compose.ui.platform.LocalUriHandler
@@ -79,7 +84,11 @@ fun SettingsDetailScreen(
7984
LaunchedEffect(settingsViewModel) {
8085
settingsViewModel.launchCollectingSelectedCategoryId()
8186
}
82-
val result: SecurityResult by settingsViewModel.stateFlow.collectAsState()
87+
val resultState: State<SecurityResult> = settingsViewModel.stateFlow.collectAsState()
88+
val result: SecurityResult = resultState.value
89+
val refreshState: State<Boolean> = remember {
90+
derivedStateOf { resultState.value.loading }
91+
}
8392
LifecycleResumeEffect(key1 = settingsViewModel) {
8493
settingsViewModel.updateSwitches()
8594
onPauseOrDispose {}
@@ -90,6 +99,8 @@ fun SettingsDetailScreen(
9099
result = result,
91100
onBackClick = onBackClick,
92101
onAction = settingsViewModel::onAction,
102+
onRefresh = { settingsViewModel.onAction(SettingsAction.Refresh) },
103+
refreshState = refreshState,
93104
)
94105
}
95106
}
@@ -100,6 +111,9 @@ fun SettingsDetailScreenBody(
100111
category: SettingsCategory = result.selectedCategory!!,
101112
onBackClick: () -> Unit = {},
102113
onAction: (action: SettingsAction) -> Unit = {},
114+
onRefresh: () -> Unit = {},
115+
pullToRefreshState: PullToRefreshState = rememberPullToRefreshState(),
116+
refreshState: State<Boolean> = remember { derivedStateOf { result.loading } },
103117
) = Scaffold(
104118
topBar = {
105119
TopAppBar(
@@ -115,12 +129,22 @@ fun SettingsDetailScreenBody(
115129
)
116130
},
117131
content = { paddingValues: PaddingValues ->
118-
Column(modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())) {
119-
if (result.loading) LinearProgressIndicator(Modifier.fillMaxWidth())
120-
when (category) {
121-
SettingsCategory.Theme -> ThemePreferences(result = result, onAction = onAction)
122-
SettingsCategory.Security -> SecurityPreferences(result = result, onAction = onAction)
123-
SettingsCategory.Info -> InfoPreferences(result = result, onAction = onAction)
132+
PullToRefreshBox(
133+
modifier = Modifier.padding(paddingValues),
134+
isRefreshing = refreshState.value,
135+
onRefresh = onRefresh,
136+
state = pullToRefreshState
137+
) {
138+
LaunchedEffect(key1 = refreshState.value) {
139+
pullToRefreshState.animateToHidden()
140+
}
141+
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
142+
if (result.loading) LinearProgressIndicator(Modifier.fillMaxWidth())
143+
when (category) {
144+
SettingsCategory.Theme -> ThemePreferences(result = result, onAction = onAction)
145+
SettingsCategory.Security -> SecurityPreferences(result = result, onAction = onAction)
146+
SettingsCategory.Info -> InfoPreferences(result = result, onAction = onAction)
147+
}
124148
}
125149
}
126150
},

ui/shared/src/commonMain/kotlin/com/softartdev/notedelight/ui/settings/master/SettingsMasterScreen.kt

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ import androidx.compose.material3.ListItemDefaults
1818
import androidx.compose.material3.Scaffold
1919
import androidx.compose.material3.Text
2020
import androidx.compose.material3.TopAppBar
21+
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
22+
import androidx.compose.material3.pulltorefresh.PullToRefreshState
23+
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
2124
import androidx.compose.runtime.Composable
2225
import androidx.compose.runtime.LaunchedEffect
26+
import androidx.compose.runtime.State
2327
import androidx.compose.runtime.collectAsState
24-
import androidx.compose.runtime.getValue
28+
import androidx.compose.runtime.derivedStateOf
29+
import androidx.compose.runtime.remember
2530
import androidx.compose.ui.Modifier
2631
import androidx.compose.ui.platform.testTag
2732
import androidx.compose.ui.tooling.preview.Preview
@@ -43,12 +48,18 @@ fun SettingsMasterScreen(categoriesViewModel: SettingsCategoriesViewModel) {
4348
LaunchedEffect(categoriesViewModel) {
4449
categoriesViewModel.launchCategories()
4550
}
46-
val result: SettingsCategoriesResult by categoriesViewModel.stateFlow.collectAsState()
51+
val resultState: State<SettingsCategoriesResult> = categoriesViewModel.stateFlow.collectAsState()
52+
val result: SettingsCategoriesResult = resultState.value
53+
val refreshState: State<Boolean> = remember {
54+
derivedStateOf { resultState.value.loading }
55+
}
4756
SettingsMasterScreenBody(
4857
selectedCategoryId = result.selectedCategoryId,
4958
onCategoryClick = { category: SettingsCategory ->
5059
categoriesViewModel.onAction(SettingsCategoriesAction.SelectCategory(category))
5160
},
61+
onRefresh = { categoriesViewModel.onAction(SettingsCategoriesAction.Refresh) },
62+
refreshState = refreshState,
5263
onNavigateBack = { categoriesViewModel.onAction(SettingsCategoriesAction.NavBack) }
5364
)
5465
}
@@ -58,6 +69,9 @@ fun SettingsMasterScreenBody(
5869
selectedCategoryId: Long? = null,
5970
onCategoryClick: (SettingsCategory) -> Unit = {},
6071
onNavigateBack: () -> Unit = {},
72+
onRefresh: () -> Unit = {},
73+
pullToRefreshState: PullToRefreshState = rememberPullToRefreshState(),
74+
refreshState: State<Boolean> = remember { derivedStateOf { false } },
6175
) = Scaffold(
6276
topBar = {
6377
TopAppBar(
@@ -73,18 +87,26 @@ fun SettingsMasterScreenBody(
7387
)
7488
},
7589
content = { paddingValues: PaddingValues ->
76-
Column(
77-
modifier = Modifier
78-
.padding(paddingValues)
79-
.verticalScroll(rememberScrollState())
90+
PullToRefreshBox(
91+
modifier = Modifier.padding(paddingValues),
92+
isRefreshing = refreshState.value,
93+
onRefresh = onRefresh,
94+
state = pullToRefreshState
8095
) {
81-
SettingsCategory.entries.forEach { category: SettingsCategory ->
82-
SettingsCategoryItem(
83-
category = category,
84-
selected = selectedCategoryId == category.id,
85-
modifier = Modifier.testTag(category.tag),
86-
onClick = { onCategoryClick(category) },
87-
)
96+
LaunchedEffect(key1 = refreshState.value) {
97+
pullToRefreshState.animateToHidden()
98+
}
99+
Column(
100+
modifier = Modifier.verticalScroll(rememberScrollState())
101+
) {
102+
SettingsCategory.entries.forEach { category: SettingsCategory ->
103+
SettingsCategoryItem(
104+
category = category,
105+
selected = selectedCategoryId == category.id,
106+
modifier = Modifier.testTag(category.tag),
107+
onClick = { onCategoryClick(category) },
108+
)
109+
}
88110
}
89111
}
90112
}

0 commit comments

Comments
 (0)