Skip to content

Commit 7172961

Browse files
committed
feat: Implement adaptive Master-Detail layout for Settings
- Refactor Settings screen into a Master-Detail architecture using `ListDetailPaneScaffold`. - Create `SettingsCategoriesViewModel` and `SettingsMasterScreen` to manage category selection (Theme, Security, Info). - Move detailed preference logic to `SettingsDetailScreen` and separate components into `ThemePreferences`, `SecurityPreferences`, and `InfoPreferences`. - Update `AdaptiveInteractor` and `SettingsViewModel` to synchronize category selection state across adaptive panes. - Add pull-to-refresh support to both settings master and detail views for state re-sync. - Introduce `SettingsCategory` enum in the domain module to standardize category identification. - Optimize adaptive UI components by extracting `VerticalPaneExpansionDragHandle` and `selectedListItemColor` into shared composables. - Update `Router` and its implementations to better handle the lifecycle and navigation of the adaptive navigator. - Enhance UI tests and test screens to accommodate the new settings navigation flow and categories. - Improve build efficiency by updating the `build_quick.sh` script to more accurately exclude redundant iOS-related Gradle tasks. - Disable the `ui:test` CocoaPods release framework for iOS simulators to reduce unnecessary linking during test execution.
1 parent 3f69453 commit 7172961

42 files changed

