-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Step 8 - Assignment , ensure UI reacts when new note is stored
- Loading branch information
Showing
11 changed files
with
416 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 8 additions & 0 deletions
8
composeApp/src/commonMain/kotlin/app/academy/di/KoinModule.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
package app.academy.di | ||
|
||
import org.koin.dsl.module | ||
|
||
fun getKoinModule(appModule: AppModule) = module { | ||
single { appModule.provideNotesRepository() } | ||
single { appModule.provideDispatchersProvider() } | ||
} |
85 changes: 80 additions & 5 deletions
85
composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,99 @@ | ||
package app.academy.domain | ||
|
||
import app.academy.data.NotesRepository | ||
import app.academy.di.AppModule | ||
import app.academy.model.Note | ||
import app.academy.utils.asNativeStateFlow | ||
import cafe.adriel.voyager.core.model.ScreenModel | ||
import co.touchlab.kermit.Logger | ||
import kotlinx.coroutines.CoroutineDispatcher | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.MainScope | ||
import kotlinx.coroutines.flow.MutableStateFlow | ||
import org.koin.core.component.KoinComponent | ||
import org.koin.core.component.inject | ||
import kotlinx.coroutines.flow.combine | ||
import kotlinx.coroutines.flow.debounce | ||
import kotlinx.coroutines.flow.flowOn | ||
import kotlinx.coroutines.flow.launchIn | ||
import kotlinx.coroutines.flow.onEach | ||
import kotlinx.coroutines.flow.update | ||
import kotlinx.coroutines.launch | ||
|
||
class HomeViewModel( | ||
coroutineScope: CoroutineScope?, | ||
) : ScreenModel, KoinComponent { | ||
appModule: AppModule | ||
) : ScreenModel { | ||
|
||
private val viewModelScope = coroutineScope ?: MainScope() | ||
private val notesRepository: NotesRepository = appModule.provideNotesRepository() | ||
private val defaultDispatcher: CoroutineDispatcher = | ||
appModule.provideDispatchersProvider().defaultDispatcher | ||
|
||
private val notesRepository: NotesRepository by inject() | ||
private val defaultDispatcher: CoroutineDispatcher by inject() | ||
/** | ||
* The current [HomeScreenUiState] | ||
*/ | ||
private val _uiState = MutableStateFlow(HomeScreenUiState(isLoadingSavedNotes = true)) | ||
val uiState = _uiState.asNativeStateFlow() | ||
|
||
private val currentSearchText = MutableStateFlow("") | ||
private var recentlyDeletedNote: Note? = null | ||
|
||
init { | ||
notesRepository.savedNotesStream.onEach { savedNotesList -> | ||
Logger.d("NotesCRUD") { "Update: $savedNotesList" } | ||
_uiState.update { it.copy(isLoadingSavedNotes = false, savedNotes = savedNotesList) } | ||
}.launchIn(viewModelScope) | ||
|
||
combine( | ||
uiState, | ||
currentSearchText.debounce(200) | ||
) { updatedUiState, searchText -> | ||
val savedNotes = updatedUiState.savedNotes | ||
// filtering notes with titles containing the search text | ||
_uiState.update { it.copy(isLoadingSearchResults = true) } | ||
val notesWithTitleContainingSearchText = savedNotes.filter { | ||
it.title.contains(searchText, ignoreCase = true) | ||
} | ||
_uiState.update { it.copy(searchResults = notesWithTitleContainingSearchText) } | ||
|
||
// filtering notes with the content containing the search text. | ||
// Since this is slower than the previous filtering operation above, | ||
// update ui state independently | ||
val notesWithContentContainingSearchText = savedNotes.filter { | ||
it.content.contains(searchText, ignoreCase = true) | ||
} | ||
_uiState.update { | ||
it.copy(searchResults = (it.searchResults + notesWithContentContainingSearchText).distinct()) | ||
} | ||
_uiState.update { | ||
it.copy(isLoadingSearchResults = false) | ||
} | ||
|
||
}.flowOn(defaultDispatcher).launchIn(viewModelScope) | ||
} | ||
|
||
/** | ||
* Searches for notes with titles or contents containing the given [searchText]. | ||
*/ | ||
fun search(searchText: String) { | ||
currentSearchText.value = searchText | ||
} | ||
|
||
fun fetchNotes() { | ||
notesRepository.savedNotesStream.onEach { savedNotesList -> | ||
Logger.d("NotesCRUD") { "Update: $savedNotesList" } | ||
_uiState.update { it.copy(isLoadingSavedNotes = false, savedNotes = savedNotesList) } | ||
}.launchIn(viewModelScope) | ||
} | ||
|
||
fun deleteNote(note: Note) { | ||
viewModelScope.launch { | ||
notesRepository.deleteNote(note) | ||
recentlyDeletedNote = note | ||
} | ||
} | ||
|
||
fun restoreRecentlyDeletedNote() { | ||
viewModelScope.launch { recentlyDeletedNote?.let { notesRepository.saveNote(it) } } | ||
} | ||
|
||
} |
93 changes: 93 additions & 0 deletions
93
composeApp/src/commonMain/kotlin/app/academy/domain/NoteDetailViewModel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
package app.academy.domain | ||
|
||
import app.academy.data.NotesRepository | ||
import app.academy.model.Note | ||
import app.academy.utils.UUID | ||
import app.academy.utils.asNativeStateFlow | ||
import cafe.adriel.voyager.core.model.ScreenModel | ||
import co.touchlab.kermit.Logger | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.FlowPreview | ||
import kotlinx.coroutines.SupervisorJob | ||
import kotlinx.coroutines.flow.MutableStateFlow | ||
import kotlinx.coroutines.flow.combine | ||
import kotlinx.coroutines.flow.debounce | ||
import kotlinx.coroutines.flow.distinctUntilChanged | ||
import kotlinx.coroutines.flow.first | ||
import kotlinx.coroutines.flow.launchIn | ||
import kotlinx.coroutines.flow.update | ||
import kotlinx.coroutines.launch | ||
import kotlinx.datetime.Clock | ||
|
||
@OptIn(FlowPreview::class) | ||
class NoteDetailViewModel( | ||
private val currentNoteId: String?, | ||
private val notesRepository: NotesRepository, | ||
coroutineScope: CoroutineScope? | ||
) : ScreenModel { | ||
|
||
private val viewModelScope = | ||
coroutineScope ?: CoroutineScope(Dispatchers.Main + SupervisorJob()) | ||
|
||
private val _titleText = MutableStateFlow("") | ||
val titleTextStream = _titleText.asNativeStateFlow() | ||
|
||
private val _contentText = MutableStateFlow("") | ||
val contentTextStream = _contentText.asNativeStateFlow() | ||
|
||
private lateinit var currentNote: Note | ||
|
||
|
||
init { | ||
viewModelScope.launch { | ||
currentNote = getOrCreateNoteWithId(currentNoteId ?: UUID.randomUUIDString()) | ||
_titleText.update { currentNote.title } | ||
_contentText.update { currentNote.content } | ||
val debounceTimeout = 200L | ||
combine( | ||
_titleText.debounce(timeoutMillis = debounceTimeout).distinctUntilChanged(), | ||
_contentText.debounce(timeoutMillis = debounceTimeout).distinctUntilChanged() | ||
) { updatedTitleText, updatedContentText -> | ||
// remove note from database, if note is blank | ||
if (updatedTitleText.isBlank() && updatedContentText.isBlank()) { | ||
notesRepository.deleteNote(currentNote) | ||
return@combine | ||
} | ||
val updatedNote = currentNote.copy( | ||
title = updatedTitleText, | ||
content = updatedContentText | ||
) | ||
Logger.d("NotesCRUD") { "Saving Note $updatedNote" } | ||
notesRepository.saveNote(updatedNote) | ||
}.launchIn(this) | ||
} | ||
} | ||
|
||
fun onTitleChange(newTitle: String) { | ||
_titleText.update { newTitle } | ||
} | ||
|
||
fun onContentChange(newContent: String) { | ||
_contentText.update { newContent } | ||
} | ||
|
||
fun clear() { | ||
_titleText.update { "" } | ||
_contentText.update { "" } | ||
} | ||
|
||
private suspend fun getOrCreateNoteWithId(id: String): Note { | ||
val savedNotes = notesRepository.savedNotesStream.first() | ||
val matchingNote = savedNotes.firstOrNull { it.id == id } | ||
if (matchingNote != null) return matchingNote | ||
return Note( | ||
id = id, | ||
title = "", | ||
content = "", | ||
createdAtTimestampMillis = Clock.System.now().toEpochMilliseconds(), | ||
isDeleted = false | ||
) | ||
} | ||
|
||
} |
111 changes: 78 additions & 33 deletions
111
composeApp/src/commonMain/kotlin/app/academy/ui/HomeScreen.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,63 +1,108 @@ | ||
package app.academy.ui | ||
|
||
import androidx.compose.foundation.ExperimentalFoundationApi | ||
import androidx.compose.foundation.layout.Box | ||
import androidx.compose.foundation.layout.fillMaxSize | ||
import androidx.compose.foundation.layout.fillMaxWidth | ||
import androidx.compose.foundation.layout.navigationBarsPadding | ||
import androidx.compose.foundation.layout.padding | ||
import androidx.compose.foundation.lazy.LazyColumn | ||
import androidx.compose.material.icons.Icons | ||
import androidx.compose.material.icons.filled.Add | ||
import androidx.compose.material3.Icon | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.DisposableEffect | ||
import androidx.compose.runtime.LaunchedEffect | ||
import androidx.compose.runtime.collectAsState | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.runtime.rememberCoroutineScope | ||
import androidx.compose.runtime.setValue | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.unit.dp | ||
import app.academy.components.AnimatedSearchBar | ||
import app.academy.components.NoteItems | ||
import app.academy.di.AppModule | ||
import app.academy.domain.HomeViewModel | ||
import cafe.adriel.voyager.core.model.rememberNavigatorScreenModel | ||
import cafe.adriel.voyager.core.screen.Screen | ||
import cafe.adriel.voyager.navigator.LocalNavigator | ||
import cafe.adriel.voyager.navigator.currentOrThrow | ||
import co.touchlab.kermit.Logger | ||
|
||
object HomeScreen : Screen { | ||
class HomeScreen(private val appModule: AppModule) : Screen { | ||
@OptIn(ExperimentalFoundationApi::class) | ||
@Composable | ||
override fun Content() { | ||
val navigator = LocalNavigator.currentOrThrow | ||
var currentSearchQuery by remember { mutableStateOf("") } | ||
var isSearchBarActive by remember { mutableStateOf(false) } | ||
LazyColumn(Modifier.fillMaxSize()) { | ||
item { | ||
// Do not add status bars padding to AnimatedSearchBar. | ||
// If the padding is added, then the search bar wouldn't | ||
// fill the entire screen when it is expanded. | ||
AnimatedSearchBar( | ||
modifier = Modifier | ||
.fillMaxWidth() | ||
.padding(bottom = 8.dp), | ||
query = currentSearchQuery, | ||
isSearchBarActive = isSearchBarActive, | ||
onQueryChange = { | ||
currentSearchQuery = it | ||
// TODO call search | ||
}, | ||
onBackButtonClick = { isSearchBarActive = false }, | ||
onActiveChange = { isSearchBarActive = it }, | ||
onClearSearchQueryButtonClick = { | ||
currentSearchQuery = "" | ||
// TODO call search | ||
}, | ||
suggestionsForQuery = emptyList(),// TODO fetch via search ui state | ||
onNoteDismissed = { | ||
//TODO note dismiss | ||
}, | ||
onNoteItemClick = { | ||
//TODO on note click | ||
} | ||
val coroutineScope = rememberCoroutineScope() | ||
val viewModel: HomeViewModel = | ||
navigator.rememberNavigatorScreenModel { | ||
HomeViewModel( | ||
coroutineScope = coroutineScope, | ||
appModule = appModule | ||
) | ||
} | ||
NoteItems(emptyList(), onClick = {}) { | ||
//onDismissed | ||
DisposableEffect(Unit) { | ||
Logger.d("NotesCRUD") { "HomeScreen is running" } | ||
onDispose { | ||
Logger.d("NotesCRUD") { "HomeScreen is disposed" } | ||
} | ||
} | ||
var currentSearchQuery by remember { mutableStateOf("") } | ||
var isSearchBarActive by remember { mutableStateOf(false) } | ||
LaunchedEffect(Unit){ | ||
Logger.d("NotesCRUD") { "HomeScreen called fetchNotes" } | ||
viewModel.fetchNotes() | ||
} | ||
val uiState by viewModel.uiState.collectAsState() | ||
Box(Modifier.fillMaxSize()) { | ||
LazyColumn(Modifier.fillMaxSize()) { | ||
item { | ||
// Do not add status bars padding to AnimatedSearchBar. | ||
// If the padding is added, then the search bar wouldn't | ||
// fill the entire screen when it is expanded. | ||
AnimatedSearchBar( | ||
modifier = Modifier | ||
.fillMaxWidth() | ||
.padding(bottom = 8.dp), | ||
query = currentSearchQuery, | ||
isSearchBarActive = isSearchBarActive, | ||
onQueryChange = { | ||
currentSearchQuery = it | ||
// TODO call search | ||
}, | ||
onBackButtonClick = { isSearchBarActive = false }, | ||
onActiveChange = { isSearchBarActive = it }, | ||
onClearSearchQueryButtonClick = { | ||
currentSearchQuery = "" | ||
// TODO call search | ||
}, | ||
suggestionsForQuery = emptyList(),// TODO fetch via search ui state | ||
onNoteDismissed = { | ||
//TODO note dismiss | ||
}, | ||
onNoteItemClick = { | ||
//TODO on note click | ||
} | ||
) | ||
} | ||
NoteItems(uiState.savedNotes, onClick = {}) { | ||
//onDismissed | ||
} | ||
} | ||
androidx.compose.material3.FloatingActionButton( | ||
modifier = Modifier | ||
.align(Alignment.BottomEnd) | ||
.navigationBarsPadding() | ||
.padding(16.dp), | ||
onClick = { | ||
navigator.push(NoteDetailScreen(appModule)) | ||
}, | ||
content = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) } | ||
) | ||
} | ||
} | ||
} |
Oops, something went wrong.