Skip to content

Commit

Permalink
Step 8 - Assignment , ensure UI reacts when new note is stored
Browse files Browse the repository at this point in the history
  • Loading branch information
aldefy committed Jun 30, 2024
1 parent 5aac42a commit 7144a6e
Show file tree
Hide file tree
Showing 11 changed files with 416 additions and 41 deletions.
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ kotlin {
implementation(libs.voyager.screenmodel)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.kermit)
// sql-delight runtime
implementation(libs.runtime.v200)
// flows support for sql-delight
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ package app.academy

import android.app.Application
import app.academy.di.AppModule
import app.academy.di.getKoinModule
import org.koin.core.context.startKoin

class NotesAppApplication : Application() {
val appModule by lazy { AppModule(this) }

override fun onCreate() {
super.onCreate()
startKoin {
modules(getKoinModule(appModule = appModule))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import app.academy.model.Note
import app.academy.model.toNote
import app.academy.model.toSavedNoteEntity
import app.academy.notes.database.SavedNoteEntity
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

Expand All @@ -14,6 +15,7 @@ class DefaultNotesRepository(

override val savedNotesStream: Flow<List<Note>> =
localNotesDataSource.savedNotesStream.map { savedNoteEntities ->
Logger.d("NotesCRUD") {"savednotes are ::$savedNoteEntities"}
savedNoteEntities.map { savedNoteEntity: SavedNoteEntity -> savedNoteEntity.toNote() }
}

Expand Down
8 changes: 8 additions & 0 deletions composeApp/src/commonMain/kotlin/app/academy/di/KoinModule.kt
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() }
}
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) } }
}

}
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 composeApp/src/commonMain/kotlin/app/academy/ui/HomeScreen.kt
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) }
)
}
}
}
Loading

0 comments on commit 7144a6e

Please sign in to comment.