Lines changed: 954 additions & 376 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/kmp.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ jobs:
3636
with:
3737
path: ~/.konan
3838
key: kotlin-${{ steps.kotlin-version.outputs.version }}
39-
- name: Build
39+
- name: Quick build
40+
run: ./gradle/build_quick.sh
41+
- name: Full build
4042
run: ./gradlew build
4143
- name: Archive build-output artifacts
4244
if: always()

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ private fun ScreenContent(
225225
4. **Preview functions**: Add `@Preview` for visual components
226226
5. **Remember wisely**: Use `remember` for expensive calculations
227227
6. **Keys in lists**: Provide stable keys for LazyColumn/LazyRow
228+
7. **Adaptive drag handle**: Prefer method references for pane expansion, e.g. `paneExpansionDragHandle = ThreePaneScaffoldScope::VerticalPaneExpansionDragHandle` (use a lambda in Kotlin/Wasm actuals to avoid the current composable function reference compiler issue).
228229

229230
### ViewModel Style
230231

app/android/src/androidTest/java/com/softartdev/notedelight/ui/SignInToSettingsTest.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class SignInToSettingsTest {
3434
private val composeUiTest: ComposeUiTest = reflect(composeTestRule)
3535

3636
@Test
37-
fun signInToSettingsTest() = SignInToSettingsTestCase(composeUiTest, Espresso::closeSoftKeyboard).invoke()
37+
fun signInToSettingsTest() = SignInToSettingsTestCase(
38+
composeUiTest,
39+
Espresso::closeSoftKeyboard,
40+
Espresso::pressBack,
41+
).invoke()
3842
}
39-

app/desktop/src/jvmTest/kotlin/com/softartdev/notedelight/ui/DesktopUiTests.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import androidx.compose.ui.test.ExperimentalTestApi
99
import androidx.compose.ui.test.assertIsDisplayed
1010
import androidx.compose.ui.test.junit4.ComposeContentTestRule
1111
import androidx.compose.ui.test.junit4.createComposeRule
12-
import androidx.compose.ui.test.onNodeWithContentDescription
12+
import androidx.compose.ui.test.onAllNodesWithContentDescription
1313
import androidx.compose.ui.test.performClick
1414
import androidx.lifecycle.Lifecycle
1515
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -104,9 +104,12 @@ class DesktopUiTests : AbstractJvmUiTests() {
104104
override fun localeTest() = super.localeTest()
105105

106106
override fun pressBack() {
107-
composeTestRule.onNodeWithContentDescription(label = Icons.AutoMirrored.Filled.ArrowBack.name)
108-
.assertIsDisplayed()
109-
.performClick()
107+
val backButtons = composeTestRule.onAllNodesWithContentDescription(
108+
label = Icons.AutoMirrored.Filled.ArrowBack.name
109+
)
110+
val nodes = backButtons.fetchSemanticsNodes()
111+
val rightMostIndex = nodes.indices.maxBy { index -> nodes[index].boundsInRoot.left }
112+
backButtons[rightMostIndex].assertIsDisplayed().performClick()
110113
}
111114

112115
override fun closeSoftKeyboard() = Unit // Desktop doesn't have soft keyboard

app/iosApp/Pods/Pods.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.softartdev.notedelight.model
2+
3+
enum class SettingsCategory {
4+
Theme,
5+
Security,
6+
Info;
7+
8+
val id: Long = ordinal.toLong()
9+
10+
companion object {
11+
fun fromId(id: Long?): SettingsCategory? = id?.toInt()?.let(entries::get)
12+
}
13+
}

core/presentation/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Each screen has a dedicated ViewModel following **MVVM pattern**:
5656

5757
#### Settings
5858
- `SettingsViewModel`: App settings management
59+
- `SettingsCategoriesViewModel`: Category selection and adaptive navigation for settings
5960
- `SecurityResult`: Security settings states
6061
- **Password Management:**
6162
- `EnterViewModel`: Enter new password
@@ -68,7 +69,7 @@ Each screen has a dedicated ViewModel following **MVVM pattern**:
6869

6970
### Interactors (`interactor/`)
7071

71-
- `AdaptiveInteractor`: Shared state holder that bridges adaptive navigation between `MainViewModel`, router, and UI panes. It also exposes `checkSaveChangeChannel` so the list pane can request a save-change confirmation before switching or creating notes in adaptive layouts.
72+
- `AdaptiveInteractor`: Shared state holder that bridges adaptive navigation between `MainViewModel`, `SettingsCategoriesViewModel`, router, and UI panes. It also exposes `selectedSettingsCategoryIdStateFlow` for settings selection sync and `checkSaveChangeChannel` so the list pane can request a save-change confirmation before switching or creating notes in adaptive layouts.
7273
- `SnackbarInteractor`: Multiplatform contract used by ViewModels to emit UI messages without depending on Compose APIs.
7374

7475
### Navigation (`navigation/`)

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

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,29 @@ import com.softartdev.notedelight.CoroutineDispatchersStub
88
import com.softartdev.notedelight.PrintLogWriter
99
import com.softartdev.notedelight.db.NoteDAO
1010
import com.softartdev.notedelight.interactor.AdaptiveInteractor
11+
import com.softartdev.notedelight.interactor.LocaleInteractor
1112
import com.softartdev.notedelight.interactor.SnackbarInteractor
13+
import com.softartdev.notedelight.model.SettingsCategory
1214
import com.softartdev.notedelight.model.Note
1315
import com.softartdev.notedelight.navigation.Router
1416
import com.softartdev.notedelight.presentation.MainDispatcherRule
1517
import com.softartdev.notedelight.presentation.main.MainAction
1618
import com.softartdev.notedelight.presentation.main.MainViewModel
1719
import com.softartdev.notedelight.presentation.main.NoteListResult
1820
import com.softartdev.notedelight.presentation.note.NoteAction
21+
import com.softartdev.notedelight.presentation.note.NoteResult
1922
import com.softartdev.notedelight.presentation.note.NoteViewModel
23+
import com.softartdev.notedelight.presentation.settings.SecurityResult
24+
import com.softartdev.notedelight.presentation.settings.SettingsAction
25+
import com.softartdev.notedelight.presentation.settings.SettingsCategoriesAction
26+
import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel
27+
import com.softartdev.notedelight.presentation.settings.SettingsViewModel
2028
import com.softartdev.notedelight.repository.SafeRepo
2129
import com.softartdev.notedelight.usecase.note.CreateNoteUseCase
2230
import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase
2331
import com.softartdev.notedelight.usecase.note.SaveNoteUseCase
32+
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
33+
import com.softartdev.notedelight.usecase.settings.RevealFileListUseCase
2434
import com.softartdev.notedelight.util.createLocalDateTime
2535
import kotlinx.coroutines.ExperimentalCoroutinesApi
2636
import kotlinx.coroutines.async
@@ -52,11 +62,16 @@ class AdaptiveInteractorTest {
5262
private val mockCreateNoteUseCase = Mockito.mock(CreateNoteUseCase::class.java)
5363
private val mockDeleteNoteUseCase = Mockito.mock(DeleteNoteUseCase::class.java)
5464
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
65+
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
66+
private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo)
67+
private val revealFileListUseCase = RevealFileListUseCase()
5568
private val adaptiveInteractor = AdaptiveInteractor()
5669
private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher)
57-
70+
5871
private lateinit var mainViewModel: MainViewModel
5972
private lateinit var noteViewModel: NoteViewModel
73+
private lateinit var settingsCategoriesViewModel: SettingsCategoriesViewModel
74+
private lateinit var settingsViewModel: SettingsViewModel
6075

6176
private val id = 1L
6277
private val title: String = "title"
@@ -84,6 +99,19 @@ class AdaptiveInteractorTest {
8499
router = mockRouter,
85100
coroutineDispatchers = coroutineDispatchers
86101
)
102+
settingsCategoriesViewModel = SettingsCategoriesViewModel(
103+
router = mockRouter,
104+
adaptiveInteractor = adaptiveInteractor,
105+
)
106+
settingsViewModel = SettingsViewModel(
107+
safeRepo = mockSafeRepo,
108+
checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase,
109+
snackbarInteractor = mockSnackbarInteractor,
110+
router = mockRouter,
111+
revealFileListUseCase = revealFileListUseCase,
112+
localeInteractor = mockLocaleInteractor,
113+
adaptiveInteractor = adaptiveInteractor,
114+
)
87115
Mockito.`when`(mockNoteDAO.pagingDataFlow).thenReturn(flowOf(PagingData.empty()))
88116
Mockito.`when`(mockSafeRepo.noteDAO).thenReturn(mockNoteDAO)
89117
Mockito.`when`(mockNoteDAO.count()).thenReturn(0)
@@ -93,7 +121,7 @@ class AdaptiveInteractorTest {
93121

94122
@After
95123
fun tearDown() = runTest {
96-
Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor)
124+
Mockito.reset(mockSafeRepo, mockRouter, mockNoteDAO, mockCreateNoteUseCase, mockDeleteNoteUseCase, mockSnackbarInteractor, mockLocaleInteractor)
97125
Logger.setLogWriters()
98126
}
99127

@@ -103,20 +131,20 @@ class AdaptiveInteractorTest {
103131
noteViewModel.launchCollectingSelectedNoteId()
104132

105133
mainViewModel.stateFlow.test {
106-
val initialResult = awaitItem()
134+
val initialResult: NoteListResult = awaitItem()
107135
assertTrue(initialResult is NoteListResult.Success)
108136
assertNull(initialResult.selectedId)
109137

110138
mainViewModel.onAction(MainAction.OnNoteClick(id))
111139

112-
val updatedResult = awaitItem()
140+
val updatedResult: NoteListResult = awaitItem()
113141
assertTrue(updatedResult is NoteListResult.Success)
114142
assertEquals(id, updatedResult.selectedId)
115143

116144
cancelAndIgnoreRemainingEvents()
117145
}
118146
noteViewModel.stateFlow.test {
119-
var noteResult = awaitItem()
147+
var noteResult: NoteResult = awaitItem()
120148
while (noteResult.note == null && !noteResult.loading) {
121149
noteResult = awaitItem()
122150
}
@@ -138,13 +166,13 @@ class AdaptiveInteractorTest {
138166
mainViewModel.onAction(MainAction.OnNoteClick(id))
139167

140168
mainViewModel.stateFlow.test {
141-
val selectedResult = awaitItem()
169+
val selectedResult: NoteListResult = awaitItem()
142170
assertTrue(selectedResult is NoteListResult.Success)
143171
assertEquals(id, selectedResult.selectedId)
144172

145173
noteViewModel.onAction(NoteAction.CheckSaveChange(text))
146174

147-
val clearedResult = awaitItem()
175+
val clearedResult: NoteListResult = awaitItem()
148176
assertTrue(clearedResult is NoteListResult.Success)
149177
assertNull(clearedResult.selectedId)
150178

@@ -163,4 +191,43 @@ class AdaptiveInteractorTest {
163191
deferred.await()
164192
assertEquals(id, adaptiveInteractor.selectedNoteIdStateFlow.value)
165193
}
194+
195+
@Test
196+
fun `when SettingsCategoriesViewModel selects category then SettingsViewModel reflects selection`() = runTest {
197+
settingsCategoriesViewModel.launchCategories()
198+
settingsViewModel.launchCollectingSelectedCategoryId()
199+
200+
settingsViewModel.stateFlow.test {
201+
assertNull(awaitItem().selectedCategory)
202+
203+
settingsCategoriesViewModel.onAction(SettingsCategoriesAction.SelectCategory(SettingsCategory.Security))
204+
205+
val updated: SecurityResult = awaitItem()
206+
assertEquals(SettingsCategory.Security, updated.selectedCategory)
207+
cancelAndIgnoreRemainingEvents()
208+
}
209+
Mockito.verify(mockRouter).adaptiveNavigateToDetail(contentKey = SettingsCategory.Security.ordinal.toLong())
210+
}
211+
212+
@Test
213+
fun `when SettingsViewModel navigates back then selection cleared`() = runTest {
214+
settingsCategoriesViewModel.launchCategories()
215+
settingsViewModel.launchCollectingSelectedCategoryId()
216+
217+
settingsViewModel.stateFlow.test {
218+
assertNull(awaitItem().selectedCategory)
219+
220+
settingsCategoriesViewModel.onAction(SettingsCategoriesAction.SelectCategory(SettingsCategory.Theme))
221+
222+
val selected: SecurityResult = awaitItem()
223+
assertEquals(SettingsCategory.Theme, selected.selectedCategory)
224+
225+
settingsViewModel.onAction(SettingsAction.NavBack)
226+
227+
val cleared: SecurityResult = awaitItem()
228+
assertNull(cleared.selectedCategory)
229+
cancelAndIgnoreRemainingEvents()
230+
}
231+
Mockito.verify(mockRouter).adaptiveNavigateBack()
232+
}
166233
}

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.softartdev.notedelight.presentation.settings
22

33
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
44
import app.cash.turbine.test
5+
import com.softartdev.notedelight.interactor.AdaptiveInteractor
56
import com.softartdev.notedelight.interactor.LocaleInteractor
67
import com.softartdev.notedelight.interactor.SnackbarInteractor
78
import com.softartdev.notedelight.interactor.SnackbarMessage
@@ -38,7 +39,16 @@ class SettingsViewModelTest {
3839
private val mockRouter = Mockito.mock(Router::class.java)
3940
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
4041
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
41-
private val settingsViewModel = SettingsViewModel(mockSafeRepo, checkSqlCipherVersionUseCase, mockSnackbarInteractor, mockRouter, RevealFileListUseCase(), mockLocaleInteractor)
42+
private val adaptiveInteractor = AdaptiveInteractor()
43+
private val settingsViewModel = SettingsViewModel(
44+
safeRepo = mockSafeRepo,
45+
checkSqlCipherVersionUseCase = checkSqlCipherVersionUseCase,
46+
snackbarInteractor = mockSnackbarInteractor,
47+
router = mockRouter,
48+
revealFileListUseCase = RevealFileListUseCase(),
49+
localeInteractor = mockLocaleInteractor,
50+
adaptiveInteractor = adaptiveInteractor,
51+
)
4252

4353
@After
4454
fun tearDown() = runTest {
@@ -58,6 +68,23 @@ class SettingsViewModelTest {
5868
@Test
5969
fun checkEncryptionFalse() = assertEncryption(false)
6070

71+
@Test
72+
fun refreshUpdatesSwitches() = runTest {
73+
Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED)
74+
Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH)
75+
settingsViewModel.stateFlow.test {
76+
assertFalse(awaitItem().loading)
77+
settingsViewModel.onAction(SettingsAction.Refresh)
78+
var result: SecurityResult = awaitItem()
79+
while (result.loading) {
80+
result = awaitItem()
81+
}
82+
assertTrue(result.encryption)
83+
cancelAndIgnoreRemainingEvents()
84+
}
85+
Mockito.verifyNoMoreInteractions(mockRouter)
86+
}
87+
6188
private fun assertEncryption(encryption: Boolean) = runTest {
6289
val platformSQLiteState = if (encryption) ENCRYPTED else UNENCRYPTED
6390
Mockito.`when`(mockSafeRepo.databaseState).thenReturn(platformSQLiteState)

0 commit comments

Comments
 (0)