From 482ead3aacef9fc879cc370206ccd6cd5e583132 Mon Sep 17 00:00:00 2001 From: Adit Lal Date: Sat, 29 Jun 2024 19:46:02 +0530 Subject: [PATCH 1/3] Step 7 - Adds Ui State data class for home screen --- .../kotlin/app/academy/domain/HomeScreenUiState.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/app/academy/domain/HomeScreenUiState.kt diff --git a/composeApp/src/commonMain/kotlin/app/academy/domain/HomeScreenUiState.kt b/composeApp/src/commonMain/kotlin/app/academy/domain/HomeScreenUiState.kt new file mode 100644 index 0000000..06648d7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/academy/domain/HomeScreenUiState.kt @@ -0,0 +1,13 @@ +package app.academy.domain + +import app.academy.model.Note + +/** + * The UI state for the home screen. + */ +data class HomeScreenUiState( + val isLoadingSavedNotes: Boolean = false, + val isLoadingSearchResults: Boolean = false, + val savedNotes: List = emptyList(), + val searchResults: List = emptyList() +) From 5aac42aa47d6e6bcbe0fe1ae2c979ba0aeaf816e Mon Sep 17 00:00:00 2001 From: Adit Lal Date: Sat, 29 Jun 2024 19:59:14 +0530 Subject: [PATCH 2/3] Step 7 - Adds VM and AppModule binding to allow VM to function --- composeApp/build.gradle.kts | 2 ++ .../app/academy/domain/HomeViewModel.kt | 20 ++++++++++++++++++- .../kotlin/app/academy/di/AppModule.ios.kt | 2 +- gradle/libs.versions.toml | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 550de39..09096ce 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -49,6 +49,8 @@ kotlin { implementation(libs.voyager.navigator) implementation(libs.voyager.navigator.transitions) implementation(libs.voyager.screenmodel) + implementation(libs.koin.core) + implementation(libs.koin.compose) // sql-delight runtime implementation(libs.runtime.v200) // flows support for sql-delight diff --git a/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt index 0b1329f..dffa138 100644 --- a/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt @@ -1,6 +1,24 @@ package app.academy.domain +import app.academy.data.NotesRepository +import app.academy.utils.asNativeStateFlow import cafe.adriel.voyager.core.model.ScreenModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class HomeViewModel( + coroutineScope: CoroutineScope?, +) : ScreenModel, KoinComponent { + + 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() -class HomeViewModel : ScreenModel { } diff --git a/composeApp/src/iosMain/kotlin/app/academy/di/AppModule.ios.kt b/composeApp/src/iosMain/kotlin/app/academy/di/AppModule.ios.kt index 4b7a6a7..f01282b 100644 --- a/composeApp/src/iosMain/kotlin/app/academy/di/AppModule.ios.kt +++ b/composeApp/src/iosMain/kotlin/app/academy/di/AppModule.ios.kt @@ -4,7 +4,7 @@ import app.academy.data.DefaultNotesRepository import app.academy.data.NotesRepository import app.academy.data.local.NotesDatabaseDriverFactory import app.academy.data.local.datasource.DefaultLocalNotesDataSource -import com.example.notes.database.NotesDatabase +import com.app.academy.notes.database.NotesDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6dfb1a4..626c660 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "k ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" } androidx-material3-android = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } -koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version = "1.1.5" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } native-driver-v200 = { module = "app.cash.sqldelight:native-driver", version.ref = "nativeDriver" } From 7144a6e0a09af20051f2847133da9d53ebd01426 Mon Sep 17 00:00:00 2001 From: Adit Lal Date: Sun, 30 Jun 2024 15:40:02 +0530 Subject: [PATCH 3/3] Step 8 - Assignment , ensure UI reacts when new note is stored --- composeApp/build.gradle.kts | 1 + .../kotlin/app/academy/NotesAppApplication.kt | 5 + .../academy/data/DefaultNotesRepository.kt | 2 + .../kotlin/app/academy/di/KoinModule.kt | 8 + .../app/academy/domain/HomeViewModel.kt | 85 ++++++++++- .../app/academy/domain/NoteDetailViewModel.kt | 93 ++++++++++++ .../kotlin/app/academy/ui/HomeScreen.kt | 111 ++++++++++---- .../kotlin/app/academy/ui/NoteDetailScreen.kt | 142 ++++++++++++++++++ .../kotlin/app/academy/ui/NotesNavigation.kt | 2 +- .../src/iosMain/kotlin/MainViewController.kt | 5 +- gradle/libs.versions.toml | 3 + 11 files changed, 416 insertions(+), 41 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/app/academy/di/KoinModule.kt create mode 100644 composeApp/src/commonMain/kotlin/app/academy/domain/NoteDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/app/academy/ui/NoteDetailScreen.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 09096ce..3d0b0d6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 diff --git a/composeApp/src/androidMain/kotlin/app/academy/NotesAppApplication.kt b/composeApp/src/androidMain/kotlin/app/academy/NotesAppApplication.kt index 4371d2b..3007d44 100644 --- a/composeApp/src/androidMain/kotlin/app/academy/NotesAppApplication.kt +++ b/composeApp/src/androidMain/kotlin/app/academy/NotesAppApplication.kt @@ -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)) + } } } diff --git a/composeApp/src/commonMain/kotlin/app/academy/data/DefaultNotesRepository.kt b/composeApp/src/commonMain/kotlin/app/academy/data/DefaultNotesRepository.kt index 8a2d3d0..c54b5b0 100644 --- a/composeApp/src/commonMain/kotlin/app/academy/data/DefaultNotesRepository.kt +++ b/composeApp/src/commonMain/kotlin/app/academy/data/DefaultNotesRepository.kt @@ -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 @@ -14,6 +15,7 @@ class DefaultNotesRepository( override val savedNotesStream: Flow> = localNotesDataSource.savedNotesStream.map { savedNoteEntities -> + Logger.d("NotesCRUD") {"savednotes are ::$savedNoteEntities"} savedNoteEntities.map { savedNoteEntity: SavedNoteEntity -> savedNoteEntity.toNote() } } diff --git a/composeApp/src/commonMain/kotlin/app/academy/di/KoinModule.kt b/composeApp/src/commonMain/kotlin/app/academy/di/KoinModule.kt new file mode 100644 index 0000000..314ab62 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/academy/di/KoinModule.kt @@ -0,0 +1,8 @@ +package app.academy.di + +import org.koin.dsl.module + +fun getKoinModule(appModule: AppModule) = module { + single { appModule.provideNotesRepository() } + single { appModule.provideDispatchersProvider() } +} diff --git a/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt index dffa138..989d7f2 100644 --- a/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/app/academy/domain/HomeViewModel.kt @@ -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) } } + } + } diff --git a/composeApp/src/commonMain/kotlin/app/academy/domain/NoteDetailViewModel.kt b/composeApp/src/commonMain/kotlin/app/academy/domain/NoteDetailViewModel.kt new file mode 100644 index 0000000..d35b6cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/academy/domain/NoteDetailViewModel.kt @@ -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 + ) + } + +} diff --git a/composeApp/src/commonMain/kotlin/app/academy/ui/HomeScreen.kt b/composeApp/src/commonMain/kotlin/app/academy/ui/HomeScreen.kt index bbe24ee..4a6e12c 100644 --- a/composeApp/src/commonMain/kotlin/app/academy/ui/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/app/academy/ui/HomeScreen.kt @@ -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) } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/app/academy/ui/NoteDetailScreen.kt b/composeApp/src/commonMain/kotlin/app/academy/ui/NoteDetailScreen.kt new file mode 100644 index 0000000..c1ac9f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/app/academy/ui/NoteDetailScreen.kt @@ -0,0 +1,142 @@ +package app.academy.ui + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import app.academy.di.AppModule +import app.academy.domain.NoteDetailViewModel +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 + +class NoteDetailScreen(private val appModule: AppModule, private val noteId: String? = null) : + Screen { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val coroutineScope = rememberCoroutineScope() + val viewModel: NoteDetailViewModel = + navigator.rememberNavigatorScreenModel { + NoteDetailViewModel( + currentNoteId = noteId, + notesRepository = appModule.provideNotesRepository(), + coroutineScope = coroutineScope + ) + } + val noteTitle by viewModel.titleTextStream.collectAsState() + val noteContent by viewModel.contentTextStream.collectAsState() + NoteDetailScreenContent( + noteTitle = noteTitle, + noteContent = noteContent, + onNoteTitleChange = viewModel::onTitleChange, + onNoteContentChange = viewModel::onContentChange, + onBackButtonClick = { + viewModel.clear() + navigator.pop() + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoteDetailScreenContent( + modifier: Modifier = Modifier, + noteTitle: String, + noteContent: String, + onNoteTitleChange: (String) -> Unit, + onNoteContentChange: (String) -> Unit, + onBackButtonClick: () -> Unit +) { + val scrollState = rememberScrollState() + // without adding scrollState modifier, the "note content" TextField inside this composable + // will scroll independent of the screen. This behavior is undesirable, hence use scroll state. + Column(modifier = modifier.verticalScroll(scrollState)) { + IconButton( + modifier = Modifier.statusBarsPadding(), + onClick = onBackButtonClick, + content = { Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null) } + ) + NoteDetailScreenBasicTextField( + modifier = Modifier.fillMaxWidth(), + value = noteTitle, + textStyle = MaterialTheme.typography.displaySmall, + onValueChange = onNoteTitleChange, + placeholderText = "Title" + ) + NoteDetailScreenBasicTextField( + modifier = Modifier.fillMaxSize(), + value = noteContent, + onValueChange = onNoteContentChange, + textStyle = MaterialTheme.typography.titleLarge, + placeholderText = "Note" + ) + } +} + +@ExperimentalMaterial3Api +@Composable +private fun NoteDetailScreenBasicTextField( + value: String, + modifier: Modifier = Modifier, + placeholderText: String = "", + textStyle: TextStyle = LocalTextStyle.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onValueChange: (String) -> Unit, +) { + val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + BasicTextField( + modifier = modifier, + interactionSource = interactionSource, + value = value, + textStyle = mergedTextStyle, + onValueChange = onValueChange, + cursorBrush = SolidColor(textColor), + decorationBox = { + TextFieldDefaults.DecorationBox( + interactionSource = interactionSource, + value = value, + innerTextField = it, + enabled = true, + singleLine = false, + visualTransformation = VisualTransformation.None, + placeholder = { + Text( + modifier = Modifier.alpha(0.7f), + text = placeholderText, + style = textStyle + ) + }, + container = {} + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/app/academy/ui/NotesNavigation.kt b/composeApp/src/commonMain/kotlin/app/academy/ui/NotesNavigation.kt index 263a302..486abdd 100644 --- a/composeApp/src/commonMain/kotlin/app/academy/ui/NotesNavigation.kt +++ b/composeApp/src/commonMain/kotlin/app/academy/ui/NotesNavigation.kt @@ -9,7 +9,7 @@ import cafe.adriel.voyager.transitions.SlideTransition fun NotesAppNavigation( appModule: AppModule, ) { - Navigator(screen = HomeScreen) { + Navigator(screen = HomeScreen(appModule)) { SlideTransition(navigator = it) } } diff --git a/composeApp/src/iosMain/kotlin/MainViewController.kt b/composeApp/src/iosMain/kotlin/MainViewController.kt index dfbb34a..c7ccf9e 100644 --- a/composeApp/src/iosMain/kotlin/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/MainViewController.kt @@ -1,4 +1,5 @@ import androidx.compose.ui.window.ComposeUIViewController -import com.app.academy.ui.App +import app.academy.di.AppModule +import app.academy.ui.App -fun MainViewController() = ComposeUIViewController { App() } +fun MainViewController() = ComposeUIViewController { App(AppModule()) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 626c660..98be127 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,8 @@ material3Android = "1.2.1" lifecycleViewmodelCompose = "2.7.0" kotlinVersion = "2.0.0" voyager = "1.0.0" +kermit = "2.0.3" + [libraries] androidx-core = { module = "androidx.test:core", version.ref = "core" } @@ -63,6 +65,7 @@ runtime-v200 = { module = "app.cash.sqldelight:runtime", version.ref = "runtime" voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-navigator-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }