From 2c5ef160572cb45e25ea11c7fbbfe813b5e60873 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Mon, 11 May 2026 23:14:39 +0300 Subject: [PATCH 01/35] style: redesign notes search, directory dialog UI, directories screen # Conflicts: # app/src/main/java/com/itlab/notes/di/AppModule.kt --- .../main/java/com/itlab/notes/di/AppModule.kt | 5 +- .../main/java/com/itlab/notes/ui/NotesApp.kt | 1 - .../itlab/notes/ui/notes/AppSearchField.kt | 116 ++++ .../itlab/notes/ui/notes/DirectoriesScreen.kt | 596 +++++++++++++++--- .../com/itlab/notes/ui/notes/NotesScreen.kt | 307 +++------ 5 files changed, 737 insertions(+), 288 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index 722d44e9..f3b166e3 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -7,7 +7,6 @@ import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase -import com.itlab.domain.usecase.noteusecase.GetUserIdUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase @@ -30,7 +29,8 @@ val appModule = factory { ObserveFoldersUseCase(get()) } factory { MoveNoteToFolderUseCase(get(), get()) } factory { ObserveNotesUseCase(get()) } - factory { GetUserIdUseCase(get()) } + factory { UpdateFolderUseCase(get()) } + factory { GetFolderUseCase(get()) } factory { NotesUseCases( createFolderUseCase = get(), @@ -44,7 +44,6 @@ val appModule = getFolderUseCase = get(), moveNoteToFolderUseCase = get(), observeNotesUseCase = get(), - getUserIdUseCase = get(), ) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 2abb3c45..f6922d0a 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -35,7 +35,6 @@ fun notesApp() { notesListScreen( directoryName = screen.directory.name, notes = state.notes, - directories = state.directories.filter { it.id != "all" }, actions = NotesListActions( onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectories) }, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt new file mode 100644 index 00000000..1e8e10d1 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt @@ -0,0 +1,116 @@ +package com.itlab.notes.ui.notes + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun AppSearchField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholderText: String = "Search", +) { + var isFocused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = modifier + .fillMaxWidth() + .height(56.dp), + contentAlignment = Alignment.Center, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .onFocusChanged { isFocused = it.isFocused }, + singleLine = true, + interactionSource = interactionSource, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = { + Text( + placeholderText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp).size(25.dp), + ) + }, + trailingIcon = { + if (isFocused) { + IconButton( + onClick = { + onValueChange("") + isFocused = false + focusManager.clearFocus(force = true) + }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + shape = CircleShape, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + cursorColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = PaddingValues(start = 24.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index eac273bd..e81c4aba 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -1,55 +1,103 @@ package com.itlab.notes.ui.notes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTimeFilled import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderCopy +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stars -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties + +private const val RECENT_DIRECTORY_ID = "recent" @OptIn(ExperimentalMaterial3Api::class) @Composable fun directoriesScreen( - directories: List, + directories: List = previewDirectoriesFallback(), onCreateDirectory: (String) -> Unit, onDeleteDirectory: (DirectoryItemUi) -> Unit, onRenameDirectory: (DirectoryItemUi, String) -> Unit, onDirectoryClick: (DirectoryItemUi) -> Unit, ) { val colors = MaterialTheme.colorScheme + val focusManager = LocalFocusManager.current var showCreateDialog by remember { mutableStateOf(false) } Scaffold( + modifier = + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + focusManager.clearFocus(force = true) + }, containerColor = colors.background, topBar = { directoriesTopBar( @@ -67,37 +115,127 @@ fun directoriesScreen( } if (showCreateDialog) { var directoryName by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showCreateDialog = false }, - title = { Text("New Directory") }, - text = { - OutlinedTextField( + val onDismiss = { showCreateDialog = false } + UniversalBasicAlertDialog( + onDismissRequest = onDismiss, + icon = { + Box( + Modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Icon( + Icons.Rounded.Folder, + modifier = Modifier + .padding(all = 14.dp) + .size(32.dp), + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + title = { + Text( + text = "Create Directory", + fontWeight = FontWeight.W400, + ) + }, + input = { + DirectoryOutlinedTextField( value = directoryName, onValueChange = { directoryName = it }, - label = { Text("Directory name") }, - singleLine = true, + placeholderText = "Enter directory name...", ) }, - confirmButton = { + actions = { TextButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = 12.dp) + ) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(4.dp)) + + Button( onClick = { onCreateDirectory(directoryName) - showCreateDialog = false + onDismiss() }, enabled = directoryName.trim().isNotEmpty(), + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium ) { Text("Create") } - }, - dismissButton = { - TextButton(onClick = { showCreateDialog = false }) { - Text("Cancel") - } - }, + } ) } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UniversalBasicAlertDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), + icon: @Composable () -> Unit, + title: @Composable () -> Unit, + input: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, +) { + BasicAlertDialog( + onDismissRequest = onDismissRequest, + modifier = modifier + .fillMaxWidth(0.87f) + .sizeIn(maxWidth = 560.dp), + properties = properties, + ) { + val dialogFocusManager = LocalFocusManager.current + Surface( + shape = MaterialTheme.shapes.extraLarge, + ) { + Box { + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + dialogFocusManager.clearFocus(force = true) + }, + ) + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + icon() + Spacer(Modifier.height(10.dp)) + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface, + ) { + ProvideTextStyle(MaterialTheme.typography.headlineMedium) { + title() + } + } + Spacer(Modifier.height(14.dp)) + input() + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + actions() + } + } + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun directoriesTopBar(onAddDirectoryClick: () -> Unit) { @@ -134,17 +272,74 @@ private fun directoriesList( ) { var directoryPendingDelete by remember { mutableStateOf(null) } var directoryPendingRename by remember { mutableStateOf(null) } - LazyColumn(modifier = modifier.padding(horizontal = 16.dp)) { - items(directories) { dir -> - directoryRow( - directory = dir, - onClick = { onDirectoryClick(dir) }, - onLongClick = { - if (dir.id != "all") { - directoryPendingDelete = dir - } - }, - ) + val focusManager = LocalFocusManager.current + val favoriteDirectoryIds = setOf("all", "study", "cook") + val favoriteDirectories = directories.filter { it.id in favoriteDirectoryIds } + val regularDirectories = directories.filterNot { it.id in favoriteDirectoryIds } + val regularCount = directories.count { it.id != "all" } + val totalNotesCount = directories.firstOrNull { it.id == "all" }?.noteCount ?: directories.sumOf { it.noteCount } + val recentDirectory = DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = totalNotesCount) + Column( + modifier = + modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onTap = { + focusManager.clearFocus(force = true) + }, + ) + } + .padding(horizontal = 12.dp), + ) { + DirectorySearchBar() + LazyColumn( + modifier = Modifier.weight(1f, fill = false), + contentPadding = PaddingValues(bottom = 12.dp), + ) { + item { + directoriesHeroPanel( + directoriesCount = regularCount, + totalNotesCount = totalNotesCount, + ) + } + item { + sectionTitle(title = "Continue working") + } + item { + directoriesBlock( + directories = listOf(recentDirectory), + isRegularDirectoriesBlock = true, + onDirectoryClick = onDirectoryClick, + onDirectoryLongClick = { directoryPendingDelete = it }, + ) + } + if (favoriteDirectories.isNotEmpty()) { + item { + sectionTitle(title = "Favorite directories") + } + item { + directoriesBlock( + directories = favoriteDirectories, + isRegularDirectoriesBlock = false, + onDirectoryClick = onDirectoryClick, + onDirectoryLongClick = { directoryPendingDelete = it }, + ) + } + } + if (regularDirectories.isNotEmpty()) { + item { + sectionTitle(title = "Regular directories") + } + item { + directoriesBlock( + directories = regularDirectories, + isRegularDirectoriesBlock = true, + onDirectoryClick = onDirectoryClick, + onDirectoryLongClick = { directoryPendingDelete = it }, + ) + } + } } } directoryPendingDelete?.let { dir -> @@ -174,88 +369,147 @@ private fun directoriesList( } @Composable -private fun directoryActionsDialog( - directory: DirectoryItemUi, - onDelete: () -> Unit, - onRename: () -> Unit, - onDismiss: () -> Unit, +private fun directoriesHeroPanel( + directoriesCount: Int, + totalNotesCount: Int, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Directory actions") }, - text = { Text("Choose action for \"${directory.name}\"") }, - confirmButton = { - TextButton(onClick = onDelete) { - Text("Delete") - } - }, - dismissButton = { - TextButton(onClick = onRename) { - Text("Rename") + val colors = MaterialTheme.colorScheme + Surface( + color = colors.surfaceContainer, + shape = MaterialTheme.shapes.large, + modifier = Modifier.padding(top = 10.dp, bottom = 6.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.FolderCopy, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(25.dp), + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = "Workspace", + style = MaterialTheme.typography.titleMedium, + color = colors.onSurface, + ) + Text( + text = "$totalNotesCount notes • $directoriesCount directories", + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) } - }, + } + } +} + +@Composable +private fun sectionTitle(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 6.dp), ) } @Composable -private fun directoryRenameDialog( - directory: DirectoryItemUi, - onSave: (String) -> Unit, - onDismiss: () -> Unit, +private fun directoriesBlock( + directories: List, + isRegularDirectoriesBlock: Boolean, + onDirectoryClick: (DirectoryItemUi) -> Unit, + onDirectoryLongClick: (DirectoryItemUi) -> Unit, ) { - var renameName by remember(directory.id) { mutableStateOf(directory.name) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Rename directory") }, - text = { - OutlinedTextField( - value = renameName, - onValueChange = { renameName = it }, - label = { Text("Directory name") }, - singleLine = true, - ) - }, - confirmButton = { - TextButton( - onClick = { onSave(renameName) }, - enabled = renameName.trim().isNotEmpty(), - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + directories.forEachIndexed { index, dir -> + directoryRow( + directory = dir, + isRegularDirectory = isRegularDirectoriesBlock, + onClick = { + if (!isSpecialDirectory(dir.id)) { + onDirectoryClick(dir) + } + }, + onLongClick = { + if (!isSpecialDirectory(dir.id)) { + onDirectoryLongClick(dir) + } + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + ) + if (index < directories.lastIndex) { + directoriesListDivider() + } } + } + } +} + +@Composable +fun DirectorySearchBar( + modifier: Modifier = Modifier, + onQueryChange: (String) -> Unit = {} +) { + var searchQuery by remember { mutableStateOf("") } + AppSearchField( + value = searchQuery, + onValueChange = { + searchQuery = it + onQueryChange(it) }, + modifier = modifier, + placeholderText = "Search directories", ) } +private fun isSpecialDirectory(directoryId: String): Boolean = + directoryId == "all" || directoryId == RECENT_DIRECTORY_ID + @OptIn(ExperimentalFoundationApi::class) @Composable private fun directoryRow( directory: DirectoryItemUi, + isRegularDirectory: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, + modifier: Modifier = Modifier, ) { val colors = MaterialTheme.colorScheme Row( modifier = - Modifier + modifier .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) .combinedClickable( onClick = onClick, onLongClick = onLongClick, - ).padding(vertical = 12.dp), + ) + .padding(horizontal = 12.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.Default.Stars, + imageVector = + when { + directory.id == RECENT_DIRECTORY_ID -> Icons.Default.AccessTimeFilled + isRegularDirectory -> Icons.Default.Folder + else -> Icons.Default.Stars + }, contentDescription = null, - tint = colors.primary, - modifier = Modifier.size(24.dp), + tint = if (isRegularDirectory) colors.onSurfaceVariant else colors.primary, + modifier = Modifier.size(25.dp), ) - Spacer(Modifier.width(16.dp)) + Spacer(Modifier.width(12.dp)) Text( text = directory.name, color = colors.onSurface, @@ -273,6 +527,7 @@ private fun directoryRow( style = MaterialTheme.typography.labelSmall, ) } + Spacer(Modifier.width(5.dp)) Icon( Icons.Default.ChevronRight, contentDescription = null, @@ -280,3 +535,184 @@ private fun directoryRow( ) } } + +@Composable +private fun directoriesListDivider() { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .padding(horizontal = 10.dp) + .fillMaxWidth(0.9f), + contentAlignment = Alignment.Center, + ) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + thickness = 1.dp, + ) + } + } +} + +@Composable +private fun directoryActionsDialog( + directory: DirectoryItemUi, + onDelete: () -> Unit, + onRename: () -> Unit, + onDismiss: () -> Unit, +) { + UniversalBasicAlertDialog( + onDismissRequest = onDismiss, + + icon = { + Box( + Modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Icon( + Icons.Rounded.Edit, + modifier = Modifier + .padding(all = 14.dp) + .size(32.dp), + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + title = { + Text("Directory actions") + }, + input = { + Text("Choose action for \"${directory.name}\"", + style = MaterialTheme.typography.bodyLarge + ) + }, + actions = { + TextButton(onClick = onRename) { + Text("Rename") + } + + Spacer(modifier = Modifier.width(4.dp)) + + Button( + onClick = onDelete, + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Delete") + } + }, + ) +} + +@Composable +private fun directoryRenameDialog( + directory: DirectoryItemUi, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var renameName by remember(directory.id) { mutableStateOf(directory.name) } + UniversalBasicAlertDialog( + onDismissRequest = onDismiss, + icon = { + Box( + Modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Icon( + Icons.Rounded.Edit, + modifier = Modifier + .padding(all = 14.dp) + .size(32.dp), + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + title = { + Text("Rename directory") + }, + input = { + DirectoryOutlinedTextField( + value = renameName, + onValueChange = { renameName = it }, + placeholderText = "Enter directory name...", + ) + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(4.dp)) + + Button( + onClick = { onSave(renameName) }, + enabled = renameName.trim().isNotEmpty() && renameName != directory.name, + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + ) { + Text("Save") + } + }, + ) +} + +@Composable +private fun DirectoryOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + placeholderText: String, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(placeholderText) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.TextFields, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = modifier.fillMaxWidth(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + shape = MaterialTheme.shapes.medium, + suffix = { + Icons.Default.TextFields + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedIndicatorColor = MaterialTheme.colorScheme.outline, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + disabledIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) +} + +private fun previewDirectoriesFallback(): List = + listOf( + DirectoryItemUi(id = "all", name = "All Notes", noteCount = 0), + DirectoryItemUi(id = "study", name = "My Study", noteCount = 0), + DirectoryItemUi(id = "cook", name = "How to Cook", noteCount = 0), + DirectoryItemUi(id = "poems", name = "My poems", noteCount = 0), + DirectoryItemUi(id = "guides", name = "Guides", noteCount = 0), + ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 64de47cb..ea7dfcfd 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -1,31 +1,24 @@ package com.itlab.notes.ui.notes -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -36,57 +29,69 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import kotlin.math.abs @OptIn(ExperimentalMaterial3Api::class) @Composable fun notesListScreen( directoryName: String, notes: List, - directories: List, actions: NotesListActions, ) { val colors = MaterialTheme.colorScheme + val selectedNoteIds = remember { mutableStateListOf() } + val isSelectionMode = selectedNoteIds.isNotEmpty() + val selectedCount = selectedNoteIds.size + val clearSelection = { selectedNoteIds.clear() } + val deleteSelected = { + notes.filter { it.id in selectedNoteIds }.forEach { note -> + actions.onNoteDelete(note) + } + selectedNoteIds.clear() + } + val handleBack = { + if (isSelectionMode) { + clearSelection() + } else { + actions.onBack() + } + } Scaffold( containerColor = colors.background, topBar = { notesTopBar( directoryName = directoryName, - onBack = actions.onBack, + selectedCount = selectedCount, + onBack = handleBack, + onDeleteSelected = deleteSelected, ) }, floatingActionButton = { - notesFab(onAddNoteClick = actions.onAddNoteClick) + if (!isSelectionMode) { + notesFab(onAddNoteClick = actions.onAddNoteClick) + } }, ) { paddingValues -> notesListContent( notes = notes, paddingValues = paddingValues, - directories = directories, + selectedNoteIds = selectedNoteIds, actions = NotesListContentActions( onNoteDelete = actions.onNoteDelete, - onNoteMove = actions.onNoteMove, onNoteClick = actions.onNoteClick, ), ) @@ -103,7 +108,6 @@ data class NotesListActions( private data class NotesListContentActions( val onNoteDelete: (NoteItemUi) -> Unit, - val onNoteMove: (noteId: String, directoryId: String) -> Unit, val onNoteClick: (NoteItemUi) -> Unit, ) @@ -111,20 +115,43 @@ private data class NotesListContentActions( @Composable private fun notesTopBar( directoryName: String, + selectedCount: Int, onBack: () -> Unit, + onDeleteSelected: () -> Unit, ) { val colors = MaterialTheme.colorScheme CenterAlignedTopAppBar( - title = { Text(directoryName, color = colors.onSurface) }, + title = { + Text( + text = if (selectedCount > 0) "$selectedCount selected" else directoryName, + color = colors.onSurface, + ) + }, navigationIcon = { IconButton(onClick = onBack) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, + imageVector = + if (selectedCount > 0) { + Icons.Default.Close + } else { + Icons.AutoMirrored.Filled.ArrowBack + }, contentDescription = null, tint = colors.onSurface, ) } }, + actions = { + if (selectedCount > 0) { + IconButton(onClick = onDeleteSelected) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = colors.onSurface, + ) + } + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, @@ -155,10 +182,10 @@ private fun notesFab(onAddNoteClick: () -> Unit) { private fun notesListContent( notes: List, paddingValues: androidx.compose.foundation.layout.PaddingValues, - directories: List, + selectedNoteIds: MutableList, actions: NotesListContentActions, ) { - var pendingMoveNote by remember { mutableStateOf(null) } + val isSelectionMode = selectedNoteIds.isNotEmpty() Column( modifier = Modifier @@ -177,144 +204,69 @@ private fun notesListContent( ) { note -> notesListItem( note = note, - onDelete = { actions.onNoteDelete(note) }, - onClick = { actions.onNoteClick(note) }, - onMoveRequest = { pendingMoveNote = note }, + isSelected = note.id in selectedNoteIds, + onClick = { + if (isSelectionMode) { + if (note.id in selectedNoteIds) { + selectedNoteIds.remove(note.id) + } else { + selectedNoteIds.add(note.id) + } + } else { + actions.onNoteClick(note) + } + }, + onLongClick = { + if (note.id !in selectedNoteIds) { + selectedNoteIds.add(note.id) + } + }, ) } } } - pendingMoveNote?.let { note -> - moveNoteDialog( - directories = directories, - onMoveTo = { directoryId -> - actions.onNoteMove(note.id, directoryId) - pendingMoveNote = null - }, - onDismiss = { pendingMoveNote = null }, - ) - } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun notesListItem( note: NoteItemUi, - onDelete: () -> Unit, + isSelected: Boolean, onClick: () -> Unit, - onMoveRequest: () -> Unit, -) { - var isDeleteDispatched by remember(note.id) { mutableStateOf(false) } - val dismissState = - rememberSwipeToDismissBoxState( - positionalThreshold = { totalDistance -> totalDistance * 0.22f }, - ) - val swipeProgress by - remember(dismissState) { - derivedStateOf { - dismissState.progress.coerceIn(0f, 1f) - } - } - val swipeOffsetPx by - remember(dismissState) { - derivedStateOf { - kotlin.runCatching { abs(dismissState.requireOffset()) }.getOrDefault(0f) - } - } - LaunchedEffect(dismissState.targetValue, isDeleteDispatched) { - if (!isDeleteDispatched && dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) { - isDeleteDispatched = true - onDelete() - } - } - SwipeToDismissBox( - state = dismissState, - enableDismissFromStartToEnd = false, - backgroundContent = { - swipeDeleteBackground( - isActive = dismissState.targetValue == SwipeToDismissBoxValue.EndToStart, - swipeProgress = swipeProgress, - swipeOffsetPx = swipeOffsetPx, - ) - }, - ) { - noteCard( - note = note, - onClick = onClick, - onLongClick = onMoveRequest, - ) - } -} - -@Composable -private fun swipeDeleteBackground( - isActive: Boolean, - swipeProgress: Float, - swipeOffsetPx: Float, + onLongClick: () -> Unit, ) { - val colors = MaterialTheme.colorScheme - val density = LocalDensity.current - val clampedProgress = swipeProgress.coerceIn(0f, 1f) - val activeScale by animateFloatAsState( - targetValue = if (isActive) 1f else (0.9f + clampedProgress * 0.1f), - label = "deleteIconScale", - ) - val activeAlpha by animateFloatAsState( - targetValue = if (isActive) 1f else (0.62f + clampedProgress * 0.28f), - label = "deleteIconAlpha", + noteCard( + note = note, + isSelected = isSelected, + onClick = onClick, + onLongClick = onLongClick, ) - BoxWithConstraints( - modifier = Modifier.fillMaxSize().padding(vertical = 1.dp), - contentAlignment = Alignment.CenterEnd, - ) { - val maxWidth = maxWidth - val gapFromCard = 8.dp - val targetWidth = - with(density) { (swipeOffsetPx - gapFromCard.toPx()).coerceAtLeast(0f).toDp() } - .coerceAtMost(maxWidth) - val animatedWidth by animateDpAsState(targetValue = targetWidth, label = "deleteBackgroundWidth") - Surface( - color = colors.errorContainer.copy(alpha = 0.6f), - shape = RoundedCornerShape(16.dp), - modifier = - Modifier - .fillMaxHeight() - .width(animatedWidth), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = colors.onErrorContainer.copy(alpha = activeAlpha), - modifier = - Modifier.graphicsLayer( - scaleX = activeScale, - scaleY = activeScale, - ), - ) - } - } - } } @Composable @OptIn(ExperimentalFoundationApi::class) private fun noteCard( note: NoteItemUi, + isSelected: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme Card( - colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant), - shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + colors.surfaceContainerHighest + } else { + colors.surfaceContainer + }, + ), + shape = MaterialTheme.shapes.large, modifier = Modifier .fillMaxWidth() + .clip(MaterialTheme.shapes.large) .combinedClickable( onClick = onClick, onLongClick = onLongClick, @@ -329,7 +281,12 @@ private fun noteCard( Spacer(Modifier.height(8.dp)) Text( text = note.content, - color = colors.onSurfaceVariant, + color = + if (isSelected) { + colors.onPrimaryContainer + } else { + colors.onSurfaceVariant + }, style = MaterialTheme.typography.bodySmall, maxLines = 4, ) @@ -337,71 +294,13 @@ private fun noteCard( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun moveNoteDialog( - directories: List, - onMoveTo: (String) -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Move note") }, - text = { - Column { - directories.forEach { dir -> - TextButton( - onClick = { onMoveTo(dir.id) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(dir.name) - } - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - @Composable private fun searchField() { - val colors = MaterialTheme.colorScheme - - Surface( - color = colors.surfaceVariant.copy(alpha = 0.65f), - shape = RoundedCornerShape(24.dp), - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Menu, - contentDescription = null, - tint = colors.onSurfaceVariant, - ) - Text( - text = "Hinted search text", - color = colors.onSurfaceVariant, - modifier = - Modifier - .padding(horizontal = 16.dp) - .weight(1f), - ) - Icon( - Icons.Default.Search, - contentDescription = null, - tint = colors.onSurfaceVariant, - ) - } - } + var searchQuery by remember { mutableStateOf("") } + AppSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.padding(vertical = 16.dp), + placeholderText = "Search notes", + ) } From 57577cea4574cca64e95db4dccccdb8c5e738acf Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Tue, 12 May 2026 00:07:54 +0300 Subject: [PATCH 02/35] fix: ktlintcheck --- .../itlab/notes/ui/notes/AppSearchField.kt | 47 ++-- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 222 ++++++++---------- .../com/itlab/notes/ui/notes/NotesScreen.kt | 8 +- 3 files changed, 133 insertions(+), 144 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt index 1e8e10d1..4318fb30 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @Composable -fun AppSearchField( +fun appSearchField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, @@ -43,18 +43,20 @@ fun AppSearchField( val interactionSource = remember { MutableInteractionSource() } Box( - modifier = modifier - .fillMaxWidth() - .height(56.dp), + modifier = + modifier + .fillMaxWidth() + .height(56.dp), contentAlignment = Alignment.Center, ) { BasicTextField( value = value, onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .onFocusChanged { isFocused = it.isFocused }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp) + .onFocusChanged { isFocused = it.isFocused }, singleLine = true, interactionSource = interactionSource, textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), @@ -78,8 +80,16 @@ fun AppSearchField( Icon( imageVector = Icons.Default.Search, contentDescription = null, - tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp).size(25.dp), + tint = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + Modifier + .padding(start = 24.dp) + .size(25.dp), ) }, trailingIcon = { @@ -100,14 +110,15 @@ fun AppSearchField( } }, shape = CircleShape, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - cursorColor = MaterialTheme.colorScheme.primary, - ), + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + cursorColor = MaterialTheme.colorScheme.primary, + ), contentPadding = PaddingValues(start = 24.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), ) }, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index e81c4aba..67d9febd 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -22,15 +22,12 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTimeFilled import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderCopy -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stars import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.rounded.Edit @@ -63,13 +60,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties @@ -78,7 +72,7 @@ private const val RECENT_DIRECTORY_ID = "recent" @OptIn(ExperimentalMaterial3Api::class) @Composable fun directoriesScreen( - directories: List = previewDirectoriesFallback(), + directories: List, onCreateDirectory: (String) -> Unit, onDeleteDirectory: (DirectoryItemUi) -> Unit, onRenameDirectory: (DirectoryItemUi, String) -> Unit, @@ -116,66 +110,61 @@ fun directoriesScreen( if (showCreateDialog) { var directoryName by remember { mutableStateOf("") } val onDismiss = { showCreateDialog = false } - UniversalBasicAlertDialog( - onDismissRequest = onDismiss, - icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - Icon( - Icons.Rounded.Folder, - modifier = Modifier + universalBasicAlertDialog(onDismissRequest = onDismiss, icon = { + Box( + Modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Icon( + Icons.Rounded.Folder, + modifier = + Modifier .padding(all = 14.dp) .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - title = { - Text( - text = "Create Directory", - fontWeight = FontWeight.W400, - ) - }, - input = { - DirectoryOutlinedTextField( - value = directoryName, - onValueChange = { directoryName = it }, - placeholderText = "Enter directory name...", + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - actions = { - TextButton( - onClick = onDismiss, - contentPadding = PaddingValues(horizontal = 12.dp) - ) { - Text("Cancel") - } + } + }, title = { + Text( + text = "Create Directory", + fontWeight = FontWeight.W400, + ) + }, input = { + directoryOutlinedTextField( + value = directoryName, + onValueChange = { directoryName = it }, + placeholderText = "Enter directory name...", + ) + }, actions = { + TextButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text("Cancel") + } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) - Button( - onClick = { - onCreateDirectory(directoryName) - onDismiss() - }, - enabled = directoryName.trim().isNotEmpty(), - contentPadding = PaddingValues(horizontal = 16.dp), - shape = MaterialTheme.shapes.medium - ) { - Text("Create") - } + Button( + onClick = { + onCreateDirectory(directoryName) + onDismiss() + }, + enabled = directoryName.trim().isNotEmpty(), + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + ) { + Text("Create") } - ) + }) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun UniversalBasicAlertDialog( +private fun universalBasicAlertDialog( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), @@ -186,9 +175,10 @@ private fun UniversalBasicAlertDialog( ) { BasicAlertDialog( onDismissRequest = onDismissRequest, - modifier = modifier - .fillMaxWidth(0.87f) - .sizeIn(maxWidth = 560.dp), + modifier = + modifier + .fillMaxWidth(0.87f) + .sizeIn(maxWidth = 560.dp), properties = properties, ) { val dialogFocusManager = LocalFocusManager.current @@ -197,19 +187,20 @@ private fun UniversalBasicAlertDialog( ) { Box { Box( - modifier = Modifier - .matchParentSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - dialogFocusManager.clearFocus(force = true) - }, + modifier = + Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + dialogFocusManager.clearFocus(force = true) + }, ) Column( modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, ) { icon() Spacer(Modifier.height(10.dp)) @@ -226,7 +217,7 @@ private fun UniversalBasicAlertDialog( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { actions() } @@ -289,10 +280,9 @@ private fun directoriesList( focusManager.clearFocus(force = true) }, ) - } - .padding(horizontal = 12.dp), + }.padding(horizontal = 12.dp), ) { - DirectorySearchBar() + directorySearchBar() LazyColumn( modifier = Modifier.weight(1f, fill = false), contentPadding = PaddingValues(bottom = 12.dp), @@ -380,9 +370,10 @@ private fun directoriesHeroPanel( modifier = Modifier.padding(top = 10.dp, bottom = 6.dp), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 14.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -457,12 +448,12 @@ private fun directoriesBlock( } @Composable -fun DirectorySearchBar( +fun directorySearchBar( modifier: Modifier = Modifier, - onQueryChange: (String) -> Unit = {} + onQueryChange: (String) -> Unit = {}, ) { var searchQuery by remember { mutableStateOf("") } - AppSearchField( + appSearchField( value = searchQuery, onValueChange = { searchQuery = it @@ -494,8 +485,7 @@ private fun directoryRow( .combinedClickable( onClick = onClick, onLongClick = onLongClick, - ) - .padding(horizontal = 12.dp, vertical = 11.dp), + ).padding(horizontal = 12.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -564,22 +554,22 @@ private fun directoryActionsDialog( onRename: () -> Unit, onDismiss: () -> Unit, ) { - UniversalBasicAlertDialog( + universalBasicAlertDialog( onDismissRequest = onDismiss, - icon = { Box( Modifier .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer) + .background(MaterialTheme.colorScheme.surfaceContainer), ) { Icon( Icons.Rounded.Edit, - modifier = Modifier - .padding(all = 14.dp) - .size(32.dp), + modifier = + Modifier + .padding(all = 14.dp) + .size(32.dp), contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, @@ -587,8 +577,9 @@ private fun directoryActionsDialog( Text("Directory actions") }, input = { - Text("Choose action for \"${directory.name}\"", - style = MaterialTheme.typography.bodyLarge + Text( + "Choose action for \"${directory.name}\"", + style = MaterialTheme.typography.bodyLarge, ) }, actions = { @@ -621,21 +612,22 @@ private fun directoryRenameDialog( onDismiss: () -> Unit, ) { var renameName by remember(directory.id) { mutableStateOf(directory.name) } - UniversalBasicAlertDialog( + universalBasicAlertDialog( onDismissRequest = onDismiss, icon = { Box( Modifier .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer) + .background(MaterialTheme.colorScheme.surfaceContainer), ) { Icon( Icons.Rounded.Edit, - modifier = Modifier - .padding(all = 14.dp) - .size(32.dp), + modifier = + Modifier + .padding(all = 14.dp) + .size(32.dp), contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, @@ -643,7 +635,7 @@ private fun directoryRenameDialog( Text("Rename directory") }, input = { - DirectoryOutlinedTextField( + directoryOutlinedTextField( value = renameName, onValueChange = { renameName = it }, placeholderText = "Enter directory name...", @@ -669,7 +661,7 @@ private fun directoryRenameDialog( } @Composable -private fun DirectoryOutlinedTextField( +private fun directoryOutlinedTextField( value: String, onValueChange: (String) -> Unit, placeholderText: String, @@ -683,7 +675,7 @@ private fun DirectoryOutlinedTextField( Icon( imageVector = Icons.Rounded.TextFields, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, modifier = modifier.fillMaxWidth(), @@ -693,26 +685,18 @@ private fun DirectoryOutlinedTextField( suffix = { Icons.Default.TextFields }, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - disabledContainerColor = Color.Transparent, - focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedIndicatorColor = MaterialTheme.colorScheme.outline, - unfocusedIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - disabledIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - ) + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedIndicatorColor = MaterialTheme.colorScheme.outline, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + disabledIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + ), ) } - -private fun previewDirectoriesFallback(): List = - listOf( - DirectoryItemUi(id = "all", name = "All Notes", noteCount = 0), - DirectoryItemUi(id = "study", name = "My Study", noteCount = 0), - DirectoryItemUi(id = "cook", name = "How to Cook", noteCount = 0), - DirectoryItemUi(id = "poems", name = "My poems", noteCount = 0), - DirectoryItemUi(id = "guides", name = "Guides", noteCount = 0), - ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index ea7dfcfd..cb0c2055 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -4,21 +4,17 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -28,7 +24,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -37,7 +32,6 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -297,7 +291,7 @@ private fun noteCard( @Composable private fun searchField() { var searchQuery by remember { mutableStateOf("") } - AppSearchField( + appSearchField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier.padding(vertical = 16.dp), From 5c8822efbe8c89b4c9c2391de33d5e867ce16920 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Tue, 12 May 2026 20:56:34 +0300 Subject: [PATCH 03/35] fix: longMethod in app search field --- .../itlab/notes/ui/notes/AppSearchField.kt | 153 +++++++++++------- 1 file changed, 97 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt index 4318fb30..1a1dda58 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt @@ -41,6 +41,11 @@ fun appSearchField( var isFocused by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } + val onClearClick: () -> Unit = { + onValueChange("") + isFocused = false + focusManager.clearFocus(force = true) + } Box( modifier = @@ -62,66 +67,102 @@ fun appSearchField( textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorationBox = { innerTextField -> - TextFieldDefaults.DecorationBox( - value = value, + appSearchFieldDecorationBox( + input = + AppSearchFieldDecorationInput( + value = value, + isFocused = isFocused, + placeholderText = placeholderText, + onClearClick = onClearClick, + ), innerTextField = innerTextField, - enabled = true, - singleLine = true, - visualTransformation = VisualTransformation.None, interactionSource = interactionSource, - placeholder = { - Text( - placeholderText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - tint = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = - Modifier - .padding(start = 24.dp) - .size(25.dp), - ) - }, - trailingIcon = { - if (isFocused) { - IconButton( - onClick = { - onValueChange("") - isFocused = false - focusManager.clearFocus(force = true) - }, - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - shape = CircleShape, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - cursorColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = PaddingValues(start = 24.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), ) }, ) } } + +@Composable +private fun appSearchFieldDecorationBox( + input: AppSearchFieldDecorationInput, + innerTextField: @Composable () -> Unit, + interactionSource: MutableInteractionSource, +) { + TextFieldDefaults.DecorationBox( + value = input.value, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = { + Text( + input.placeholderText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingIcon = { + appSearchFieldLeadingIcon(isFocused = input.isFocused) + }, + trailingIcon = { + appSearchFieldTrailingIcon( + isFocused = input.isFocused, + onClearClick = input.onClearClick, + ) + }, + shape = CircleShape, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + cursorColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = PaddingValues(start = 24.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), + ) +} + +@Composable +private fun appSearchFieldLeadingIcon(isFocused: Boolean) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + Modifier + .padding(start = 24.dp) + .size(25.dp), + ) +} + +@Composable +private fun appSearchFieldTrailingIcon( + isFocused: Boolean, + onClearClick: () -> Unit, +) { + if (isFocused) { + IconButton(onClick = onClearClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private data class AppSearchFieldDecorationInput( + val value: String, + val isFocused: Boolean, + val placeholderText: String, + val onClearClick: () -> Unit, +) From 1efd85db1995e0431fba28d7803cce2f825279f2 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Tue, 12 May 2026 21:13:53 +0300 Subject: [PATCH 04/35] fix: long method in directories screen --- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 308 ++++++++++-------- 1 file changed, 169 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 67d9febd..f6f9e15e 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -108,70 +108,94 @@ fun directoriesScreen( ) } if (showCreateDialog) { - var directoryName by remember { mutableStateOf("") } - val onDismiss = { showCreateDialog = false } - universalBasicAlertDialog(onDismissRequest = onDismiss, icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - Icon( - Icons.Rounded.Folder, - modifier = + directoriesCreateDirectoryDialog( + onDismissRequest = { showCreateDialog = false }, + onCreateDirectory = onCreateDirectory, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun directoriesCreateDirectoryDialog( + onDismissRequest: () -> Unit, + onCreateDirectory: (String) -> Unit, +) { + var directoryName by remember { mutableStateOf("") } + universalBasicAlertDialog( + onDismissRequest = onDismissRequest, + slots = + UniversalBasicAlertDialogSlots( + icon = { + Box( Modifier - .padding(all = 14.dp) - .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, title = { - Text( - text = "Create Directory", - fontWeight = FontWeight.W400, - ) - }, input = { - directoryOutlinedTextField( - value = directoryName, - onValueChange = { directoryName = it }, - placeholderText = "Enter directory name...", - ) - }, actions = { - TextButton( - onClick = onDismiss, - contentPadding = PaddingValues(horizontal = 12.dp), - ) { - Text("Cancel") - } + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Icon( + Icons.Rounded.Folder, + modifier = + Modifier + .padding(all = 14.dp) + .size(32.dp), + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + title = { + Text( + text = "Create Directory", + fontWeight = FontWeight.W400, + ) + }, + input = { + directoryOutlinedTextField( + value = directoryName, + onValueChange = { directoryName = it }, + placeholderText = "Enter directory name...", + ) + }, + actions = { + TextButton( + onClick = onDismissRequest, + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text("Cancel") + } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) - Button( - onClick = { - onCreateDirectory(directoryName) - onDismiss() + Button( + onClick = { + onCreateDirectory(directoryName) + onDismissRequest() + }, + enabled = directoryName.trim().isNotEmpty(), + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + ) { + Text("Create") + } }, - enabled = directoryName.trim().isNotEmpty(), - contentPadding = PaddingValues(horizontal = 16.dp), - shape = MaterialTheme.shapes.medium, - ) { - Text("Create") - } - }) - } + ), + ) } +private data class UniversalBasicAlertDialogSlots( + val icon: @Composable () -> Unit, + val title: @Composable () -> Unit, + val input: @Composable () -> Unit, + val actions: @Composable RowScope.() -> Unit, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun universalBasicAlertDialog( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), - icon: @Composable () -> Unit, - title: @Composable () -> Unit, - input: @Composable () -> Unit, - actions: @Composable RowScope.() -> Unit, + slots: UniversalBasicAlertDialogSlots, ) { BasicAlertDialog( onDismissRequest = onDismissRequest, @@ -202,24 +226,24 @@ private fun universalBasicAlertDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, ) { - icon() + slots.icon() Spacer(Modifier.height(10.dp)) CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface, ) { ProvideTextStyle(MaterialTheme.typography.headlineMedium) { - title() + slots.title() } } Spacer(Modifier.height(14.dp)) - input() + slots.input() Spacer(Modifier.height(10.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { - actions() + slots.actions(this) } } } @@ -556,52 +580,55 @@ private fun directoryActionsDialog( ) { universalBasicAlertDialog( onDismissRequest = onDismiss, - icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - Icon( - Icons.Rounded.Edit, - modifier = + slots = + UniversalBasicAlertDialogSlots( + icon = { + Box( Modifier - .padding(all = 14.dp) - .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - title = { - Text("Directory actions") - }, - input = { - Text( - "Choose action for \"${directory.name}\"", - style = MaterialTheme.typography.bodyLarge, - ) - }, - actions = { - TextButton(onClick = onRename) { - Text("Rename") - } + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Icon( + Icons.Rounded.Edit, + modifier = + Modifier + .padding(all = 14.dp) + .size(32.dp), + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + title = { + Text("Directory actions") + }, + input = { + Text( + "Choose action for \"${directory.name}\"", + style = MaterialTheme.typography.bodyLarge, + ) + }, + actions = { + TextButton(onClick = onRename) { + Text("Rename") + } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) - Button( - onClick = onDelete, - contentPadding = PaddingValues(horizontal = 16.dp), - shape = MaterialTheme.shapes.medium, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text("Delete") - } - }, + Button( + onClick = onDelete, + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Delete") + } + }, + ), ) } @@ -614,49 +641,52 @@ private fun directoryRenameDialog( var renameName by remember(directory.id) { mutableStateOf(directory.name) } universalBasicAlertDialog( onDismissRequest = onDismiss, - icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - Icon( - Icons.Rounded.Edit, - modifier = + slots = + UniversalBasicAlertDialogSlots( + icon = { + Box( Modifier - .padding(all = 14.dp) - .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - title = { - Text("Rename directory") - }, - input = { - directoryOutlinedTextField( - value = renameName, - onValueChange = { renameName = it }, - placeholderText = "Enter directory name...", - ) - }, - actions = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Icon( + Icons.Rounded.Edit, + modifier = + Modifier + .padding(all = 14.dp) + .size(32.dp), + contentDescription = "Folder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + title = { + Text("Rename directory") + }, + input = { + directoryOutlinedTextField( + value = renameName, + onValueChange = { renameName = it }, + placeholderText = "Enter directory name...", + ) + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) - Button( - onClick = { onSave(renameName) }, - enabled = renameName.trim().isNotEmpty() && renameName != directory.name, - contentPadding = PaddingValues(horizontal = 16.dp), - shape = MaterialTheme.shapes.medium, - ) { - Text("Save") - } - }, + Button( + onClick = { onSave(renameName) }, + enabled = renameName.trim().isNotEmpty() && renameName != directory.name, + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + ) { + Text("Save") + } + }, + ), ) } From e77ca7f01fd2d08be43a72b0743c4a6f3f37f722 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Tue, 12 May 2026 21:32:14 +0300 Subject: [PATCH 05/35] fix: edit directories screen --- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 116 ++++++++---------- 1 file changed, 54 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index f6f9e15e..5617dec6 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package com.itlab.notes.ui.notes import androidx.compose.foundation.ExperimentalFoundationApi @@ -21,6 +23,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTimeFilled @@ -60,6 +63,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager @@ -277,6 +281,13 @@ private fun directoriesTopBar(onAddDirectoryClick: () -> Unit) { ) } +private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = + pointerInput(Unit) { + detectTapGestures( + onTap = { focusManager.clearFocus(force = true) }, + ) + } + @Composable private fun directoriesList( directories: List, @@ -285,99 +296,80 @@ private fun directoriesList( onDirectoryClick: (DirectoryItemUi) -> Unit, modifier: Modifier = Modifier, ) { - var directoryPendingDelete by remember { mutableStateOf(null) } - var directoryPendingRename by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } + var pendingRename by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current - val favoriteDirectoryIds = setOf("all", "study", "cook") - val favoriteDirectories = directories.filter { it.id in favoriteDirectoryIds } - val regularDirectories = directories.filterNot { it.id in favoriteDirectoryIds } - val regularCount = directories.count { it.id != "all" } - val totalNotesCount = directories.firstOrNull { it.id == "all" }?.noteCount ?: directories.sumOf { it.noteCount } - val recentDirectory = DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = totalNotesCount) + + val sectionData = + remember(directories) { + val favIds = setOf("all", "study", "cook") + val total = + directories.firstOrNull { it.id == "all" }?.noteCount ?: directories.sumOf { it.noteCount } + val favs = directories.filter { it.id in favIds } + val regs = directories.filterNot { it.id in favIds } + val recent = DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = total) + Triple(favs, regs, recent) + } + Column( - modifier = - modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onTap = { - focusManager.clearFocus(force = true) - }, - ) - }.padding(horizontal = 12.dp), + modifier = modifier.fillMaxWidth().clearFocusOnTap(focusManager).padding(horizontal = 12.dp), ) { directorySearchBar() LazyColumn( modifier = Modifier.weight(1f, fill = false), contentPadding = PaddingValues(bottom = 12.dp), ) { - item { - directoriesHeroPanel( - directoriesCount = regularCount, - totalNotesCount = totalNotesCount, - ) - } - item { - sectionTitle(title = "Continue working") - } - item { - directoriesBlock( - directories = listOf(recentDirectory), - isRegularDirectoriesBlock = true, - onDirectoryClick = onDirectoryClick, - onDirectoryLongClick = { directoryPendingDelete = it }, - ) - } - if (favoriteDirectories.isNotEmpty()) { - item { - sectionTitle(title = "Favorite directories") - } + fun LazyListScope.addSection( + title: String, + dirs: List, + isRegularBlock: Boolean, + ) { + if (dirs.isEmpty()) return + item { sectionTitle(title = title) } item { directoriesBlock( - directories = favoriteDirectories, - isRegularDirectoriesBlock = false, + directories = dirs, + isRegularDirectoriesBlock = isRegularBlock, onDirectoryClick = onDirectoryClick, - onDirectoryLongClick = { directoryPendingDelete = it }, + onDirectoryLongClick = { pendingDelete = it }, ) } } - if (regularDirectories.isNotEmpty()) { - item { - sectionTitle(title = "Regular directories") - } - item { - directoriesBlock( - directories = regularDirectories, - isRegularDirectoriesBlock = true, - onDirectoryClick = onDirectoryClick, - onDirectoryLongClick = { directoryPendingDelete = it }, - ) - } + + item { + directoriesHeroPanel( + directoriesCount = directories.count { it.id != "all" }, + totalNotesCount = sectionData.third.noteCount, + ) } + addSection("Continue working", listOf(sectionData.third), true) + addSection("Favorite directories", sectionData.first, false) + addSection("Regular directories", sectionData.second, true) } } - directoryPendingDelete?.let { dir -> + + pendingDelete?.let { dir -> directoryActionsDialog( directory = dir, onDelete = { onDirectoryLongClick(dir) - directoryPendingDelete = null + pendingDelete = null }, onRename = { - directoryPendingDelete = null - directoryPendingRename = dir + pendingDelete = null + pendingRename = dir }, - onDismiss = { directoryPendingDelete = null }, + onDismiss = { pendingDelete = null }, ) } - directoryPendingRename?.let { dir -> + pendingRename?.let { dir -> directoryRenameDialog( directory = dir, onSave = { newName -> onDirectoryRename(dir, newName) - directoryPendingRename = null + pendingRename = null }, - onDismiss = { directoryPendingRename = null }, + onDismiss = { pendingRename = null }, ) } } From e07b38cc1a08d8f3fb260ccd5692fc8b1ce26c8f Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Tue, 12 May 2026 21:47:36 +0300 Subject: [PATCH 06/35] fix: CI --- .../java/com/itlab/notes/ui/notes/AppSearchField.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt index 1a1dda58..a690bbf2 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt @@ -66,7 +66,7 @@ fun appSearchField( interactionSource = interactionSource, textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - decorationBox = { innerTextField -> + decorationBox = { content -> appSearchFieldDecorationBox( input = AppSearchFieldDecorationInput( @@ -75,9 +75,10 @@ fun appSearchField( placeholderText = placeholderText, onClearClick = onClearClick, ), - innerTextField = innerTextField, interactionSource = interactionSource, - ) + ) { + content() + } }, ) } @@ -86,12 +87,12 @@ fun appSearchField( @Composable private fun appSearchFieldDecorationBox( input: AppSearchFieldDecorationInput, - innerTextField: @Composable () -> Unit, interactionSource: MutableInteractionSource, + content: @Composable () -> Unit, ) { TextFieldDefaults.DecorationBox( value = input.value, - innerTextField = innerTextField, + innerTextField = content, enabled = true, singleLine = true, visualTransformation = VisualTransformation.None, From 24c0085dddcb146fb052267f7ba4c4e5413f5f4c Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Tue, 12 May 2026 23:06:19 +0300 Subject: [PATCH 07/35] style: change color when note is selected --- app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index cb0c2055..9e0839d9 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -269,7 +269,12 @@ private fun noteCard( Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) { Text( text = note.title, - color = colors.onSurface, + color = + if (isSelected) { + colors.onPrimaryContainer + } else { + colors.onSurface + }, style = MaterialTheme.typography.titleMedium, ) Spacer(Modifier.height(8.dp)) From d2dcd2c3d2eb2f3c699b7b5fd83da79527eb0e08 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Wed, 13 May 2026 12:19:42 +0300 Subject: [PATCH 08/35] style: some improvments --- .../main/java/com/itlab/notes/ui/NotesApp.kt | 1 + .../itlab/notes/ui/notes/DirectoriesScreen.kt | 87 +++------ .../com/itlab/notes/ui/notes/NotesScreen.kt | 180 ++++++++++++++++-- 3 files changed, 199 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index f6922d0a..4ea30e3b 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -35,6 +35,7 @@ fun notesApp() { notesListScreen( directoryName = screen.directory.name, notes = state.notes, + directories = state.directories, actions = NotesListActions( onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectories) }, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 5617dec6..597f4412 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -65,6 +65,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight @@ -130,23 +131,9 @@ private fun directoriesCreateDirectoryDialog( onDismissRequest = onDismissRequest, slots = UniversalBasicAlertDialogSlots( - icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - Icon( - Icons.Rounded.Folder, - modifier = - Modifier - .padding(all = 14.dp) - .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, + icon = Icons.Rounded.Folder, + iconContainerColor = MaterialTheme.colorScheme.surfaceContainer, + iconTintColor = MaterialTheme.colorScheme.onSurfaceVariant, title = { Text( text = "Create Directory", @@ -186,8 +173,10 @@ private fun directoriesCreateDirectoryDialog( ) } -private data class UniversalBasicAlertDialogSlots( - val icon: @Composable () -> Unit, +internal data class UniversalBasicAlertDialogSlots( + val icon: ImageVector, + val iconContainerColor: Color, + val iconTintColor: Color, val title: @Composable () -> Unit, val input: @Composable () -> Unit, val actions: @Composable RowScope.() -> Unit, @@ -195,7 +184,7 @@ private data class UniversalBasicAlertDialogSlots( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun universalBasicAlertDialog( +internal fun universalBasicAlertDialog( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), @@ -230,7 +219,21 @@ private fun universalBasicAlertDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, ) { - slots.icon() + Box( + Modifier + .clip(MaterialTheme.shapes.medium) + .background(slots.iconContainerColor), + ) { + Icon( + slots.icon, + modifier = + Modifier + .padding(all = 14.dp) + .size(30.dp), + contentDescription = null, + tint = slots.iconTintColor, + ) + } Spacer(Modifier.height(10.dp)) CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface, @@ -302,7 +305,7 @@ private fun directoriesList( val sectionData = remember(directories) { - val favIds = setOf("all", "study", "cook") + val favIds = setOf("all") val total = directories.firstOrNull { it.id == "all" }?.noteCount ?: directories.sumOf { it.noteCount } val favs = directories.filter { it.id in favIds } @@ -574,23 +577,9 @@ private fun directoryActionsDialog( onDismissRequest = onDismiss, slots = UniversalBasicAlertDialogSlots( - icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - Icon( - Icons.Rounded.Edit, - modifier = - Modifier - .padding(all = 14.dp) - .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, + icon = Icons.Rounded.Edit, + iconContainerColor = MaterialTheme.colorScheme.surfaceContainer, + iconTintColor = MaterialTheme.colorScheme.onSurfaceVariant, title = { Text("Directory actions") }, @@ -635,23 +624,9 @@ private fun directoryRenameDialog( onDismissRequest = onDismiss, slots = UniversalBasicAlertDialogSlots( - icon = { - Box( - Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) { - Icon( - Icons.Rounded.Edit, - modifier = - Modifier - .padding(all = 14.dp) - .size(32.dp), - contentDescription = "Folder", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, + icon = Icons.Rounded.Edit, + iconContainerColor = MaterialTheme.colorScheme.surfaceContainer, + iconTintColor = MaterialTheme.colorScheme.onSurfaceVariant, title = { Text("Rename directory") }, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 9e0839d9..aa380776 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -3,18 +3,28 @@ package com.itlab.notes.ui.notes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.CompareArrows import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CompareArrows import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FolderCopy +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -25,6 +35,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,6 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @@ -42,18 +54,25 @@ import androidx.compose.ui.unit.dp fun notesListScreen( directoryName: String, notes: List, + directories: List, actions: NotesListActions, ) { val colors = MaterialTheme.colorScheme val selectedNoteIds = remember { mutableStateListOf() } + var showMoveDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } val isSelectionMode = selectedNoteIds.isNotEmpty() val selectedCount = selectedNoteIds.size - val clearSelection = { selectedNoteIds.clear() } + val clearSelection = { + selectedNoteIds.clear() + showMoveDialog = false + showDeleteDialog = false + } val deleteSelected = { notes.filter { it.id in selectedNoteIds }.forEach { note -> actions.onNoteDelete(note) } - selectedNoteIds.clear() + clearSelection() } val handleBack = { if (isSelectionMode) { @@ -70,7 +89,8 @@ fun notesListScreen( directoryName = directoryName, selectedCount = selectedCount, onBack = handleBack, - onDeleteSelected = deleteSelected, + onMoveSelected = { showMoveDialog = true }, + onDeleteSelected = { showDeleteDialog = true }, ) }, floatingActionButton = { @@ -79,16 +99,36 @@ fun notesListScreen( } }, ) { paddingValues -> - notesListContent( - notes = notes, - paddingValues = paddingValues, - selectedNoteIds = selectedNoteIds, - actions = - NotesListContentActions( - onNoteDelete = actions.onNoteDelete, - onNoteClick = actions.onNoteClick, - ), - ) + Box(Modifier.fillMaxSize()) { + notesListContent( + notes = notes, + paddingValues = paddingValues, + selectedNoteIds = selectedNoteIds, + actions = + NotesListContentActions( + onNoteDelete = actions.onNoteDelete, + onNoteClick = actions.onNoteClick, + ), + ) + if (showMoveDialog && selectedNoteIds.isNotEmpty()) { + notesMoveNotesDialog( + directories = directories, + onDismissRequest = { showMoveDialog = false }, + onFolderChosen = { folderId -> + selectedNoteIds.forEach { noteId -> actions.onNoteMove(noteId, folderId) } + selectedNoteIds.clear() + showMoveDialog = false + }, + ) + } + if (showDeleteDialog && selectedNoteIds.isNotEmpty()) { + notesDeleteConfirmationDialog( + selectedCount = selectedCount, + onDismissRequest = { showDeleteDialog = false }, + onConfirmDelete = deleteSelected, + ) + } + } } } @@ -111,6 +151,7 @@ private fun notesTopBar( directoryName: String, selectedCount: Int, onBack: () -> Unit, + onMoveSelected: () -> Unit, onDeleteSelected: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -137,6 +178,13 @@ private fun notesTopBar( }, actions = { if (selectedCount > 0) { + IconButton(onClick = onMoveSelected) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CompareArrows, + contentDescription = null, + tint = colors.onSurface, + ) + } IconButton(onClick = onDeleteSelected) { Icon( imageVector = Icons.Default.Delete, @@ -157,6 +205,112 @@ private fun notesTopBar( ) } +@Composable +private fun notesMoveNotesDialog( + directories: List, + onDismissRequest: () -> Unit, + onFolderChosen: (String) -> Unit, +) { + val moveTargets = remember(directories) { directories.filter { it.id != "all" } } + universalBasicAlertDialog( + onDismissRequest = onDismissRequest, + slots = + UniversalBasicAlertDialogSlots( + icon = Icons.AutoMirrored.Filled.CompareArrows, + iconContainerColor = MaterialTheme.colorScheme.surfaceContainer, + iconTintColor = MaterialTheme.colorScheme.onSurfaceVariant, + title = { + Text( + text = "Move to folder", + fontWeight = FontWeight.W400, + ) + }, + input = { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items( + items = moveTargets, + key = { it.id }, + ) { dir -> + TextButton( + onClick = { onFolderChosen(dir.id) }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + shape = MaterialTheme.shapes.medium, + ) { + Text( + text = dir.name, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + }, + actions = { + TextButton( + onClick = onDismissRequest, + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text("Cancel") + } + }, + ), + ) +} + +@Composable +private fun notesDeleteConfirmationDialog( + selectedCount: Int, + onDismissRequest: () -> Unit, + onConfirmDelete: () -> Unit, +) { + universalBasicAlertDialog( + onDismissRequest = onDismissRequest, + slots = + UniversalBasicAlertDialogSlots( + icon = Icons.Default.Delete, + iconContainerColor = MaterialTheme.colorScheme.errorContainer, + iconTintColor = MaterialTheme.colorScheme.onErrorContainer, + title = { + Text( + text = "Delete selected notes?", + ) + }, + input = { + Text( + text = "This will permanently delete $selectedCount note(s).", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + }, + actions = { + TextButton( + onClick = onDismissRequest, + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text("Cancel") + } + Button( + onClick = onConfirmDelete, + contentPadding = PaddingValues(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Delete") + } + }, + ), + ) +} + @Composable private fun notesFab(onAddNoteClick: () -> Unit) { val colors = MaterialTheme.colorScheme From c9b581ebcaf36015c01c245c1227bc068c02e27a Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Wed, 13 May 2026 13:19:34 +0300 Subject: [PATCH 09/35] style: add empty state to directories screen --- .../main/java/com/itlab/notes/ui/NotesApp.kt | 1 + .../java/com/itlab/notes/ui/NotesViewModel.kt | 29 ++-- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 82 ++++++++++- .../com/itlab/notes/ui/notes/NotesScreen.kt | 131 ++++++++++++++---- 4 files changed, 202 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 4ea30e3b..64df6acc 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -33,6 +33,7 @@ fun notesApp() { is NotesUiScreen.DirectoryNotes -> { notesListScreen( + directoryId = screen.directory.id, directoryName = screen.directory.name, notes = state.notes, directories = state.directories, diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index bc25d8ce..8bd681d0 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -11,8 +11,11 @@ import com.itlab.domain.model.NoteFolder import com.itlab.notes.ui.notes.DirectoryItemUi import com.itlab.notes.ui.notes.NoteItemUi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +private const val RECENT_DIRECTORY_ID = "recent" + class NotesViewModel( private val useCases: NotesUseCases, ) : ViewModel(), @@ -58,7 +61,7 @@ class NotesViewModel( is NotesUiEvent.RenameDirectory -> renameDirectory(event) is NotesUiEvent.DeleteDirectory -> deleteDirectory(event.directoryId) is NotesUiEvent.MoveNoteToDirectory -> { - if (event.targetDirectoryId == "all") return + if (event.targetDirectoryId == "all" || event.targetDirectoryId == RECENT_DIRECTORY_ID) return viewModelScope.launch { useCases.moveNoteToFolderUseCase( folderId = event.targetDirectoryId, @@ -78,7 +81,7 @@ class NotesViewModel( private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { val normalized = event.newName.trim() - if (normalized.isBlank() || event.directoryId == "all") return + if (normalized.isBlank() || event.directoryId == "all" || event.directoryId == RECENT_DIRECTORY_ID) return viewModelScope.launch { val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch useCases.updateFolderUseCase(existingFolder.copy(name = normalized)) @@ -86,7 +89,7 @@ class NotesViewModel( } private fun deleteDirectory(directoryId: String) { - if (directoryId == "all") return + if (directoryId == "all" || directoryId == RECENT_DIRECTORY_ID) return viewModelScope.launch { useCases.deleteFolderUseCase(directoryId) if ((uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory?.id == directoryId) { @@ -102,14 +105,16 @@ class NotesViewModel( notes = emptyList(), ) notesJob?.cancel() - val isAll = directory.id == "all" notesJob = viewModelScope.launch { val flow = - if (isAll) { - useCases.observeNotesUseCase() - } else { - useCases.observeNotesByFolderUseCase(directory.id) + when (directory.id) { + "all" -> useCases.observeNotesUseCase() + RECENT_DIRECTORY_ID -> + useCases.observeNotesUseCase().map { notes -> + notes.sortedByDescending { it.updatedAt } + } + else -> useCases.observeNotesByFolderUseCase(directory.id) } flow.collect { notes -> @@ -254,4 +259,10 @@ internal fun Note.applyUiUpdate( ) } -internal fun String.asDomainFolderId(): String? = if (this == "all") null else this +internal fun String.asDomainFolderId(): String? = + when (this) { + "all", + RECENT_DIRECTORY_ID, + -> null + else -> this + } diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 597f4412..2447ad16 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn @@ -69,7 +70,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties private const val RECENT_DIRECTORY_ID = "recent" @@ -142,6 +145,7 @@ private fun directoriesCreateDirectoryDialog( }, input = { directoryOutlinedTextField( + modifier = Modifier.padding(top = 5.dp), value = directoryName, onValueChange = { directoryName = it }, placeholderText = "Enter directory name...", @@ -242,7 +246,7 @@ internal fun universalBasicAlertDialog( slots.title() } } - Spacer(Modifier.height(14.dp)) + Spacer(Modifier.height(5.dp)) slots.input() Spacer(Modifier.height(10.dp)) Row( @@ -313,13 +317,15 @@ private fun directoriesList( val recent = DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = total) Triple(favs, regs, recent) } + val totalNotesCount = sectionData.third.noteCount + val allNotesDirectory = remember(directories) { directories.firstOrNull { it.id == "all" } } Column( - modifier = modifier.fillMaxWidth().clearFocusOnTap(focusManager).padding(horizontal = 12.dp), + modifier = modifier.fillMaxSize().clearFocusOnTap(focusManager).padding(horizontal = 12.dp), ) { directorySearchBar() LazyColumn( - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier.weight(1f), contentPadding = PaddingValues(bottom = 12.dp), ) { fun LazyListScope.addSection( @@ -342,12 +348,32 @@ private fun directoriesList( item { directoriesHeroPanel( directoriesCount = directories.count { it.id != "all" }, - totalNotesCount = sectionData.third.noteCount, + totalNotesCount = totalNotesCount, ) } addSection("Continue working", listOf(sectionData.third), true) addSection("Favorite directories", sectionData.first, false) addSection("Regular directories", sectionData.second, true) + + if (sectionData.second.isEmpty()) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 80.dp) + .heightIn(min = 220.dp), + contentAlignment = Alignment.Center, + ) { + directoriesEmptyPlaceholder( + onOpenAllNotes = { + allNotesDirectory?.let { onDirectoryClick(it) } + }, + openAllNotesEnabled = allNotesDirectory != null, + ) + } + } + } } } @@ -418,6 +444,48 @@ private fun directoriesHeroPanel( } } +@Composable +private fun directoriesEmptyPlaceholder( + onOpenAllNotes: () -> Unit, + openAllNotesEnabled: Boolean, +) { + val colors = MaterialTheme.colorScheme + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .clip(MaterialTheme.shapes.medium) + .background(colors.surfaceContainer), + ) { + Icon( + imageVector = Icons.Rounded.Folder, + contentDescription = null, + modifier = Modifier.padding(14.dp).size(32.dp), + tint = colors.onSurfaceVariant, + ) + } + Spacer(Modifier.height(16.dp)) + Text( + text = "No directories yet", + style = MaterialTheme.typography.titleMedium, + color = colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = + "Tap + to create a directory and organize your notes.", + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 50.dp), + ) + } +} + @Composable private fun sectionTitle(title: String) { Text( @@ -447,9 +515,7 @@ private fun directoriesBlock( directory = dir, isRegularDirectory = isRegularDirectoriesBlock, onClick = { - if (!isSpecialDirectory(dir.id)) { - onDirectoryClick(dir) - } + onDirectoryClick(dir) }, onLongClick = { if (!isSpecialDirectory(dir.id)) { @@ -587,6 +653,7 @@ private fun directoryActionsDialog( Text( "Choose action for \"${directory.name}\"", style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center ) }, actions = { @@ -632,6 +699,7 @@ private fun directoryRenameDialog( }, input = { directoryOutlinedTextField( + modifier = Modifier.padding(top = 5.dp), value = renameName, onValueChange = { renameName = it }, placeholderText = "Enter directory name...", diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index aa380776..2e81cb7d 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -1,11 +1,13 @@ package com.itlab.notes.ui.notes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -13,15 +15,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.CompareArrows import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CompareArrows import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderCopy import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -30,10 +34,12 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -43,6 +49,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -52,6 +59,7 @@ import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun notesListScreen( + directoryId: String, directoryName: String, notes: List, directories: List, @@ -112,6 +120,7 @@ fun notesListScreen( ) if (showMoveDialog && selectedNoteIds.isNotEmpty()) { notesMoveNotesDialog( + currentDirectoryId = directoryId, directories = directories, onDismissRequest = { showMoveDialog = false }, onFolderChosen = { folderId -> @@ -207,11 +216,16 @@ private fun notesTopBar( @Composable private fun notesMoveNotesDialog( + currentDirectoryId: String, directories: List, onDismissRequest: () -> Unit, onFolderChosen: (String) -> Unit, ) { - val moveTargets = remember(directories) { directories.filter { it.id != "all" } } + val moveTargets = + remember(directories, currentDirectoryId) { + directories.filter { it.id != "all" && it.id != currentDirectoryId } + } + val moveTargetsListState = rememberLazyListState() universalBasicAlertDialog( onDismissRequest = onDismissRequest, slots = @@ -226,29 +240,11 @@ private fun notesMoveNotesDialog( ) }, input = { - LazyColumn( - modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items( - items = moveTargets, - key = { it.id }, - ) { dir -> - TextButton( - onClick = { onFolderChosen(dir.id) }, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), - shape = MaterialTheme.shapes.medium, - ) { - Text( - text = dir.name, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } + notesMoveTargetsBlock( + directories = moveTargets, + listState = moveTargetsListState, + onFolderChosen = onFolderChosen, + ) }, actions = { TextButton( @@ -262,6 +258,91 @@ private fun notesMoveNotesDialog( ) } +@Composable +private fun notesMoveTargetsBlock( + directories: List, + listState: androidx.compose.foundation.lazy.LazyListState, + onFolderChosen: (String) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth().height(180.dp), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + ) { + items( + items = directories, + key = { it.id }, + ) { dir -> + notesMoveTargetRow( + directory = dir, + onClick = { onFolderChosen(dir.id) }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + ) + if (dir.id != directories.lastOrNull()?.id) { + notesMoveTargetsDivider() + } + } + } + } +} + +@Composable +private fun notesMoveTargetRow( + directory: DirectoryItemUi, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + Row( + modifier = + modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = null, + tint = colors.onSurfaceVariant, + modifier = Modifier.size(25.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = directory.name, + color = colors.onSurface, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun notesMoveTargetsDivider() { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .padding(horizontal = 10.dp) + .fillMaxWidth(0.9f), + contentAlignment = Alignment.Center, + ) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + thickness = 1.dp, + ) + } + } +} + @Composable private fun notesDeleteConfirmationDialog( selectedCount: Int, From 310754bb7785ec083e390e6140143a2107656c7d Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Wed, 13 May 2026 13:48:22 +0300 Subject: [PATCH 10/35] style: fix move to folder dialog --- .../com/itlab/notes/ui/notes/NotesScreen.kt | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 2e81cb7d..edc57f56 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -17,8 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.CompareArrows @@ -225,7 +226,6 @@ private fun notesMoveNotesDialog( remember(directories, currentDirectoryId) { directories.filter { it.id != "all" && it.id != currentDirectoryId } } - val moveTargetsListState = rememberLazyListState() universalBasicAlertDialog( onDismissRequest = onDismissRequest, slots = @@ -242,7 +242,6 @@ private fun notesMoveNotesDialog( input = { notesMoveTargetsBlock( directories = moveTargets, - listState = moveTargetsListState, onFolderChosen = onFolderChosen, ) }, @@ -261,29 +260,44 @@ private fun notesMoveNotesDialog( @Composable private fun notesMoveTargetsBlock( directories: List, - listState: androidx.compose.foundation.lazy.LazyListState, onFolderChosen: (String) -> Unit, ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.large, - modifier = Modifier.fillMaxWidth().height(180.dp), + modifier = Modifier.fillMaxWidth(), ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth(), - ) { - items( - items = directories, - key = { it.id }, - ) { dir -> - notesMoveTargetRow( - directory = dir, - onClick = { onFolderChosen(dir.id) }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + if (directories.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No other folders to move to", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (dir.id != directories.lastOrNull()?.id) { - notesMoveTargetsDivider() + } + } else { + Column( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .verticalScroll(rememberScrollState()), + ) { + directories.forEachIndexed { index, dir -> + notesMoveTargetRow( + directory = dir, + onClick = { onFolderChosen(dir.id) }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + ) + if (index < directories.lastIndex) { + notesMoveTargetsDivider() + } } } } From a8b3a823fb8100d208e51ac0c2271b6f04e94301 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Wed, 13 May 2026 18:01:17 +0300 Subject: [PATCH 11/35] style: change outline text field to basic text field --- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 98 +++++++++++++------ 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 2447ad16..2ec382d9 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTimeFilled import androidx.compose.material.icons.filled.Add @@ -33,7 +34,6 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderCopy import androidx.compose.material.icons.filled.Stars -import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.TextFields @@ -47,13 +47,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -66,10 +65,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -725,43 +726,82 @@ private fun directoryRenameDialog( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun directoryOutlinedTextField( value: String, onValueChange: (String) -> Unit, placeholderText: String, modifier: Modifier = Modifier, + enabled: Boolean = true, + isError: Boolean = false, ) { - OutlinedTextField( + val interactionSource = remember { MutableInteractionSource() } + val scheme = MaterialTheme.colorScheme + val shape = MaterialTheme.shapes.medium + val colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = scheme.surfaceContainer, + unfocusedContainerColor = scheme.surfaceContainer, + disabledContainerColor = Color.Transparent, + focusedTextColor = scheme.onSurfaceVariant, + unfocusedTextColor = scheme.onSurfaceVariant, + disabledTextColor = scheme.onSurfaceVariant.copy(alpha = 0.38f), + focusedBorderColor = scheme.outline, + unfocusedBorderColor = scheme.outline.copy(alpha = 0.5f), + disabledBorderColor = scheme.outline.copy(alpha = 0.5f), + cursorColor = scheme.primary, + errorBorderColor = scheme.error, + errorCursorColor = scheme.error, + ) + val textStyle = MaterialTheme.typography.bodyLarge.copy(color = scheme.onSurfaceVariant) + val contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) + + BasicTextField( value = value, onValueChange = onValueChange, - placeholder = { Text(placeholderText) }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.TextFields, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, modifier = modifier.fillMaxWidth(), + enabled = enabled, + textStyle = textStyle, singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - shape = MaterialTheme.shapes.medium, - suffix = { - Icons.Default.TextFields + cursorBrush = SolidColor(scheme.primary), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + enabled = enabled, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + isError = isError, + placeholder = { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + color = scheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.TextFields, + contentDescription = null, + tint = scheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + }, + colors = colors, + contentPadding = contentPadding, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + shape = shape, + ) + }, + ) }, - colors = - TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - disabledContainerColor = Color.Transparent, - focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedIndicatorColor = MaterialTheme.colorScheme.outline, - unfocusedIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - disabledIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - ), ) } From 6e6ac0f78ba26ecd7acacb61ef5433341d013cfb Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Thu, 14 May 2026 14:50:08 +0300 Subject: [PATCH 12/35] style: create folder verification --- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 2ec382d9..aa1097f2 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -118,6 +118,7 @@ fun directoriesScreen( } if (showCreateDialog) { directoriesCreateDirectoryDialog( + directories = directories, onDismissRequest = { showCreateDialog = false }, onCreateDirectory = onCreateDirectory, ) @@ -127,10 +128,18 @@ fun directoriesScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun directoriesCreateDirectoryDialog( + directories: List, onDismissRequest: () -> Unit, onCreateDirectory: (String) -> Unit, ) { var directoryName by remember { mutableStateOf("") } + val trimmedName = directoryName.trim() + val nameAlreadyExists = + trimmedName.isNotEmpty() && + directories.any { dir -> + !isSpecialDirectory(dir.id) && + dir.name.trim().equals(trimmedName, ignoreCase = true) + } universalBasicAlertDialog( onDismissRequest = onDismissRequest, slots = @@ -150,6 +159,13 @@ private fun directoriesCreateDirectoryDialog( value = directoryName, onValueChange = { directoryName = it }, placeholderText = "Enter directory name...", + isError = nameAlreadyExists, + errorMessage = + if (nameAlreadyExists) { + "Папка с таким названием уже существует" + } else { + null + }, ) }, actions = { @@ -167,7 +183,7 @@ private fun directoriesCreateDirectoryDialog( onCreateDirectory(directoryName) onDismissRequest() }, - enabled = directoryName.trim().isNotEmpty(), + enabled = trimmedName.isNotEmpty() && !nameAlreadyExists, contentPadding = PaddingValues(horizontal = 16.dp), shape = MaterialTheme.shapes.medium, ) { @@ -735,6 +751,7 @@ private fun directoryOutlinedTextField( modifier: Modifier = Modifier, enabled: Boolean = true, isError: Boolean = false, + errorMessage: String? = null, ) { val interactionSource = remember { MutableInteractionSource() } val scheme = MaterialTheme.colorScheme @@ -775,6 +792,18 @@ private fun directoryOutlinedTextField( visualTransformation = VisualTransformation.None, interactionSource = interactionSource, isError = isError, + supportingText = + if (isError && errorMessage != null) { + { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = scheme.error, + ) + } + } else { + null + }, placeholder = { Text( text = placeholderText, From 1ec3684920442fe4b779f307d97a91508fe24719 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Thu, 14 May 2026 17:22:31 +0300 Subject: [PATCH 13/35] style: add fullscreen preview --- app/build.gradle.kts | 1 + .../com/itlab/notes/media/NoteMediaImport.kt | 39 +++ .../java/com/itlab/notes/ui/NotesViewModel.kt | 36 +-- .../com/itlab/notes/ui/editor/EditorScreen.kt | 289 +++++++++++++++++- .../itlab/notes/ui/editor/EditorViewModel.kt | 15 + .../com/itlab/notes/ui/editor/_write_test.txt | 1 + .../itlab/notes/ui/notes/DirectoriesScreen.kt | 19 +- .../com/itlab/notes/ui/notes/NoteItemUi.kt | 3 + .../com/itlab/notes/ui/notes/NotesScreen.kt | 13 +- gradle/libs.versions.toml | 2 + 10 files changed, 393 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt create mode 100644 app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c8746f8..af9d5db6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.koin.android) implementation(libs.koin.androidx.compose) + implementation(libs.coil.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt new file mode 100644 index 00000000..d96df604 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt @@ -0,0 +1,39 @@ +package com.itlab.notes.media + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import com.itlab.domain.model.ContentItem +import com.itlab.domain.model.DataSource +import java.io.File +import java.util.UUID + +object NoteMediaImport { + private const val SUBDIR = "note_attachments" + + fun importImageFromUri(context: Context, uri: Uri): ContentItem.Image { + val appContext = context.applicationContext + val resolver = appContext.contentResolver + val mime = resolver.getType(uri) ?: "image/jpeg" + val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg" + val dir = File(appContext.filesDir, SUBDIR).apply { mkdirs() } + val file = File(dir, "${UUID.randomUUID()}.$ext") + resolver.openInputStream(uri)?.use { input -> + file.outputStream().use { out -> input.copyTo(out) } + } ?: error("Cannot read selected image") + return ContentItem.Image( + source = DataSource(localPath = file.absolutePath), + mimeType = mime, + ) + } + + fun deleteImportedFileIfOwned(context: Context, localPath: String?) { + val path = localPath ?: return + val file = File(path) + if (!file.exists()) return + val root = File(context.applicationContext.filesDir, SUBDIR).absolutePath + if (file.absolutePath.startsWith(root)) { + file.delete() + } + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 8bd681d0..53fa5852 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -152,10 +152,7 @@ class NotesViewModel( val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory if (dir != null) { val newNote = - Note( - folderId = dir.id.asDomainFolderId(), - userId = useCases.getUserIdUseCase() ?: "anonymous_user", - ).toUi() + Note(folderId = dir.id.asDomainFolderId()).toUi() uiState = uiState.copy( screen = NotesUiScreen.NoteEditor(directory = dir, note = newNote), @@ -173,13 +170,12 @@ class NotesViewModel( private fun saveNote(note: NoteItemUi) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { - val userId = useCases.getUserIdUseCase() ?: "anonymous_user" val targetFolderId = note.folderId ?: editor.directory.id.asDomainFolderId() val existing = latestNotes.firstOrNull { it.id == note.id } if (existing != null) { useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)) } else { - useCases.createNoteUseCase(note.toDomain(userId = userId, folderId = targetFolderId)) + useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) } uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) } @@ -228,36 +224,32 @@ internal fun Note.toUi(): NoteItemUi = .filterIsInstance() .joinToString("\n") { it.text }, folderId = folderId, + attachments = contentItems.filterNot { it is ContentItem.Text }, ) -internal fun NoteItemUi.toDomain( - userId: String, - folderId: String?, -): Note = +internal fun NoteItemUi.toContentItems(): List = + buildList { + if (content.isNotBlank()) add(ContentItem.Text(content)) + addAll(attachments) + } + +internal fun NoteItemUi.toDomain(folderId: String?): Note = Note( id = id, title = title, folderId = folderId, - contentItems = listOf(ContentItem.Text(text = content)), - userId = userId, + contentItems = toContentItems(), ) internal fun Note.applyUiUpdate( ui: NoteItemUi, targetFolderId: String?, -): Note { - val nonTextContent = contentItems.filterNot { it is ContentItem.Text } - val updatedText = - ui.content - .takeIf { it.isNotBlank() } - ?.let { ContentItem.Text(text = it) } - - return copy( +): Note = + copy( title = ui.title, folderId = targetFolderId, - contentItems = if (updatedText != null) nonTextContent + updatedText else nonTextContent, + contentItems = ui.toContentItems(), ) -} internal fun String.asDomainFolderId(): String? = when (this) { diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index aa826b2a..d19fb271 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -1,11 +1,29 @@ package com.itlab.notes.ui.editor +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -13,16 +31,31 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.itlab.domain.model.ContentItem +import com.itlab.domain.model.DataSource +import com.itlab.notes.media.NoteMediaImport import com.itlab.notes.ui.notes.NoteItemUi +import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -33,7 +66,18 @@ fun editorScreen( onSave: (NoteItemUi) -> Unit, ) { val colors = MaterialTheme.colorScheme + val context = LocalContext.current val editorVm = remember(note.id) { EditorViewModel(initialNote = note) } + var fullscreenImage by remember { mutableStateOf(null) } + + val pickImage = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { NoteMediaImport.importImageFromUri(context, uri) } + .onSuccess { editorVm.addAttachment(it) } + } Scaffold( containerColor = colors.background, @@ -42,6 +86,11 @@ fun editorScreen( directoryName = directoryName, title = editorVm.title, onBack = onBack, + onAddImage = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, ) }, floatingActionButton = { @@ -53,11 +102,26 @@ fun editorScreen( editorContent( title = editorVm.title, content = editorVm.content, + attachments = editorVm.attachments, onTitleChange = editorVm::onTitleChange, onContentChange = editorVm::onContentChange, + onImageClick = { fullscreenImage = it }, + onRemoveAttachment = { item -> + if (item is ContentItem.Image) { + NoteMediaImport.deleteImportedFileIfOwned(context, item.source.localPath) + } + editorVm.removeAttachment(item.id) + }, modifier = Modifier.padding(paddingValues), ) } + + fullscreenImage?.let { image -> + editorFullScreenImageViewer( + image = image, + onDismiss = { fullscreenImage = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -66,6 +130,7 @@ private fun editorTopBar( directoryName: String, title: String, onBack: () -> Unit, + onAddImage: () -> Unit, ) { val colors = MaterialTheme.colorScheme CenterAlignedTopAppBar( @@ -84,6 +149,15 @@ private fun editorTopBar( ) } }, + actions = { + IconButton(onClick = onAddImage) { + Icon( + Icons.Default.Image, + contentDescription = "Add image", + tint = colors.onSurface, + ) + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, @@ -114,11 +188,13 @@ private fun editorFab(onClick: () -> Unit) { private fun editorContent( title: String, content: String, + attachments: List, onTitleChange: (String) -> Unit, onContentChange: (String) -> Unit, + onImageClick: (ContentItem.Image) -> Unit, + onRemoveAttachment: (ContentItem) -> Unit, modifier: Modifier = Modifier, ) { - val colors = MaterialTheme.colorScheme Column( modifier = modifier @@ -135,9 +211,220 @@ private fun editorContent( onValueChange = onContentChange, modifier = Modifier.padding(top = 12.dp), ) + + if (attachments.isNotEmpty()) { + editorAttachmentsRow( + attachments = attachments, + onImageClick = onImageClick, + onRemove = onRemoveAttachment, + modifier = Modifier.padding(top = 12.dp), + ) + } + } +} + +@Composable +private fun editorAttachmentsRow( + attachments: List, + onImageClick: (ContentItem.Image) -> Unit, + onRemove: (ContentItem) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + items( + items = attachments, + key = { it.id }, + ) { item -> + when (item) { + is ContentItem.Image -> editorImageThumbnail(item, onImageClick, onRemove) + is ContentItem.File -> + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier + .height(88.dp) + .width(120.dp), + ) { + Box(Modifier.fillMaxSize()) { + Text( + text = item.name, + style = MaterialTheme.typography.labelSmall, + maxLines = 3, + modifier = + Modifier + .align(Alignment.Center) + .padding(horizontal = 8.dp, vertical = 20.dp), + ) + IconButton( + onClick = { onRemove(item) }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + } + is ContentItem.Link -> + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier + .height(88.dp) + .width(120.dp), + ) { + Box(Modifier.fillMaxSize()) { + Text( + text = item.title ?: item.url, + style = MaterialTheme.typography.labelSmall, + maxLines = 3, + modifier = + Modifier + .align(Alignment.Center) + .padding(horizontal = 8.dp, vertical = 20.dp), + ) + IconButton( + onClick = { onRemove(item) }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + } + is ContentItem.Text -> { } + } + } + } +} + +@Composable +private fun editorImageThumbnail( + image: ContentItem.Image, + onImageClick: (ContentItem.Image) -> Unit, + onRemove: (ContentItem) -> Unit, +) { + val context = LocalContext.current + val model = + remember(image.id, image.source.localPath, image.source.remoteUrl) { + imageDataForCoil(image.source) + } + Box { + Surface( + shape = RoundedCornerShape(8.dp), + tonalElevation = 1.dp, + modifier = + Modifier + .size(88.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onImageClick(image) }, + ) { + if (model != null) { + AsyncImage( + model = + ImageRequest.Builder(context) + .data(model) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } + IconButton( + onClick = { onRemove(image) }, + modifier = + Modifier + .align(Alignment.TopEnd) + .size(28.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } +@Composable +private fun editorFullScreenImageViewer( + image: ContentItem.Image, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val model = + remember(image.id, image.source.localPath, image.source.remoteUrl) { + imageDataForCoil(image.source) + } + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + ) { + if (model != null) { + AsyncImage( + model = + ImageRequest.Builder(context) + .data(model) + .crossfade(false) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 48.dp) + .align(Alignment.Center) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + ) + } + IconButton( + onClick = onDismiss, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = Color.White, + ) + } + } + } +} + +private fun imageDataForCoil(source: DataSource): Any? = + when { + !source.localPath.isNullOrBlank() -> File(source.localPath!!) + !source.remoteUrl.isNullOrBlank() -> source.remoteUrl!! + else -> null + } + @Composable private fun editorTitleField( value: String, diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt index 4e448ffb..a16b79bd 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt @@ -3,12 +3,14 @@ package com.itlab.notes.ui.editor import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.itlab.domain.model.ContentItem import com.itlab.notes.ui.notes.NoteItemUi class EditorViewModel( initialNote: NoteItemUi, ) { private val noteId: String = initialNote.id + private val folderId: String? = initialNote.folderId var title: String by mutableStateOf(initialNote.title) private set @@ -16,6 +18,9 @@ class EditorViewModel( var content: String by mutableStateOf(initialNote.content) private set + var attachments: List by mutableStateOf(initialNote.attachments) + private set + fun onTitleChange(newTitle: String) { title = newTitle } @@ -24,10 +29,20 @@ class EditorViewModel( content = newContent } + fun addAttachment(item: ContentItem) { + attachments = attachments + item + } + + fun removeAttachment(id: String) { + attachments = attachments.filterNot { it.id == id } + } + fun buildUpdatedNote(): NoteItemUi = NoteItemUi( id = noteId, title = title, content = content, + folderId = folderId, + attachments = attachments, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt b/app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt @@ -0,0 +1 @@ +test diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index aa1097f2..8f78a9f8 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -410,6 +410,7 @@ private fun directoriesList( } pendingRename?.let { dir -> directoryRenameDialog( + directories = directories, directory = dir, onSave = { newName -> onDirectoryRename(dir, newName) @@ -699,11 +700,20 @@ private fun directoryActionsDialog( @Composable private fun directoryRenameDialog( + directories: List, directory: DirectoryItemUi, onSave: (String) -> Unit, onDismiss: () -> Unit, ) { var renameName by remember(directory.id) { mutableStateOf(directory.name) } + val trimmedName = renameName.trim() + val nameAlreadyExists = + trimmedName.isNotEmpty() && + directories.any { dir -> + !isSpecialDirectory(dir.id) && + dir.id != directory.id && + dir.name.trim().equals(trimmedName, ignoreCase = true) + } universalBasicAlertDialog( onDismissRequest = onDismiss, slots = @@ -720,6 +730,13 @@ private fun directoryRenameDialog( value = renameName, onValueChange = { renameName = it }, placeholderText = "Enter directory name...", + isError = nameAlreadyExists, + errorMessage = + if (nameAlreadyExists) { + "Папка с таким названием уже существует" + } else { + null + }, ) }, actions = { @@ -731,7 +748,7 @@ private fun directoryRenameDialog( Button( onClick = { onSave(renameName) }, - enabled = renameName.trim().isNotEmpty() && renameName != directory.name, + enabled = trimmedName.isNotEmpty() && renameName != directory.name && !nameAlreadyExists, contentPadding = PaddingValues(horizontal = 16.dp), shape = MaterialTheme.shapes.medium, ) { diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt index 618a112f..fbb8a14c 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt @@ -1,8 +1,11 @@ package com.itlab.notes.ui.notes +import com.itlab.domain.model.ContentItem + data class NoteItemUi( val id: String, val title: String, val content: String, val folderId: String? = null, + val attachments: List = emptyList(), ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index edc57f56..97363fb2 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -528,7 +528,18 @@ private fun noteCard( ) Spacer(Modifier.height(8.dp)) Text( - text = note.content, + text = + buildString { + if (note.content.isNotBlank()) { + append(note.content) + } + if (note.attachments.isNotEmpty()) { + if (isNotEmpty()) append(" · ") + append(note.attachments.size) + append(" attachment") + if (note.attachments.size != 1) append("s") + } + }, color = if (isSelected) { colors.onPrimaryContainer diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 734a4656..b4059cbc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ workManager = "2.9.0" androidx-work-testing = "2.9.0" lifecycleViewmodelKtx = "2.10.0" koin = "4.2.1" +coilCompose = "2.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -61,6 +62,7 @@ androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle- androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } mockk = { group = "io.mockk", name = "mockk", version = "1.14.9" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-test-core = { group = "androidx.test", name = "core", version = "1.7.0" } From ab674cd051cb5d71f1c9f7c54f1fd97f59b7dd2e Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Fri, 15 May 2026 14:15:53 +0300 Subject: [PATCH 14/35] style: select several photos --- app/build.gradle.kts | 1 + .../com/itlab/notes/media/NoteMediaImport.kt | 5 + .../com/itlab/notes/ui/editor/EditorScreen.kt | 221 ++++++++++++++---- .../itlab/notes/ui/editor/EditorViewModel.kt | 5 + 4 files changed, 184 insertions(+), 48 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index af9d5db6..f6c39f26 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) + implementation("androidx.compose.foundation:foundation") implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) diff --git a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt index d96df604..35667566 100644 --- a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt +++ b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt @@ -11,6 +11,11 @@ import java.util.UUID object NoteMediaImport { private const val SUBDIR = "note_attachments" + fun importImagesFromUris(context: Context, uris: List): List = + uris.mapNotNull { uri -> + runCatching { importImageFromUri(context, uri) }.getOrNull() + } + fun importImageFromUri(context: Context, uri: Uri): ContentItem.Image { val appContext = context.applicationContext val resolver = appContext.contentResolver diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index d19fb271..329e2562 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -4,18 +4,24 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -57,6 +63,11 @@ import com.itlab.notes.media.NoteMediaImport import com.itlab.notes.ui.notes.NoteItemUi import java.io.File +private data class EditorAttachmentsViewerState( + val attachments: List, + val initialIndex: Int, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun editorScreen( @@ -68,15 +79,15 @@ fun editorScreen( val colors = MaterialTheme.colorScheme val context = LocalContext.current val editorVm = remember(note.id) { EditorViewModel(initialNote = note) } - var fullscreenImage by remember { mutableStateOf(null) } + var attachmentsViewer by remember { mutableStateOf(null) } - val pickImage = + val pickImages = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia(), - ) { uri: Uri? -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { NoteMediaImport.importImageFromUri(context, uri) } - .onSuccess { editorVm.addAttachment(it) } + contract = ActivityResultContracts.PickMultipleVisualMedia(), + ) { uris: List -> + if (uris.isEmpty()) return@rememberLauncherForActivityResult + val imported = NoteMediaImport.importImagesFromUris(context, uris) + editorVm.addAttachments(imported) } Scaffold( @@ -87,7 +98,7 @@ fun editorScreen( title = editorVm.title, onBack = onBack, onAddImage = { - pickImage.launch( + pickImages.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), ) }, @@ -105,7 +116,16 @@ fun editorScreen( attachments = editorVm.attachments, onTitleChange = editorVm::onTitleChange, onContentChange = editorVm::onContentChange, - onImageClick = { fullscreenImage = it }, + onAttachmentClick = { item -> + val index = editorVm.attachments.indexOfFirst { it.id == item.id } + if (index >= 0) { + attachmentsViewer = + EditorAttachmentsViewerState( + attachments = editorVm.attachments, + initialIndex = index, + ) + } + }, onRemoveAttachment = { item -> if (item is ContentItem.Image) { NoteMediaImport.deleteImportedFileIfOwned(context, item.source.localPath) @@ -116,10 +136,11 @@ fun editorScreen( ) } - fullscreenImage?.let { image -> - editorFullScreenImageViewer( - image = image, - onDismiss = { fullscreenImage = null }, + attachmentsViewer?.let { viewer -> + editorFullScreenAttachmentsViewer( + attachments = viewer.attachments, + initialIndex = viewer.initialIndex, + onDismiss = { attachmentsViewer = null }, ) } } @@ -153,7 +174,7 @@ private fun editorTopBar( IconButton(onClick = onAddImage) { Icon( Icons.Default.Image, - contentDescription = "Add image", + contentDescription = "Add images", tint = colors.onSurface, ) } @@ -191,7 +212,7 @@ private fun editorContent( attachments: List, onTitleChange: (String) -> Unit, onContentChange: (String) -> Unit, - onImageClick: (ContentItem.Image) -> Unit, + onAttachmentClick: (ContentItem) -> Unit, onRemoveAttachment: (ContentItem) -> Unit, modifier: Modifier = Modifier, ) { @@ -215,7 +236,7 @@ private fun editorContent( if (attachments.isNotEmpty()) { editorAttachmentsRow( attachments = attachments, - onImageClick = onImageClick, + onAttachmentClick = onAttachmentClick, onRemove = onRemoveAttachment, modifier = Modifier.padding(top = 12.dp), ) @@ -226,7 +247,7 @@ private fun editorContent( @Composable private fun editorAttachmentsRow( attachments: List, - onImageClick: (ContentItem.Image) -> Unit, + onAttachmentClick: (ContentItem) -> Unit, onRemove: (ContentItem) -> Unit, modifier: Modifier = Modifier, ) { @@ -240,7 +261,7 @@ private fun editorAttachmentsRow( key = { it.id }, ) { item -> when (item) { - is ContentItem.Image -> editorImageThumbnail(item, onImageClick, onRemove) + is ContentItem.Image -> editorImageThumbnail(item, onAttachmentClick, onRemove) is ContentItem.File -> Surface( shape = RoundedCornerShape(8.dp), @@ -248,7 +269,11 @@ private fun editorAttachmentsRow( modifier = Modifier .height(88.dp) - .width(120.dp), + .width(120.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onAttachmentClick(item) }, ) { Box(Modifier.fillMaxSize()) { Text( @@ -275,7 +300,11 @@ private fun editorAttachmentsRow( modifier = Modifier .height(88.dp) - .width(120.dp), + .width(120.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onAttachmentClick(item) }, ) { Box(Modifier.fillMaxSize()) { Text( @@ -304,7 +333,7 @@ private fun editorAttachmentsRow( @Composable private fun editorImageThumbnail( image: ContentItem.Image, - onImageClick: (ContentItem.Image) -> Unit, + onAttachmentClick: (ContentItem) -> Unit, onRemove: (ContentItem) -> Unit, ) { val context = LocalContext.current @@ -322,7 +351,7 @@ private fun editorImageThumbnail( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - ) { onImageClick(image) }, + ) { onAttachmentClick(image) }, ) { if (model != null) { AsyncImage( @@ -353,16 +382,25 @@ private fun editorImageThumbnail( } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun editorFullScreenImageViewer( - image: ContentItem.Image, +private fun editorFullScreenAttachmentsViewer( + attachments: List, + initialIndex: Int, onDismiss: () -> Unit, ) { + if (attachments.isEmpty()) return + val context = LocalContext.current - val model = - remember(image.id, image.source.localPath, image.source.remoteUrl) { - imageDataForCoil(image.source) - } + val colors = MaterialTheme.colorScheme + val overlayBackground = colors.surface.copy(alpha = 0.88f) + val safeInitialIndex = initialIndex.coerceIn(0, attachments.lastIndex) + val pagerState = + rememberPagerState( + initialPage = safeInitialIndex, + pageCount = { attachments.size }, + ) + Dialog( onDismissRequest = onDismiss, properties = @@ -371,34 +409,121 @@ private fun editorFullScreenImageViewer( decorFitsSystemWindows = false, ), ) { + val dismissInteractionSource = remember { MutableInteractionSource() } Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .background(overlayBackground) .clickable( - interactionSource = remember { MutableInteractionSource() }, + interactionSource = dismissInteractionSource, indication = null, ) { onDismiss() }, ) { - if (model != null) { - AsyncImage( - model = - ImageRequest.Builder(context) - .data(model) - .crossfade(false) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, + HorizontalPager( + state = pagerState, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 48.dp), + ) { page -> + when (val item = attachments[page]) { + is ContentItem.Image -> { + val model = + remember(item.id, item.source.localPath, item.source.remoteUrl) { + imageDataForCoil(item.source) + } + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember(page) { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + if (model != null) { + val absorbImageTap = remember(item.id) { MutableInteractionSource() } + AsyncImage( + model = + ImageRequest.Builder(context) + .data(model) + .crossfade(false) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .sizeIn(maxWidth = maxWidth, maxHeight = maxHeight) + .wrapContentSize() + .clickable( + interactionSource = absorbImageTap, + indication = null, + onClick = {}, + ), + ) + } + } + } + is ContentItem.File -> + Box( + modifier = + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember(page) { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + color = colors.onSurface, + modifier = + Modifier.clickable( + interactionSource = remember(item.id) { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) + } + is ContentItem.Link -> + Box( + modifier = + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember(page) { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = item.title ?: item.url, + style = MaterialTheme.typography.titleMedium, + color = colors.onSurface, + modifier = + Modifier.clickable( + interactionSource = remember(item.id) { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) + } + is ContentItem.Text -> { } + } + } + if (attachments.size > 1) { + Text( + text = "${pagerState.currentPage + 1} / ${attachments.size}", + style = MaterialTheme.typography.labelLarge, + color = colors.onSurfaceVariant, modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 48.dp) - .align(Alignment.Center) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { onDismiss() }, + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), ) } IconButton( @@ -406,12 +531,12 @@ private fun editorFullScreenImageViewer( modifier = Modifier .align(Alignment.TopEnd) - .padding(8.dp), + .padding(top = 20.dp, end = 8.dp), ) { Icon( Icons.Default.Close, contentDescription = null, - tint = Color.White, + tint = colors.onSurface, ) } } diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt index a16b79bd..b41cdd4b 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt @@ -33,6 +33,11 @@ class EditorViewModel( attachments = attachments + item } + fun addAttachments(items: List) { + if (items.isEmpty()) return + attachments = attachments + items + } + fun removeAttachment(id: String) { attachments = attachments.filterNot { it.id == id } } From 95044ba5c9f41afc5ec9323006240af19173d377 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Fri, 15 May 2026 15:17:39 +0300 Subject: [PATCH 15/35] style: add verification to text field in editor screen --- .../main/java/com/itlab/notes/di/AppModule.kt | 6 +- .../itlab/notes/media/ImageRegionLuminance.kt | 68 +++++++++++++++++++ .../main/java/com/itlab/notes/ui/NotesApp.kt | 1 + .../com/itlab/notes/ui/editor/EditorScreen.kt | 57 +++++++++++++++- .../com/itlab/notes/ui/notes/NotesScreen.kt | 30 ++++---- .../usecase/noteusecase/CreateNoteUseCase.kt | 10 +-- .../usecase/noteusecase/UpdateNoteUseCase.kt | 12 ++-- .../ValidateDuplicateNoteTitleUseCase.kt | 25 +++++++ .../java/com/itlab/domain/NoteUseCasesTest.kt | 10 +-- 9 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/media/ImageRegionLuminance.kt create mode 100644 domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index f3b166e3..70192f62 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -11,6 +11,7 @@ import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase +import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import com.itlab.notes.ui.NotesUseCases import com.itlab.notes.ui.NotesViewModel import org.koin.androidx.viewmodel.dsl.viewModel @@ -18,11 +19,12 @@ import org.koin.dsl.module val appModule = module { - factory { CreateNoteUseCase(get()) } + factory { ValidateDuplicateNoteTitleUseCase(get()) } + factory { CreateNoteUseCase(get(), get()) } factory { CreateFolderUseCase(get()) } factory { DeleteFolderUseCase(get(), get()) } factory { DeleteNoteUseCase(get()) } - factory { UpdateNoteUseCase(get()) } + factory { UpdateNoteUseCase(get(), get()) } factory { UpdateFolderUseCase(get()) } factory { GetFolderUseCase(get()) } factory { ObserveNotesByFolderUseCase(get()) } diff --git a/app/src/main/java/com/itlab/notes/media/ImageRegionLuminance.kt b/app/src/main/java/com/itlab/notes/media/ImageRegionLuminance.kt new file mode 100644 index 00000000..d686f1c6 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/media/ImageRegionLuminance.kt @@ -0,0 +1,68 @@ +package com.itlab.notes.media + +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.get + +/** + * Estimates whether the top-end region of an image is light (for adaptive icon contrast). + */ +object ImageRegionLuminance { + private const val LIGHT_REGION_THRESHOLD = 0.55f + + fun isTopEndRegionLight( + drawable: Drawable, + sampleMaxSize: Int = 96, + ): Boolean { + val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: sampleMaxSize + val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: sampleMaxSize + val scale = minOf(1f, sampleMaxSize.toFloat() / maxOf(width, height)) + val targetWidth = (width * scale).toInt().coerceAtLeast(1) + val targetHeight = (height * scale).toInt().coerceAtLeast(1) + val bitmap = drawable.toBitmap(targetWidth, targetHeight) + return try { + isTopEndRegionLight(bitmap) + } finally { + if (drawable !is BitmapDrawable || drawable.bitmap !== bitmap) { + bitmap.recycle() + } + } + } + + private fun isTopEndRegionLight(bitmap: Bitmap): Boolean { + val width = bitmap.width + val height = bitmap.height + if (width == 0 || height == 0) return true + + val startX = (width * 0.5f).toInt().coerceIn(0, width - 1) + val endY = (height * 0.5f).toInt().coerceAtLeast(1) + val step = maxOf(1, minOf(width, height) / 10) + + var luminanceSum = 0.0 + var count = 0 + var x = startX + while (x < width) { + var y = 0 + while (y < endY) { + val pixel = bitmap[x, y] + val composeColor = + Color( + red = android.graphics.Color.red(pixel) / 255f, + green = android.graphics.Color.green(pixel) / 255f, + blue = android.graphics.Color.blue(pixel) / 255f, + ) + luminanceSum += composeColor.luminance() + count++ + y += step + } + x += step + } + + if (count == 0) return true + return (luminanceSum / count) > LIGHT_REGION_THRESHOLD + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 64df6acc..e58c6fb1 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -60,6 +60,7 @@ fun notesApp() { is NotesUiScreen.NoteEditor -> { editorScreen( directoryName = screen.directory.name, + directoryId = screen.directory.id, note = screen.note, onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectoryNotes) }, onSave = { updated -> diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index 329e2562..fef95264 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -24,7 +26,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check @@ -43,12 +47,14 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -59,9 +65,13 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.itlab.domain.model.ContentItem import com.itlab.domain.model.DataSource +import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase +import com.itlab.notes.media.ImageRegionLuminance import com.itlab.notes.media.NoteMediaImport +import com.itlab.notes.ui.asDomainFolderId import com.itlab.notes.ui.notes.NoteItemUi import java.io.File +import org.koin.compose.koinInject private data class EditorAttachmentsViewerState( val attachments: List, @@ -72,14 +82,29 @@ private data class EditorAttachmentsViewerState( @Composable fun editorScreen( directoryName: String, + directoryId: String, note: NoteItemUi, onBack: () -> Unit, onSave: (NoteItemUi) -> Unit, ) { val colors = MaterialTheme.colorScheme val context = LocalContext.current + val validateDuplicateTitle: ValidateDuplicateNoteTitleUseCase = koinInject() val editorVm = remember(note.id) { EditorViewModel(initialNote = note) } var attachmentsViewer by remember { mutableStateOf(null) } + val targetFolderId = note.folderId ?: directoryId.asDomainFolderId() + var titleDuplicate by remember { mutableStateOf(false) } + val trimmedTitle = editorVm.title.trim() + val titleHasDuplicate = titleDuplicate && trimmedTitle.isNotEmpty() + + LaunchedEffect(editorVm.title, targetFolderId, note.id) { + titleDuplicate = + validateDuplicateTitle( + title = editorVm.title, + folderId = targetFolderId, + excludeNoteId = note.id, + ) + } val pickImages = rememberLauncherForActivityResult( @@ -107,11 +132,13 @@ fun editorScreen( floatingActionButton = { editorFab( onClick = { onSave(editorVm.buildUpdatedNote()) }, + enabled = !titleHasDuplicate, ) }, ) { paddingValues -> editorContent( title = editorVm.title, + titleHasDuplicate = titleHasDuplicate, content = editorVm.content, attachments = editorVm.attachments, onTitleChange = editorVm::onTitleChange, @@ -191,10 +218,14 @@ private fun editorTopBar( } @Composable -private fun editorFab(onClick: () -> Unit) { +private fun editorFab( + onClick: () -> Unit, + enabled: Boolean = true, +) { val colors = MaterialTheme.colorScheme FloatingActionButton( - onClick = onClick, + onClick = { if (enabled) onClick() }, + modifier = Modifier.alpha(if (enabled) 1f else 0.4f), containerColor = colors.primary, ) { Icon( @@ -208,6 +239,7 @@ private fun editorFab(onClick: () -> Unit) { @Composable private fun editorContent( title: String, + titleHasDuplicate: Boolean, content: String, attachments: List, onTitleChange: (String) -> Unit, @@ -216,16 +248,26 @@ private fun editorContent( onRemoveAttachment: (ContentItem) -> Unit, modifier: Modifier = Modifier, ) { + val scrollState = rememberScrollState() Column( modifier = modifier .fillMaxSize() + .verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp), ) { editorTitleField( value = title, onValueChange = onTitleChange, ) + if (titleHasDuplicate) { + Text( + text = "A note with that name already exists.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 16.dp), + ) + } editorContentField( value = content, @@ -241,6 +283,8 @@ private fun editorContent( modifier = Modifier.padding(top = 12.dp), ) } + + Spacer(modifier = Modifier.height(88.dp)) } } @@ -341,6 +385,7 @@ private fun editorImageThumbnail( remember(image.id, image.source.localPath, image.source.remoteUrl) { imageDataForCoil(image.source) } + var closeIconTint by remember(image.id) { mutableStateOf(Color.White) } Box { Surface( shape = RoundedCornerShape(8.dp), @@ -359,10 +404,16 @@ private fun editorImageThumbnail( ImageRequest.Builder(context) .data(model) .crossfade(true) + .allowHardware(false) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + onSuccess = { state -> + val isLightRegion = + ImageRegionLuminance.isTopEndRegionLight(state.result.drawable) + closeIconTint = if (isLightRegion) Color.Black else Color.White + }, ) } } @@ -376,7 +427,7 @@ private fun editorImageThumbnail( Icon( Icons.Default.Close, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, + tint = closeIconTint, ) } } diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 97363fb2..4108b7d1 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @@ -528,31 +529,34 @@ private fun noteCard( ) Spacer(Modifier.height(8.dp)) Text( - text = - buildString { - if (note.content.isNotBlank()) { - append(note.content) - } - if (note.attachments.isNotEmpty()) { - if (isNotEmpty()) append(" · ") - append(note.attachments.size) - append(" attachment") - if (note.attachments.size != 1) append("s") - } - }, + text = noteCardDescriptionText(note), color = if (isSelected) { colors.onPrimaryContainer + } else if (note.content.isBlank()) { + colors.onSurfaceVariant.copy(alpha = 0.7f) } else { colors.onSurfaceVariant }, style = MaterialTheme.typography.bodySmall, - maxLines = 4, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } } +private fun noteCardDescriptionText(note: NoteItemUi): String = + buildString { + append(if (note.content.isNotBlank()) note.content else "No description") + if (note.attachments.isNotEmpty()) { + append(" · ") + append(note.attachments.size) + append(" attachment") + if (note.attachments.size != 1) append("s") + } + } + @Composable private fun searchField() { var searchQuery by remember { mutableStateOf("") } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt index 1fa9779e..8c52ad24 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt @@ -2,21 +2,21 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.Note import com.itlab.domain.repository.NotesRepository -import kotlinx.coroutines.flow.first import java.util.UUID import kotlin.time.Clock class CreateNoteUseCase( private val repo: NotesRepository, + private val validateDuplicateNoteTitle: ValidateDuplicateNoteTitleUseCase, ) { suspend operator fun invoke(note: Note): Result = runCatching { val normalizedTitle = note.title.trim() val hasDuplicateTitle = - repo.observeNotes().first().any { existing -> - existing.folderId == note.folderId && - existing.title.trim().equals(normalizedTitle, ignoreCase = true) - } + validateDuplicateNoteTitle( + title = normalizedTitle, + folderId = note.folderId, + ) require(!hasDuplicateTitle) { "Note with title '$normalizedTitle' already exists in this folder" } val now = Clock.System.now() diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt index 508ef2fd..1cf1808b 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt @@ -2,21 +2,21 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.Note import com.itlab.domain.repository.NotesRepository -import kotlinx.coroutines.flow.first import kotlin.time.Clock class UpdateNoteUseCase( private val repo: NotesRepository, + private val validateDuplicateNoteTitle: ValidateDuplicateNoteTitleUseCase, ) { suspend operator fun invoke(note: Note): Result = runCatching { val normalizedTitle = note.title.trim() val hasDuplicateTitle = - repo.observeNotes().first().any { existing -> - existing.id != note.id && - existing.folderId == note.folderId && - existing.title.trim().equals(normalizedTitle, ignoreCase = true) - } + validateDuplicateNoteTitle( + title = normalizedTitle, + folderId = note.folderId, + excludeNoteId = note.id, + ) require(!hasDuplicateTitle) { "Note with title '$normalizedTitle' already exists in this folder" } val note = note.copy(updatedAt = Clock.System.now()) repo.updateNote(note) diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt new file mode 100644 index 00000000..b1abeb9f --- /dev/null +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt @@ -0,0 +1,25 @@ +package com.itlab.domain.usecase.noteusecase + +import com.itlab.domain.repository.NotesRepository +import kotlinx.coroutines.flow.first + +class ValidateDuplicateNoteTitleUseCase( + private val repo: NotesRepository, +) { + /** + * @return `true` if another note in the same folder already has this title. + */ + suspend operator fun invoke( + title: String, + folderId: String?, + excludeNoteId: String? = null, + ): Boolean { + val normalizedTitle = title.trim() + if (normalizedTitle.isBlank()) return false + return repo.observeNotes().first().any { existing -> + (excludeNoteId == null || existing.id != excludeNoteId) && + existing.folderId == folderId && + existing.title.trim().equals(normalizedTitle, ignoreCase = true) + } + } +} diff --git a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt index cda4095c..0d8ad245 100644 --- a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt +++ b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt @@ -18,6 +18,7 @@ import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase +import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -82,8 +83,9 @@ class NoteUseCasesTest { runBlocking { val repo = FakeNotesRepo() - val create = CreateNoteUseCase(repo) - val update = UpdateNoteUseCase(repo) + val validateTitle = ValidateDuplicateNoteTitleUseCase(repo) + val create = CreateNoteUseCase(repo, validateTitle) + val update = UpdateNoteUseCase(repo, validateTitle) val delete = DeleteNoteUseCase(repo) val get = GetNoteUseCase(repo) @@ -112,7 +114,7 @@ class NoteUseCasesTest { val folderRepo = FakeFolderRepo() val move = MoveNoteToFolderUseCase(notesRepo, folderRepo) - val createNote = CreateNoteUseCase(notesRepo) + val createNote = CreateNoteUseCase(notesRepo, ValidateDuplicateNoteTitleUseCase(notesRepo)) val folder = NoteFolder(id = "f1", name = "Folder") folderRepo.createFolder(folder) @@ -132,7 +134,7 @@ class NoteUseCasesTest { runBlocking { val repo = FakeNotesRepo() val observe = ObserveNotesUseCase(repo) - val create = CreateNoteUseCase(repo) + val create = CreateNoteUseCase(repo, ValidateDuplicateNoteTitleUseCase(repo)) create(Note(id = "n1", title = "Test", userId = testUserId)).getOrThrow() From 14267252979d71db9425dbce612f20801653f937 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Fri, 15 May 2026 16:13:41 +0300 Subject: [PATCH 16/35] feat: implement search functionality --- .../main/java/com/itlab/notes/di/AppModule.kt | 3 + .../main/java/com/itlab/notes/ui/NotesApp.kt | 15 ++- .../com/itlab/notes/ui/NotesUiContract.kt | 10 ++ .../java/com/itlab/notes/ui/NotesUseCases.kt | 4 +- .../java/com/itlab/notes/ui/NotesViewModel.kt | 123 +++++++++++++----- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 22 ++-- .../com/itlab/notes/ui/notes/NotesScreen.kt | 21 ++- .../usecase/noteusecase/SearchNotesUseCase.kt | 16 ++- .../java/com/itlab/domain/NoteUseCasesTest.kt | 27 ++++ 9 files changed, 191 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index 70192f62..41d3b985 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -10,6 +10,7 @@ import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase +import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import com.itlab.notes.ui.NotesUseCases @@ -31,6 +32,7 @@ val appModule = factory { ObserveFoldersUseCase(get()) } factory { MoveNoteToFolderUseCase(get(), get()) } factory { ObserveNotesUseCase(get()) } + factory { SearchNotesUseCase(get()) } factory { UpdateFolderUseCase(get()) } factory { GetFolderUseCase(get()) } factory { @@ -46,6 +48,7 @@ val appModule = getFolderUseCase = get(), moveNoteToFolderUseCase = get(), observeNotesUseCase = get(), + searchNotesUseCase = get(), ) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index e58c6fb1..60478a66 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -2,6 +2,7 @@ package com.itlab.notes.ui import androidx.compose.runtime.Composable import com.itlab.notes.ui.editor.editorScreen +import com.itlab.notes.ui.filterDirectoriesByName import com.itlab.notes.ui.notes.NotesListActions import com.itlab.notes.ui.notes.directoriesScreen import com.itlab.notes.ui.notes.notesListScreen @@ -15,7 +16,15 @@ fun notesApp() { when (val screen = state.screen) { NotesUiScreen.Directories -> { directoriesScreen( - directories = state.directories, + directories = + filterDirectoriesByName( + directories = state.directories, + query = state.directorySearchQuery, + ), + searchQuery = state.directorySearchQuery, + onSearchQueryChange = { query -> + viewModel.onEvent(NotesUiEvent.DirectorySearchQueryChanged(query)) + }, onCreateDirectory = { name -> viewModel.onEvent(NotesUiEvent.CreateDirectory(name)) }, @@ -36,6 +45,10 @@ fun notesApp() { directoryId = screen.directory.id, directoryName = screen.directory.name, notes = state.notes, + searchQuery = state.notesSearchQuery, + onSearchQueryChange = { query -> + viewModel.onEvent(NotesUiEvent.NotesSearchQueryChanged(query)) + }, directories = state.directories, actions = NotesListActions( diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt index 054570f4..dcd03d89 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt @@ -24,6 +24,8 @@ data class NotesUiState( val screen: NotesUiScreen = NotesUiScreen.Directories, val directories: List = emptyList(), val notes: List = emptyList(), + val notesSearchQuery: String = "", + val directorySearchQuery: String = "", ) sealed interface NotesUiEvent { @@ -66,6 +68,14 @@ sealed interface NotesUiEvent { val noteId: String, val targetDirectoryId: String, ) : NotesUiEvent + + data class NotesSearchQueryChanged( + val query: String, + ) : NotesUiEvent + + data class DirectorySearchQueryChanged( + val query: String, + ) : NotesUiEvent } interface NotesViewModelContract { diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt index 7b2ffc16..6600096a 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt @@ -7,10 +7,10 @@ import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase -import com.itlab.domain.usecase.noteusecase.GetUserIdUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase +import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase data class NotesUseCases( @@ -25,5 +25,5 @@ data class NotesUseCases( val getFolderUseCase: GetFolderUseCase, val moveNoteToFolderUseCase: MoveNoteToFolderUseCase, val observeNotesUseCase: ObserveNotesUseCase, - val getUserIdUseCase: GetUserIdUseCase, + val searchNotesUseCase: SearchNotesUseCase, ) diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 53fa5852..2f2b3339 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -11,6 +11,7 @@ import com.itlab.domain.model.NoteFolder import com.itlab.notes.ui.notes.DirectoryItemUi import com.itlab.notes.ui.notes.NoteItemUi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -76,9 +77,19 @@ class NotesViewModel( useCases.deleteNoteUseCase(event.noteId) } } + is NotesUiEvent.NotesSearchQueryChanged -> onNotesSearchQueryChanged(event.query) + is NotesUiEvent.DirectorySearchQueryChanged -> { + uiState = uiState.copy(directorySearchQuery = event.query) + } } } + private fun onNotesSearchQueryChanged(query: String) { + val directory = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory ?: return + uiState = uiState.copy(notesSearchQuery = query) + startNotesCollection(directory, query) + } + private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { val normalized = event.newName.trim() if (normalized.isBlank() || event.directoryId == "all" || event.directoryId == RECENT_DIRECTORY_ID) return @@ -103,68 +114,96 @@ class NotesViewModel( uiState.copy( screen = NotesUiScreen.DirectoryNotes(directory = directory), notes = emptyList(), + notesSearchQuery = "", ) + startNotesCollection(directory, searchQuery = "") + } + + private fun startNotesCollection( + directory: DirectoryItemUi, + searchQuery: String, + ) { notesJob?.cancel() notesJob = viewModelScope.launch { - val flow = - when (directory.id) { - "all" -> useCases.observeNotesUseCase() - RECENT_DIRECTORY_ID -> - useCases.observeNotesUseCase().map { notes -> - notes.sortedByDescending { it.updatedAt } - } - else -> useCases.observeNotesByFolderUseCase(directory.id) - } - - flow.collect { notes -> + notesFlow(directory, searchQuery).collect { notes -> + val opened = uiState.screen as? NotesUiScreen.DirectoryNotes ?: return@collect uiState = uiState.copy( notes = notes.map { it.toUi() }, + notesSearchQuery = searchQuery, screen = NotesUiScreen.DirectoryNotes( - directory = directory.copy(noteCount = notes.size), + directory = opened.directory.copy(noteCount = notes.size), ), ) } } } + private fun notesFlow( + directory: DirectoryItemUi, + searchQuery: String, + ): Flow> { + val normalizedQuery = searchQuery.trim() + return if (normalizedQuery.isBlank()) { + when (directory.id) { + "all" -> useCases.observeNotesUseCase() + RECENT_DIRECTORY_ID -> + useCases.observeNotesUseCase().map { notes -> + notes.sortedByDescending { it.updatedAt } + } + else -> useCases.observeNotesByFolderUseCase(directory.id) + } + } else { + val searchFlow = + useCases.searchNotesUseCase( + query = normalizedQuery, + folderId = directory.folderIdForSearch(), + ) + when (directory.id) { + RECENT_DIRECTORY_ID -> + searchFlow.map { notes -> + notes.sortedByDescending { it.updatedAt } + } + else -> searchFlow + } + } + } + private val backToDirectories: () -> Unit = { uiState = uiState.copy( screen = NotesUiScreen.Directories, notes = emptyList(), + notesSearchQuery = "", ) } private fun openNote(note: NoteItemUi) { - val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory - if (dir != null) { - uiState = - uiState.copy( - screen = NotesUiScreen.NoteEditor(directory = dir, note = note), - ) - } + val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory ?: return + notesJob?.cancel() + uiState = + uiState.copy( + screen = NotesUiScreen.NoteEditor(directory = dir, note = note), + ) } private fun createNote() { - val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory - if (dir != null) { - val newNote = - Note(folderId = dir.id.asDomainFolderId()).toUi() - uiState = - uiState.copy( - screen = NotesUiScreen.NoteEditor(directory = dir, note = newNote), - ) - } + val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory ?: return + notesJob?.cancel() + val newNote = Note(folderId = dir.id.asDomainFolderId()).toUi() + uiState = + uiState.copy( + screen = NotesUiScreen.NoteEditor(directory = dir, note = newNote), + ) } private fun backToDirectoryNotes() { - val editor = uiState.screen as? NotesUiScreen.NoteEditor - if (editor != null) { - uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) - } + val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return + val directory = editor.directory + uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = directory)) + startNotesCollection(directory, uiState.notesSearchQuery) } private fun saveNote(note: NoteItemUi) { @@ -178,6 +217,7 @@ class NotesViewModel( useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) } uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) + startNotesCollection(editor.directory, uiState.notesSearchQuery) } } @@ -258,3 +298,22 @@ internal fun String.asDomainFolderId(): String? = -> null else -> this } + +internal fun DirectoryItemUi.folderIdForSearch(): String? = + when (id) { + "all", + RECENT_DIRECTORY_ID, + -> null + else -> id + } + +internal fun filterDirectoriesByName( + directories: List, + query: String, +): List { + val normalized = query.trim() + if (normalized.isBlank()) return directories + return directories.filter { directory -> + directory.name.contains(normalized, ignoreCase = true) + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 8f78a9f8..6f71874f 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -82,6 +82,8 @@ private const val RECENT_DIRECTORY_ID = "recent" @Composable fun directoriesScreen( directories: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, onCreateDirectory: (String) -> Unit, onDeleteDirectory: (DirectoryItemUi) -> Unit, onRenameDirectory: (DirectoryItemUi, String) -> Unit, @@ -110,6 +112,8 @@ fun directoriesScreen( ) { paddingValues -> directoriesList( directories = directories, + searchQuery = searchQuery, + onSearchQueryChange = onSearchQueryChange, onDirectoryLongClick = onDeleteDirectory, onDirectoryRename = onRenameDirectory, onDirectoryClick = onDirectoryClick, @@ -315,6 +319,8 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = @Composable private fun directoriesList( directories: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, onDirectoryLongClick: (DirectoryItemUi) -> Unit, onDirectoryRename: (DirectoryItemUi, String) -> Unit, onDirectoryClick: (DirectoryItemUi) -> Unit, @@ -340,7 +346,10 @@ private fun directoriesList( Column( modifier = modifier.fillMaxSize().clearFocusOnTap(focusManager).padding(horizontal = 12.dp), ) { - directorySearchBar() + directorySearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + ) LazyColumn( modifier = Modifier.weight(1f), contentPadding = PaddingValues(bottom = 12.dp), @@ -552,16 +561,13 @@ private fun directoriesBlock( @Composable fun directorySearchBar( + query: String, + onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, - onQueryChange: (String) -> Unit = {}, ) { - var searchQuery by remember { mutableStateOf("") } appSearchField( - value = searchQuery, - onValueChange = { - searchQuery = it - onQueryChange(it) - }, + value = query, + onValueChange = onQueryChange, modifier = modifier, placeholderText = "Search directories", ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 4108b7d1..b63b7472 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -64,6 +64,8 @@ fun notesListScreen( directoryId: String, directoryName: String, notes: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, directories: List, actions: NotesListActions, ) { @@ -112,6 +114,8 @@ fun notesListScreen( Box(Modifier.fillMaxSize()) { notesListContent( notes = notes, + searchQuery = searchQuery, + onSearchQueryChange = onSearchQueryChange, paddingValues = paddingValues, selectedNoteIds = selectedNoteIds, actions = @@ -425,6 +429,8 @@ private fun notesFab(onAddNoteClick: () -> Unit) { @Composable private fun notesListContent( notes: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, paddingValues: androidx.compose.foundation.layout.PaddingValues, selectedNoteIds: MutableList, actions: NotesListContentActions, @@ -436,7 +442,10 @@ private fun notesListContent( .padding(paddingValues) .padding(horizontal = 16.dp), ) { - searchField() + searchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + ) LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -558,11 +567,13 @@ private fun noteCardDescriptionText(note: NoteItemUi): String = } @Composable -private fun searchField() { - var searchQuery by remember { mutableStateOf("") } +private fun searchField( + query: String, + onQueryChange: (String) -> Unit, +) { appSearchField( - value = searchQuery, - onValueChange = { searchQuery = it }, + value = query, + onValueChange = onQueryChange, modifier = Modifier.padding(vertical = 16.dp), placeholderText = "Search notes", ) diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt index f827c5f4..1d08d0a9 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt @@ -9,12 +9,24 @@ import kotlinx.coroutines.flow.map class SearchNotesUseCase( private val repo: NotesRepository, ) { - operator fun invoke(query: String): Flow> { + /** + * @param folderId when set, limits results to notes in that folder (for search inside a directory). + */ + operator fun invoke( + query: String, + folderId: String? = null, + ): Flow> { val normalizedQuery = query.trim().lowercase() if (normalizedQuery.isBlank()) return repo.observeNotes() return repo.observeNotes().map { notes -> - notes.filter { note -> note.matches(normalizedQuery) } + val scoped = + if (folderId == null) { + notes + } else { + notes.filter { it.folderId == folderId } + } + scoped.filter { note -> note.matches(normalizedQuery) } } } diff --git a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt index 0d8ad245..2c61ae37 100644 --- a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt +++ b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt @@ -378,6 +378,33 @@ class NoteUseCasesTest { assertEquals("n9", result.first().id) } + @Test + fun searchNotes_scopedToFolderId() = + runBlocking { + val repo = FakeNotesRepo() + val useCase = SearchNotesUseCase(repo) + + repo.createNote( + Note( + id = "n1", + folderId = "folder-a", + title = "Молоко в папке A", + ), + ) + repo.createNote( + Note( + id = "n2", + folderId = "folder-b", + title = "Молоко в папке B", + ), + ) + + val result = useCase("молоко", folderId = "folder-a").first() + + assertEquals(1, result.size) + assertEquals("n1", result.first().id) + } + @Test fun addTag_trimsIncomingTag() = runBlocking { From 654f7941200f496759ae997a0b848743a940ff11 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 11:39:54 +0300 Subject: [PATCH 17/35] style: add ai tags and summary --- .../com/itlab/notes/ui/editor/EditorScreen.kt | 226 ++++++++++++++++-- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 46 ++-- 2 files changed, 221 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index fef95264..b1575cf7 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -4,6 +4,11 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -15,6 +20,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,6 +32,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -33,9 +40,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Image import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -58,6 +69,8 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -73,11 +86,29 @@ import com.itlab.notes.ui.notes.NoteItemUi import java.io.File import org.koin.compose.koinInject +private const val EDITOR_TOP_BAR_TITLE_MAX_LENGTH = 35 + private data class EditorAttachmentsViewerState( val attachments: List, val initialIndex: Int, ) +private fun String.truncateForEditorTopBar(): String = + if (length <= EDITOR_TOP_BAR_TITLE_MAX_LENGTH) { + this + } else { + take(EDITOR_TOP_BAR_TITLE_MAX_LENGTH - 1) + "…" + } + +/** Set to `false` after layout review; wire [editorAiTagsBar] / [editorCollapsibleSummaryCard] to real AI data. */ +private const val EDITOR_AI_UI_PREVIEW = true + +private val editorAiPreviewSummary = + "This note is about planning the product launch: goals for the week, " + + "open questions for the team, and a short list of next steps." +private val editorAiPreviewTags = + listOf("Work", "Planning", "Product", "Follow-up", "Study", "Study","Study",) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun editorScreen( @@ -136,31 +167,51 @@ fun editorScreen( ) }, ) { paddingValues -> - editorContent( - title = editorVm.title, - titleHasDuplicate = titleHasDuplicate, - content = editorVm.content, - attachments = editorVm.attachments, - onTitleChange = editorVm::onTitleChange, - onContentChange = editorVm::onContentChange, - onAttachmentClick = { item -> - val index = editorVm.attachments.indexOfFirst { it.id == item.id } - if (index >= 0) { - attachmentsViewer = - EditorAttachmentsViewerState( - attachments = editorVm.attachments, - initialIndex = index, - ) - } - }, - onRemoveAttachment = { item -> - if (item is ContentItem.Image) { - NoteMediaImport.deleteImportedFileIfOwned(context, item.source.localPath) - } - editorVm.removeAttachment(item.id) - }, - modifier = Modifier.padding(paddingValues), - ) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (EDITOR_AI_UI_PREVIEW && editorAiPreviewTags.isNotEmpty()) { + editorAiTagsBar( + tags = editorAiPreviewTags, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + editorContent( + title = editorVm.title, + titleHasDuplicate = titleHasDuplicate, + content = editorVm.content, + attachments = editorVm.attachments, + aiSummary = if (EDITOR_AI_UI_PREVIEW) editorAiPreviewSummary else null, + onTitleChange = editorVm::onTitleChange, + onContentChange = editorVm::onContentChange, + onAttachmentClick = { item -> + val index = editorVm.attachments.indexOfFirst { it.id == item.id } + if (index >= 0) { + attachmentsViewer = + EditorAttachmentsViewerState( + attachments = editorVm.attachments, + initialIndex = index, + ) + } + }, + onRemoveAttachment = { item -> + if (item is ContentItem.Image) { + NoteMediaImport.deleteImportedFileIfOwned(context, item.source.localPath) + } + editorVm.removeAttachment(item.id) + }, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + } } attachmentsViewer?.let { viewer -> @@ -181,11 +232,17 @@ private fun editorTopBar( onAddImage: () -> Unit, ) { val colors = MaterialTheme.colorScheme + val topBarTitle = + (if (title.isBlank()) directoryName else title).truncateForEditorTopBar() CenterAlignedTopAppBar( title = { Text( - text = if (title.isBlank()) directoryName else title, + text = topBarTitle, color = colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) }, navigationIcon = { @@ -242,6 +299,7 @@ private fun editorContent( titleHasDuplicate: Boolean, content: String, attachments: List, + aiSummary: String?, onTitleChange: (String) -> Unit, onContentChange: (String) -> Unit, onAttachmentClick: (ContentItem) -> Unit, @@ -256,6 +314,13 @@ private fun editorContent( .verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp), ) { + if (!aiSummary.isNullOrBlank()) { + editorCollapsibleSummaryCard( + summary = aiSummary, + modifier = Modifier.padding(bottom = 10.dp), + ) + } + editorTitleField( value = title, onValueChange = onTitleChange, @@ -601,6 +666,115 @@ private fun imageDataForCoil(source: DataSource): Any? = else -> null } +@Composable +private fun editorAiTagsBar( + tags: List, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + LazyRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 0.dp), + ) { + itemsIndexed( + items = tags, + key = { index, tag -> "$index-$tag" }, + ) { _, tag -> + FilterChip( + selected = true, + onClick = { }, + label = { + Text( + text = tag, + style = MaterialTheme.typography.labelLarge, + ) + }, + shape = MaterialTheme.shapes.extraLarge, + colors = + FilterChipDefaults.filterChipColors( + containerColor = colors.surfaceContainerHigh, + labelColor = colors.onSurface, + iconColor = colors.onSurface, + selectedContainerColor = colors.secondaryContainer, + selectedLabelColor = colors.onSecondaryContainer, + selectedLeadingIconColor = colors.onSecondaryContainer, + ), + border = + FilterChipDefaults.filterChipBorder( + enabled = true, + selected = true, + borderColor = colors.outline.copy(alpha = 0.35f), + selectedBorderColor = Color.Transparent, + ), + ) + } + } +} + +@Composable +private fun editorCollapsibleSummaryCard( + summary: String, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(true) } + val colors = MaterialTheme.colorScheme + + Surface( + color = colors.surfaceContainer, + shape = MaterialTheme.shapes.large, + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { expanded = !expanded } + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "AI Summary", + style = MaterialTheme.typography.titleSmall, + color = colors.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = + if (expanded) { + "Collapse summary" + } else { + "Expand summary" + }, + tint = colors.onSurfaceVariant, + ) + } + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurface, + modifier = + Modifier.padding( + start = 20.dp, + end = 20.dp, + bottom = 14.dp, + ), + ) + } + } + } +} + @Composable private fun editorTitleField( value: String, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 6f71874f..c158e5f0 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -30,10 +30,12 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTimeFilled import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AllInbox import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderCopy -import androidx.compose.material.icons.filled.Stars +import androidx.compose.material.icons.filled.Notes import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.TextFields @@ -330,18 +332,14 @@ private fun directoriesList( var pendingRename by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current - val sectionData = - remember(directories) { - val favIds = setOf("all") - val total = - directories.firstOrNull { it.id == "all" }?.noteCount ?: directories.sumOf { it.noteCount } - val favs = directories.filter { it.id in favIds } - val regs = directories.filterNot { it.id in favIds } - val recent = DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = total) - Triple(favs, regs, recent) - } - val totalNotesCount = sectionData.third.noteCount val allNotesDirectory = remember(directories) { directories.firstOrNull { it.id == "all" } } + val regularDirectories = remember(directories) { directories.filter { it.id != "all" } } + val totalNotesCount = + allNotesDirectory?.noteCount ?: directories.sumOf { it.noteCount } + val recentDirectory = + remember(totalNotesCount) { + DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = totalNotesCount) + } Column( modifier = modifier.fillMaxSize().clearFocusOnTap(focusManager).padding(horizontal = 12.dp), @@ -357,14 +355,12 @@ private fun directoriesList( fun LazyListScope.addSection( title: String, dirs: List, - isRegularBlock: Boolean, ) { if (dirs.isEmpty()) return item { sectionTitle(title = title) } item { directoriesBlock( directories = dirs, - isRegularDirectoriesBlock = isRegularBlock, onDirectoryClick = onDirectoryClick, onDirectoryLongClick = { pendingDelete = it }, ) @@ -373,15 +369,17 @@ private fun directoriesList( item { directoriesHeroPanel( - directoriesCount = directories.count { it.id != "all" }, + directoriesCount = regularDirectories.size, totalNotesCount = totalNotesCount, ) } - addSection("Continue working", listOf(sectionData.third), true) - addSection("Favorite directories", sectionData.first, false) - addSection("Regular directories", sectionData.second, true) + allNotesDirectory?.let { allNotes -> + addSection("Everything", listOf(allNotes)) + } + addSection("Continue working", listOf(recentDirectory)) + addSection("Regular directories", regularDirectories) - if (sectionData.second.isEmpty()) { + if (regularDirectories.isEmpty()) { item { Box( modifier = @@ -526,7 +524,6 @@ private fun sectionTitle(title: String) { @Composable private fun directoriesBlock( directories: List, - isRegularDirectoriesBlock: Boolean, onDirectoryClick: (DirectoryItemUi) -> Unit, onDirectoryLongClick: (DirectoryItemUi) -> Unit, ) { @@ -540,7 +537,6 @@ private fun directoriesBlock( directories.forEachIndexed { index, dir -> directoryRow( directory = dir, - isRegularDirectory = isRegularDirectoriesBlock, onClick = { onDirectoryClick(dir) }, @@ -580,12 +576,12 @@ private fun isSpecialDirectory(directoryId: String): Boolean = @Composable private fun directoryRow( directory: DirectoryItemUi, - isRegularDirectory: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, ) { val colors = MaterialTheme.colorScheme + val isAllNotes = directory.id == "all" Row( modifier = modifier @@ -601,11 +597,11 @@ private fun directoryRow( imageVector = when { directory.id == RECENT_DIRECTORY_ID -> Icons.Default.AccessTimeFilled - isRegularDirectory -> Icons.Default.Folder - else -> Icons.Default.Stars + isAllNotes -> Icons.Default.AllInbox + else -> Icons.Default.Folder }, contentDescription = null, - tint = if (isRegularDirectory) colors.onSurfaceVariant else colors.primary, + tint = if (isAllNotes) colors.primary else colors.onSurfaceVariant, modifier = Modifier.size(25.dp), ) Spacer(Modifier.width(12.dp)) From a633f2c5c3a1c2f8b04d01b462df30ec694feb87 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 12:27:49 +0300 Subject: [PATCH 18/35] style: change all icons type to rounded --- .../com/itlab/notes/ui/editor/EditorScreen.kt | 152 +++++++++++------- .../itlab/notes/ui/notes/AppSearchField.kt | 8 +- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 25 ++- .../com/itlab/notes/ui/notes/NotesScreen.kt | 33 ++-- 4 files changed, 124 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index b1575cf7..09a6ccf7 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -35,14 +35,15 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.AddPhotoAlternate +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -54,7 +55,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -67,8 +67,11 @@ import androidx.compose.ui.Alignment 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -88,19 +91,16 @@ import org.koin.compose.koinInject private const val EDITOR_TOP_BAR_TITLE_MAX_LENGTH = 35 +private val EditorHorizontalGutter = 15.dp +private val EditorHorizontalContentPadding = 15.dp + private data class EditorAttachmentsViewerState( val attachments: List, val initialIndex: Int, ) -private fun String.truncateForEditorTopBar(): String = - if (length <= EDITOR_TOP_BAR_TITLE_MAX_LENGTH) { - this - } else { - take(EDITOR_TOP_BAR_TITLE_MAX_LENGTH - 1) + "…" - } +private fun String.truncateForEditorTopBar(): String = take(EDITOR_TOP_BAR_TITLE_MAX_LENGTH) -/** Set to `false` after layout review; wire [editorAiTagsBar] / [editorCollapsibleSummaryCard] to real AI data. */ private const val EDITOR_AI_UI_PREVIEW = true private val editorAiPreviewSummary = @@ -179,7 +179,7 @@ fun editorScreen( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = EditorHorizontalGutter, vertical = 8.dp), ) } editorContent( @@ -242,13 +242,13 @@ private fun editorTopBar( maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), ) }, navigationIcon = { IconButton(onClick = onBack) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, + Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null, tint = colors.onSurface, ) @@ -257,7 +257,7 @@ private fun editorTopBar( actions = { IconButton(onClick = onAddImage) { Icon( - Icons.Default.Image, + Icons.Rounded.AddPhotoAlternate, contentDescription = "Add images", tint = colors.onSurface, ) @@ -286,7 +286,7 @@ private fun editorFab( containerColor = colors.primary, ) { Icon( - Icons.Default.Check, + Icons.Rounded.Check, contentDescription = null, tint = colors.onPrimary, ) @@ -312,7 +312,7 @@ private fun editorContent( modifier .fillMaxSize() .verticalScroll(scrollState) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = EditorHorizontalGutter, vertical = 12.dp), ) { if (!aiSummary.isNullOrBlank()) { editorCollapsibleSummaryCard( @@ -330,7 +330,6 @@ private fun editorContent( text = "A note with that name already exists.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 16.dp), ) } @@ -398,7 +397,7 @@ private fun editorAttachmentsRow( onClick = { onRemove(item) }, modifier = Modifier.align(Alignment.TopEnd), ) { - Icon(Icons.Default.Close, contentDescription = null) + Icon(Icons.Rounded.Close, contentDescription = null) } } } @@ -429,7 +428,7 @@ private fun editorAttachmentsRow( onClick = { onRemove(item) }, modifier = Modifier.align(Alignment.TopEnd), ) { - Icon(Icons.Default.Close, contentDescription = null) + Icon(Icons.Rounded.Close, contentDescription = null) } } } @@ -490,7 +489,7 @@ private fun editorImageThumbnail( .size(28.dp), ) { Icon( - Icons.Default.Close, + Icons.Rounded.Close, contentDescription = null, tint = closeIconTint, ) @@ -650,7 +649,7 @@ private fun editorFullScreenAttachmentsViewer( .padding(top = 20.dp, end = 8.dp), ) { Icon( - Icons.Default.Close, + Icons.Rounded.Close, contentDescription = null, tint = colors.onSurface, ) @@ -734,7 +733,7 @@ private fun editorCollapsibleSummaryCard( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { expanded = !expanded } - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding(horizontal = EditorHorizontalContentPadding, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -744,7 +743,7 @@ private fun editorCollapsibleSummaryCard( modifier = Modifier.weight(1f), ) Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, contentDescription = if (expanded) { "Collapse summary" @@ -765,8 +764,8 @@ private fun editorCollapsibleSummaryCard( color = colors.onSurface, modifier = Modifier.padding( - start = 20.dp, - end = 20.dp, + start = EditorHorizontalContentPadding, + end = EditorHorizontalContentPadding, bottom = 14.dp, ), ) @@ -776,29 +775,73 @@ private fun editorCollapsibleSummaryCard( } @Composable -private fun editorTitleField( +private fun editorPlainTextField( value: String, onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge, + singleLine: Boolean = false, + minLines: Int = 1, ) { val colors = MaterialTheme.colorScheme - TextField( + val interactionSource = remember { MutableInteractionSource() } + + BasicTextField( value = value, onValueChange = onValueChange, - placeholder = { Text("Title") }, + modifier = modifier.fillMaxWidth(), + textStyle = textStyle.copy(color = colors.onSurface), + cursorBrush = SolidColor(colors.primary), + singleLine = singleLine, + minLines = if (singleLine) 1 else minLines, + interactionSource = interactionSource, + decorationBox = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + enabled = true, + singleLine = singleLine, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = { + Text( + text = placeholder, + style = textStyle, + color = colors.onSurfaceVariant, + ) + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + contentPadding = + PaddingValues( + horizontal = EditorHorizontalContentPadding, + vertical = 8.dp, + ), + ) + }, + ) +} + +@Composable +private fun editorTitleField( + value: String, + onValueChange: (String) -> Unit, +) { + editorPlainTextField( + value = value, + onValueChange = onValueChange, + placeholder = "Title", singleLine = true, - colors = - TextFieldDefaults.colors( - focusedTextColor = colors.onSurface, - unfocusedTextColor = colors.onSurface, - focusedPlaceholderColor = colors.onSurfaceVariant, - unfocusedPlaceholderColor = colors.onSurfaceVariant, - focusedContainerColor = colors.background, - unfocusedContainerColor = colors.background, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - ), + textStyle = MaterialTheme.typography.titleLarge, ) } @@ -808,25 +851,12 @@ private fun editorContentField( onValueChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - val colors = MaterialTheme.colorScheme - TextField( + editorPlainTextField( value = value, onValueChange = onValueChange, + placeholder = "Input", modifier = modifier, - placeholder = { Text("Input") }, minLines = 12, - colors = - TextFieldDefaults.colors( - focusedTextColor = colors.onSurface, - unfocusedTextColor = colors.onSurface, - focusedPlaceholderColor = colors.onSurfaceVariant, - unfocusedPlaceholderColor = colors.onSurfaceVariant, - focusedContainerColor = colors.background, - unfocusedContainerColor = colors.background, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - ), + textStyle = MaterialTheme.typography.bodyLarge, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt index a690bbf2..aea4f72a 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppSearchField.kt @@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -130,7 +130,7 @@ private fun appSearchFieldDecorationBox( @Composable private fun appSearchFieldLeadingIcon(isFocused: Boolean) { Icon( - imageVector = Icons.Default.Search, + imageVector = Icons.Rounded.Search, contentDescription = null, tint = if (isFocused) { @@ -153,7 +153,7 @@ private fun appSearchFieldTrailingIcon( if (isFocused) { IconButton(onClick = onClearClick) { Icon( - imageVector = Icons.Default.Close, + imageVector = Icons.Rounded.Close, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index c158e5f0..f436c653 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -28,16 +28,13 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccessTimeFilled -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AllInbox -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderCopy -import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.AllInbox +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderCopy +import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button @@ -294,7 +291,7 @@ private fun directoriesTopBar(onAddDirectoryClick: () -> Unit) { actions = { IconButton(onClick = onAddDirectoryClick) { Icon( - Icons.Default.Add, + Icons.Rounded.Add, contentDescription = null, tint = colors.onSurface, ) @@ -447,7 +444,7 @@ private fun directoriesHeroPanel( verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = Icons.Default.FolderCopy, + imageVector = Icons.Rounded.FolderCopy, contentDescription = null, tint = colors.primary, modifier = Modifier.size(25.dp), @@ -596,9 +593,9 @@ private fun directoryRow( Icon( imageVector = when { - directory.id == RECENT_DIRECTORY_ID -> Icons.Default.AccessTimeFilled - isAllNotes -> Icons.Default.AllInbox - else -> Icons.Default.Folder + directory.id == RECENT_DIRECTORY_ID -> Icons.Rounded.Schedule + isAllNotes -> Icons.Rounded.AllInbox + else -> Icons.Rounded.Folder }, contentDescription = null, tint = if (isAllNotes) colors.primary else colors.onSurfaceVariant, @@ -624,7 +621,7 @@ private fun directoryRow( } Spacer(Modifier.width(5.dp)) Icon( - Icons.Default.ChevronRight, + Icons.Rounded.ChevronRight, contentDescription = null, tint = colors.onSurfaceVariant, ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index b63b7472..71d72711 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -21,13 +21,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.CompareArrows -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderCopy +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.CompareArrows +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderCopy import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -182,9 +182,9 @@ private fun notesTopBar( Icon( imageVector = if (selectedCount > 0) { - Icons.Default.Close + Icons.Rounded.Close } else { - Icons.AutoMirrored.Filled.ArrowBack + Icons.AutoMirrored.Rounded.ArrowBack }, contentDescription = null, tint = colors.onSurface, @@ -195,14 +195,14 @@ private fun notesTopBar( if (selectedCount > 0) { IconButton(onClick = onMoveSelected) { Icon( - imageVector = Icons.AutoMirrored.Filled.CompareArrows, + imageVector = Icons.AutoMirrored.Rounded.CompareArrows, contentDescription = null, tint = colors.onSurface, ) } IconButton(onClick = onDeleteSelected) { Icon( - imageVector = Icons.Default.Delete, + imageVector = Icons.Rounded.Delete, contentDescription = null, tint = colors.onSurface, ) @@ -235,7 +235,7 @@ private fun notesMoveNotesDialog( onDismissRequest = onDismissRequest, slots = UniversalBasicAlertDialogSlots( - icon = Icons.AutoMirrored.Filled.CompareArrows, + icon = Icons.AutoMirrored.Rounded.CompareArrows, iconContainerColor = MaterialTheme.colorScheme.surfaceContainer, iconTintColor = MaterialTheme.colorScheme.onSurfaceVariant, title = { @@ -326,7 +326,7 @@ private fun notesMoveTargetRow( verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = Icons.Default.Folder, + imageVector = Icons.Rounded.Folder, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size(25.dp), @@ -372,7 +372,7 @@ private fun notesDeleteConfirmationDialog( onDismissRequest = onDismissRequest, slots = UniversalBasicAlertDialogSlots( - icon = Icons.Default.Delete, + icon = Icons.Rounded.Delete, iconContainerColor = MaterialTheme.colorScheme.errorContainer, iconTintColor = MaterialTheme.colorScheme.onErrorContainer, title = { @@ -419,7 +419,7 @@ private fun notesFab(onAddNoteClick: () -> Unit) { containerColor = colors.primary, ) { Icon( - Icons.Default.Add, + Icons.Rounded.Add, contentDescription = null, tint = colors.onPrimary, ) @@ -535,6 +535,9 @@ private fun noteCard( colors.onSurface }, style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) Text( From 324b256a49ec63e1b7a67d5b87149bbfa012f36a Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 12:56:36 +0300 Subject: [PATCH 19/35] feat: add favorites --- .../main/java/com/itlab/notes/di/AppModule.kt | 6 + .../com/itlab/notes/media/NoteMediaDisplay.kt | 21 ++ .../com/itlab/notes/media/NoteMediaImport.kt | 1 + .../main/java/com/itlab/notes/ui/NotesApp.kt | 3 + .../com/itlab/notes/ui/NotesUiContract.kt | 4 + .../java/com/itlab/notes/ui/NotesUseCases.kt | 4 + .../java/com/itlab/notes/ui/NotesViewModel.kt | 59 ++++-- .../com/itlab/notes/ui/editor/EditorScreen.kt | 188 +++++++++--------- .../itlab/notes/ui/editor/EditorViewModel.kt | 11 +- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 29 ++- .../com/itlab/notes/ui/notes/DirectoryIds.kt | 10 + .../com/itlab/notes/ui/notes/NoteItemUi.kt | 1 + .../com/itlab/notes/ui/notes/NotesScreen.kt | 2 +- 13 files changed, 218 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/media/NoteMediaDisplay.kt create mode 100644 app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index 41d3b985..eeaa4984 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -7,7 +7,9 @@ import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase +import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase +import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase @@ -33,6 +35,8 @@ val appModule = factory { MoveNoteToFolderUseCase(get(), get()) } factory { ObserveNotesUseCase(get()) } factory { SearchNotesUseCase(get()) } + factory { SwitchFavoriteUseCase(get()) } + factory { GetAllFavoritesUseCase(get()) } factory { UpdateFolderUseCase(get()) } factory { GetFolderUseCase(get()) } factory { @@ -49,6 +53,8 @@ val appModule = moveNoteToFolderUseCase = get(), observeNotesUseCase = get(), searchNotesUseCase = get(), + switchFavoriteUseCase = get(), + getAllFavoritesUseCase = get(), ) } diff --git a/app/src/main/java/com/itlab/notes/media/NoteMediaDisplay.kt b/app/src/main/java/com/itlab/notes/media/NoteMediaDisplay.kt new file mode 100644 index 00000000..d8e6511f --- /dev/null +++ b/app/src/main/java/com/itlab/notes/media/NoteMediaDisplay.kt @@ -0,0 +1,21 @@ +package com.itlab.notes.media + +import com.itlab.domain.model.ContentItem +import com.itlab.domain.model.DataSource +import java.io.File + +fun List.withoutTextItems(): List = filterNot { it is ContentItem.Text } + +fun List.imageAttachments(): List = filterIsInstance() + +fun DataSource.toCoilModel(): Any? { + localPath + ?.takeIf { it.isNotBlank() } + ?.let { path -> + val file = File(path) + if (file.isFile && file.canRead() && file.length() > 0L) { + return file + } + } + return remoteUrl?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt index 35667566..610fe604 100644 --- a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt +++ b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt @@ -26,6 +26,7 @@ object NoteMediaImport { resolver.openInputStream(uri)?.use { input -> file.outputStream().use { out -> input.copyTo(out) } } ?: error("Cannot read selected image") + require(file.length() > 0L) { "Selected image is empty" } return ContentItem.Image( source = DataSource(localPath = file.absolutePath), mimeType = mime, diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 60478a66..c8e9b1bd 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -79,6 +79,9 @@ fun notesApp() { onSave = { updated -> viewModel.onEvent(NotesUiEvent.SaveNote(updated)) }, + onToggleFavorite = { + viewModel.onEvent(NotesUiEvent.ToggleNoteFavorite(screen.note.id)) + }, ) } } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt index dcd03d89..e66732a9 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt @@ -76,6 +76,10 @@ sealed interface NotesUiEvent { data class DirectorySearchQueryChanged( val query: String, ) : NotesUiEvent + + data class ToggleNoteFavorite( + val noteId: String, + ) : NotesUiEvent } interface NotesViewModelContract { diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt index 6600096a..008fd470 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt @@ -7,7 +7,9 @@ import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase +import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase +import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase @@ -26,4 +28,6 @@ data class NotesUseCases( val moveNoteToFolderUseCase: MoveNoteToFolderUseCase, val observeNotesUseCase: ObserveNotesUseCase, val searchNotesUseCase: SearchNotesUseCase, + val switchFavoriteUseCase: SwitchFavoriteUseCase, + val getAllFavoritesUseCase: GetAllFavoritesUseCase, ) diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 2f2b3339..5383c5ef 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -8,15 +8,18 @@ import androidx.lifecycle.viewModelScope import com.itlab.domain.model.ContentItem import com.itlab.domain.model.Note import com.itlab.domain.model.NoteFolder +import com.itlab.notes.media.withoutTextItems +import com.itlab.notes.ui.notes.ALL_DIRECTORY_ID import com.itlab.notes.ui.notes.DirectoryItemUi +import com.itlab.notes.ui.notes.FAVORITES_DIRECTORY_ID import com.itlab.notes.ui.notes.NoteItemUi +import com.itlab.notes.ui.notes.RECENT_DIRECTORY_ID +import com.itlab.notes.ui.notes.isVirtualDirectory import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -private const val RECENT_DIRECTORY_ID = "recent" - class NotesViewModel( private val useCases: NotesUseCases, ) : ViewModel(), @@ -62,7 +65,7 @@ class NotesViewModel( is NotesUiEvent.RenameDirectory -> renameDirectory(event) is NotesUiEvent.DeleteDirectory -> deleteDirectory(event.directoryId) is NotesUiEvent.MoveNoteToDirectory -> { - if (event.targetDirectoryId == "all" || event.targetDirectoryId == RECENT_DIRECTORY_ID) return + if (isVirtualDirectory(event.targetDirectoryId)) return viewModelScope.launch { useCases.moveNoteToFolderUseCase( folderId = event.targetDirectoryId, @@ -70,6 +73,7 @@ class NotesViewModel( ) } } + is NotesUiEvent.ToggleNoteFavorite -> toggleNoteFavorite(event.noteId) NotesUiEvent.BackToDirectoryNotes -> backToDirectoryNotes() is NotesUiEvent.SaveNote -> saveNote(event.note) is NotesUiEvent.DeleteNote -> { @@ -92,7 +96,7 @@ class NotesViewModel( private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { val normalized = event.newName.trim() - if (normalized.isBlank() || event.directoryId == "all" || event.directoryId == RECENT_DIRECTORY_ID) return + if (normalized.isBlank() || isVirtualDirectory(event.directoryId)) return viewModelScope.launch { val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch useCases.updateFolderUseCase(existingFolder.copy(name = normalized)) @@ -100,7 +104,7 @@ class NotesViewModel( } private fun deleteDirectory(directoryId: String) { - if (directoryId == "all" || directoryId == RECENT_DIRECTORY_ID) return + if (isVirtualDirectory(directoryId)) return viewModelScope.launch { useCases.deleteFolderUseCase(directoryId) if ((uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory?.id == directoryId) { @@ -148,7 +152,8 @@ class NotesViewModel( val normalizedQuery = searchQuery.trim() return if (normalizedQuery.isBlank()) { when (directory.id) { - "all" -> useCases.observeNotesUseCase() + ALL_DIRECTORY_ID -> useCases.observeNotesUseCase() + FAVORITES_DIRECTORY_ID -> useCases.getAllFavoritesUseCase() RECENT_DIRECTORY_ID -> useCases.observeNotesUseCase().map { notes -> notes.sortedByDescending { it.updatedAt } @@ -162,6 +167,8 @@ class NotesViewModel( folderId = directory.folderIdForSearch(), ) when (directory.id) { + FAVORITES_DIRECTORY_ID -> + searchFlow.map { notes -> notes.filter { it.isFavorite } } RECENT_DIRECTORY_ID -> searchFlow.map { notes -> notes.sortedByDescending { it.updatedAt } @@ -206,6 +213,22 @@ class NotesViewModel( startNotesCollection(directory, uiState.notesSearchQuery) } + private fun toggleNoteFavorite(noteId: String) { + viewModelScope.launch { + useCases.switchFavoriteUseCase(noteId) + val editor = uiState.screen as? NotesUiScreen.NoteEditor + if (editor?.note?.id == noteId) { + uiState = + uiState.copy( + screen = + editor.copy( + note = editor.note.copy(isFavorite = !editor.note.isFavorite), + ), + ) + } + } + } + private fun saveNote(note: NoteItemUi) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { @@ -225,10 +248,17 @@ class NotesViewModel( val countsByFolderId = latestNotes.groupingBy { it.folderId }.eachCount() val allNotesCount = latestNotes.size - val allNotesDir = DirectoryItemUi(id = "all", name = "All Notes", noteCount = allNotesCount) + val favoritesCount = latestNotes.count { it.isFavorite } + val allNotesDir = DirectoryItemUi(id = ALL_DIRECTORY_ID, name = "All Notes", noteCount = allNotesCount) + val favoritesDir = + DirectoryItemUi( + id = FAVORITES_DIRECTORY_ID, + name = "Favorites", + noteCount = favoritesCount, + ) val directories = - listOf(allNotesDir) + + listOf(allNotesDir, favoritesDir) + latestFolders.map { folder -> val count = countsByFolderId[folder.id] ?: 0 folder.toUi(noteCount = count) @@ -264,13 +294,14 @@ internal fun Note.toUi(): NoteItemUi = .filterIsInstance() .joinToString("\n") { it.text }, folderId = folderId, - attachments = contentItems.filterNot { it is ContentItem.Text }, + attachments = contentItems.withoutTextItems(), + isFavorite = isFavorite, ) internal fun NoteItemUi.toContentItems(): List = buildList { if (content.isNotBlank()) add(ContentItem.Text(content)) - addAll(attachments) + addAll(attachments.withoutTextItems()) } internal fun NoteItemUi.toDomain(folderId: String?): Note = @@ -279,6 +310,7 @@ internal fun NoteItemUi.toDomain(folderId: String?): Note = title = title, folderId = folderId, contentItems = toContentItems(), + isFavorite = isFavorite, ) internal fun Note.applyUiUpdate( @@ -289,20 +321,23 @@ internal fun Note.applyUiUpdate( title = ui.title, folderId = targetFolderId, contentItems = ui.toContentItems(), + isFavorite = ui.isFavorite, ) internal fun String.asDomainFolderId(): String? = when (this) { - "all", + ALL_DIRECTORY_ID, RECENT_DIRECTORY_ID, + FAVORITES_DIRECTORY_ID, -> null else -> this } internal fun DirectoryItemUi.folderIdForSearch(): String? = when (id) { - "all", + ALL_DIRECTORY_ID, RECENT_DIRECTORY_ID, + FAVORITES_DIRECTORY_ID, -> null else -> id } diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index 09a6ccf7..3dff26ae 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -40,10 +40,13 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.AddPhotoAlternate +import androidx.compose.material.icons.rounded.BrokenImage import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -80,13 +83,13 @@ import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import coil.request.ImageRequest import com.itlab.domain.model.ContentItem -import com.itlab.domain.model.DataSource import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import com.itlab.notes.media.ImageRegionLuminance import com.itlab.notes.media.NoteMediaImport +import com.itlab.notes.media.imageAttachments +import com.itlab.notes.media.toCoilModel import com.itlab.notes.ui.asDomainFolderId import com.itlab.notes.ui.notes.NoteItemUi -import java.io.File import org.koin.compose.koinInject private const val EDITOR_TOP_BAR_TITLE_MAX_LENGTH = 35 @@ -95,7 +98,7 @@ private val EditorHorizontalGutter = 15.dp private val EditorHorizontalContentPadding = 15.dp private data class EditorAttachmentsViewerState( - val attachments: List, + val images: List, val initialIndex: Int, ) @@ -117,6 +120,7 @@ fun editorScreen( note: NoteItemUi, onBack: () -> Unit, onSave: (NoteItemUi) -> Unit, + onToggleFavorite: () -> Unit, ) { val colors = MaterialTheme.colorScheme val context = LocalContext.current @@ -137,6 +141,10 @@ fun editorScreen( ) } + LaunchedEffect(note.isFavorite) { + editorVm.syncFavoriteFromNote(note.isFavorite) + } + val pickImages = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(), @@ -152,7 +160,9 @@ fun editorScreen( editorTopBar( directoryName = directoryName, title = editorVm.title, + isFavorite = note.isFavorite, onBack = onBack, + onToggleFavorite = onToggleFavorite, onAddImage = { pickImages.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), @@ -191,11 +201,13 @@ fun editorScreen( onTitleChange = editorVm::onTitleChange, onContentChange = editorVm::onContentChange, onAttachmentClick = { item -> - val index = editorVm.attachments.indexOfFirst { it.id == item.id } + if (item !is ContentItem.Image) return@editorContent + val images = editorVm.attachments.imageAttachments() + val index = images.indexOfFirst { it.id == item.id } if (index >= 0) { attachmentsViewer = EditorAttachmentsViewerState( - attachments = editorVm.attachments, + images = images, initialIndex = index, ) } @@ -216,7 +228,7 @@ fun editorScreen( attachmentsViewer?.let { viewer -> editorFullScreenAttachmentsViewer( - attachments = viewer.attachments, + images = viewer.images, initialIndex = viewer.initialIndex, onDismiss = { attachmentsViewer = null }, ) @@ -228,7 +240,9 @@ fun editorScreen( private fun editorTopBar( directoryName: String, title: String, + isFavorite: Boolean, onBack: () -> Unit, + onToggleFavorite: () -> Unit, onAddImage: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -255,6 +269,18 @@ private fun editorTopBar( } }, actions = { + IconButton(onClick = onToggleFavorite) { + Icon( + imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + contentDescription = + if (isFavorite) { + "Remove from favorites" + } else { + "Add to favorites" + }, + tint = if (isFavorite) colors.primary else colors.onSurface, + ) + } IconButton(onClick = onAddImage) { Icon( Icons.Rounded.AddPhotoAlternate, @@ -445,9 +471,10 @@ private fun editorImageThumbnail( onRemove: (ContentItem) -> Unit, ) { val context = LocalContext.current + val colors = MaterialTheme.colorScheme val model = remember(image.id, image.source.localPath, image.source.remoteUrl) { - imageDataForCoil(image.source) + image.source.toCoilModel() } var closeIconTint by remember(image.id) { mutableStateOf(Color.White) } Box { @@ -478,7 +505,19 @@ private fun editorImageThumbnail( ImageRegionLuminance.isTopEndRegionLight(state.result.drawable) closeIconTint = if (isLightRegion) Color.Black else Color.White }, + onError = { closeIconTint = colors.onSurfaceVariant }, ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.BrokenImage, + contentDescription = null, + tint = colors.onSurfaceVariant, + ) + } } } IconButton( @@ -500,20 +539,20 @@ private fun editorImageThumbnail( @OptIn(ExperimentalFoundationApi::class) @Composable private fun editorFullScreenAttachmentsViewer( - attachments: List, + images: List, initialIndex: Int, onDismiss: () -> Unit, ) { - if (attachments.isEmpty()) return + if (images.isEmpty()) return val context = LocalContext.current val colors = MaterialTheme.colorScheme val overlayBackground = colors.surface.copy(alpha = 0.88f) - val safeInitialIndex = initialIndex.coerceIn(0, attachments.lastIndex) + val safeInitialIndex = initialIndex.coerceIn(0, images.lastIndex) val pagerState = rememberPagerState( initialPage = safeInitialIndex, - pageCount = { attachments.size }, + pageCount = { images.size }, ) Dialog( @@ -542,97 +581,55 @@ private fun editorFullScreenAttachmentsViewer( .fillMaxSize() .padding(horizontal = 8.dp, vertical = 48.dp), ) { page -> - when (val item = attachments[page]) { - is ContentItem.Image -> { - val model = - remember(item.id, item.source.localPath, item.source.remoteUrl) { - imageDataForCoil(item.source) - } - BoxWithConstraints( - modifier = - Modifier - .fillMaxSize() - .clickable( - interactionSource = remember(page) { MutableInteractionSource() }, - indication = null, - ) { onDismiss() }, - contentAlignment = Alignment.Center, - ) { - if (model != null) { - val absorbImageTap = remember(item.id) { MutableInteractionSource() } - AsyncImage( - model = - ImageRequest.Builder(context) - .data(model) - .crossfade(false) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = - Modifier - .sizeIn(maxWidth = maxWidth, maxHeight = maxHeight) - .wrapContentSize() - .clickable( - interactionSource = absorbImageTap, - indication = null, - onClick = {}, - ), - ) - } - } + val item = images[page] + val model = + remember(item.id, item.source.localPath, item.source.remoteUrl) { + item.source.toCoilModel() } - is ContentItem.File -> - Box( - modifier = - Modifier - .fillMaxSize() - .clickable( - interactionSource = remember(page) { MutableInteractionSource() }, - indication = null, - ) { onDismiss() }, - contentAlignment = Alignment.Center, - ) { - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium, - color = colors.onSurface, - modifier = - Modifier.clickable( - interactionSource = remember(item.id) { MutableInteractionSource() }, - indication = null, - onClick = {}, - ), - ) - } - is ContentItem.Link -> - Box( + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember(page) { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + if (model != null) { + val absorbImageTap = remember(item.id) { MutableInteractionSource() } + AsyncImage( + model = + ImageRequest.Builder(context) + .data(model) + .crossfade(false) + .allowHardware(false) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxSize() + .sizeIn(maxWidth = maxWidth, maxHeight = maxHeight) + .wrapContentSize() .clickable( - interactionSource = remember(page) { MutableInteractionSource() }, - indication = null, - ) { onDismiss() }, - contentAlignment = Alignment.Center, - ) { - Text( - text = item.title ?: item.url, - style = MaterialTheme.typography.titleMedium, - color = colors.onSurface, - modifier = - Modifier.clickable( - interactionSource = remember(item.id) { MutableInteractionSource() }, + interactionSource = absorbImageTap, indication = null, onClick = {}, ), - ) - } - is ContentItem.Text -> { } + ) + } else { + Icon( + imageVector = Icons.Rounded.BrokenImage, + contentDescription = null, + tint = colors.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } } } - if (attachments.size > 1) { + if (images.size > 1) { Text( - text = "${pagerState.currentPage + 1} / ${attachments.size}", + text = "${pagerState.currentPage + 1} / ${images.size}", style = MaterialTheme.typography.labelLarge, color = colors.onSurfaceVariant, modifier = @@ -658,13 +655,6 @@ private fun editorFullScreenAttachmentsViewer( } } -private fun imageDataForCoil(source: DataSource): Any? = - when { - !source.localPath.isNullOrBlank() -> File(source.localPath!!) - !source.remoteUrl.isNullOrBlank() -> source.remoteUrl!! - else -> null - } - @Composable private fun editorAiTagsBar( tags: List, diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt index b41cdd4b..63e7411d 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.itlab.domain.model.ContentItem +import com.itlab.notes.media.withoutTextItems import com.itlab.notes.ui.notes.NoteItemUi class EditorViewModel( @@ -18,9 +19,16 @@ class EditorViewModel( var content: String by mutableStateOf(initialNote.content) private set - var attachments: List by mutableStateOf(initialNote.attachments) + var attachments: List by mutableStateOf(initialNote.attachments.withoutTextItems()) private set + var isFavorite: Boolean by mutableStateOf(initialNote.isFavorite) + private set + + fun syncFavoriteFromNote(value: Boolean) { + isFavorite = value + } + fun onTitleChange(newTitle: String) { title = newTitle } @@ -49,5 +57,6 @@ class EditorViewModel( content = content, folderId = folderId, attachments = attachments, + isFavorite = isFavorite, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index f436c653..3edea8bb 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.FolderCopy import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button @@ -75,8 +76,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties -private const val RECENT_DIRECTORY_ID = "recent" - @OptIn(ExperimentalMaterial3Api::class) @Composable fun directoriesScreen( @@ -329,8 +328,13 @@ private fun directoriesList( var pendingRename by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current - val allNotesDirectory = remember(directories) { directories.firstOrNull { it.id == "all" } } - val regularDirectories = remember(directories) { directories.filter { it.id != "all" } } + val allNotesDirectory = remember(directories) { directories.firstOrNull { it.id == ALL_DIRECTORY_ID } } + val favoritesDirectory = + remember(directories) { directories.firstOrNull { it.id == FAVORITES_DIRECTORY_ID } } + val regularDirectories = + remember(directories) { + directories.filter { it.id != ALL_DIRECTORY_ID && it.id != FAVORITES_DIRECTORY_ID } + } val totalNotesCount = allNotesDirectory?.noteCount ?: directories.sumOf { it.noteCount } val recentDirectory = @@ -373,6 +377,9 @@ private fun directoriesList( allNotesDirectory?.let { allNotes -> addSection("Everything", listOf(allNotes)) } + favoritesDirectory?.let { favorites -> + addSection("Favorites", listOf(favorites)) + } addSection("Continue working", listOf(recentDirectory)) addSection("Regular directories", regularDirectories) @@ -566,8 +573,7 @@ fun directorySearchBar( ) } -private fun isSpecialDirectory(directoryId: String): Boolean = - directoryId == "all" || directoryId == RECENT_DIRECTORY_ID +private fun isSpecialDirectory(directoryId: String): Boolean = isVirtualDirectory(directoryId) @OptIn(ExperimentalFoundationApi::class) @Composable @@ -578,7 +584,8 @@ private fun directoryRow( modifier: Modifier = Modifier, ) { val colors = MaterialTheme.colorScheme - val isAllNotes = directory.id == "all" + val isAllNotes = directory.id == ALL_DIRECTORY_ID + val isFavorites = directory.id == FAVORITES_DIRECTORY_ID Row( modifier = modifier @@ -594,11 +601,17 @@ private fun directoryRow( imageVector = when { directory.id == RECENT_DIRECTORY_ID -> Icons.Rounded.Schedule + isFavorites -> Icons.Rounded.Star isAllNotes -> Icons.Rounded.AllInbox else -> Icons.Rounded.Folder }, contentDescription = null, - tint = if (isAllNotes) colors.primary else colors.onSurfaceVariant, + tint = + if (isAllNotes || isFavorites) { + colors.primary + } else { + colors.onSurfaceVariant + }, modifier = Modifier.size(25.dp), ) Spacer(Modifier.width(12.dp)) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt new file mode 100644 index 00000000..9660d5a4 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt @@ -0,0 +1,10 @@ +package com.itlab.notes.ui.notes + +internal const val ALL_DIRECTORY_ID = "all" +internal const val RECENT_DIRECTORY_ID = "recent" +internal const val FAVORITES_DIRECTORY_ID = "favorites" + +internal fun isVirtualDirectory(directoryId: String): Boolean = + directoryId == ALL_DIRECTORY_ID || + directoryId == RECENT_DIRECTORY_ID || + directoryId == FAVORITES_DIRECTORY_ID diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt index fbb8a14c..17da6bac 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt @@ -8,4 +8,5 @@ data class NoteItemUi( val content: String, val folderId: String? = null, val attachments: List = emptyList(), + val isFavorite: Boolean = false, ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 71d72711..ebce0db1 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -229,7 +229,7 @@ private fun notesMoveNotesDialog( ) { val moveTargets = remember(directories, currentDirectoryId) { - directories.filter { it.id != "all" && it.id != currentDirectoryId } + directories.filter { !isVirtualDirectory(it.id) && it.id != currentDirectoryId } } universalBasicAlertDialog( onDismissRequest = onDismissRequest, From 2b9af7ffd6ce990091eaedd7d78a10b6e9259cf4 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 18:46:09 +0300 Subject: [PATCH 20/35] style: some improvments --- .../main/java/com/itlab/notes/di/AppModule.kt | 3 + .../main/java/com/itlab/notes/ui/NotesApp.kt | 3 + .../com/itlab/notes/ui/NotesUiContract.kt | 5 + .../java/com/itlab/notes/ui/NotesUseCases.kt | 2 + .../java/com/itlab/notes/ui/NotesViewModel.kt | 38 ++- .../com/itlab/notes/ui/editor/EditorScreen.kt | 270 +++++++++++++++--- .../com/itlab/notes/ui/editor/_write_test.txt | 1 - .../com/itlab/notes/ui/notes/AppEmptyState.kt | 98 +++++++ .../itlab/notes/ui/notes/DirectoriesScreen.kt | 154 +++++----- .../com/itlab/notes/ui/notes/DirectoryIds.kt | 5 + .../com/itlab/notes/ui/notes/NotesScreen.kt | 33 ++- 11 files changed, 476 insertions(+), 136 deletions(-) delete mode 100644 app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt create mode 100644 app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index eeaa4984..38e6975a 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -8,6 +8,7 @@ import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase +import com.itlab.domain.usecase.noteusecase.GetNoteUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase @@ -37,6 +38,7 @@ val appModule = factory { SearchNotesUseCase(get()) } factory { SwitchFavoriteUseCase(get()) } factory { GetAllFavoritesUseCase(get()) } + factory { GetNoteUseCase(get()) } factory { UpdateFolderUseCase(get()) } factory { GetFolderUseCase(get()) } factory { @@ -55,6 +57,7 @@ val appModule = searchNotesUseCase = get(), switchFavoriteUseCase = get(), getAllFavoritesUseCase = get(), + getNoteUseCase = get(), ) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index c8e9b1bd..d67de5ae 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -76,6 +76,9 @@ fun notesApp() { directoryId = screen.directory.id, note = screen.note, onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectoryNotes) }, + onPersist = { draft -> + viewModel.onEvent(NotesUiEvent.PersistNote(draft)) + }, onSave = { updated -> viewModel.onEvent(NotesUiEvent.SaveNote(updated)) }, diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt index e66732a9..60ca2db3 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt @@ -55,6 +55,11 @@ sealed interface NotesUiEvent { val note: NoteItemUi, ) : NotesUiEvent + /** Persists editor changes without leaving the editor screen. */ + data class PersistNote( + val note: NoteItemUi, + ) : NotesUiEvent + data class DeleteNote( val noteId: String, ) : NotesUiEvent diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt index 008fd470..24e9bb0a 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt @@ -8,6 +8,7 @@ import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase +import com.itlab.domain.usecase.noteusecase.GetNoteUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase @@ -30,4 +31,5 @@ data class NotesUseCases( val searchNotesUseCase: SearchNotesUseCase, val switchFavoriteUseCase: SwitchFavoriteUseCase, val getAllFavoritesUseCase: GetAllFavoritesUseCase, + val getNoteUseCase: GetNoteUseCase, ) diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 5383c5ef..0e6326a0 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -14,6 +14,7 @@ import com.itlab.notes.ui.notes.DirectoryItemUi import com.itlab.notes.ui.notes.FAVORITES_DIRECTORY_ID import com.itlab.notes.ui.notes.NoteItemUi import com.itlab.notes.ui.notes.RECENT_DIRECTORY_ID +import com.itlab.notes.ui.notes.coerceDirectoryNameLength import com.itlab.notes.ui.notes.isVirtualDirectory import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -55,7 +56,7 @@ class NotesViewModel( is NotesUiEvent.OpenNote -> openNote(event.note) NotesUiEvent.CreateNote -> createNote() is NotesUiEvent.CreateDirectory -> { - val normalized = event.name.trim() + val normalized = event.name.trim().coerceDirectoryNameLength() if (normalized.isNotBlank()) { viewModelScope.launch { useCases.createFolderUseCase(NoteFolder(name = normalized)) @@ -76,6 +77,7 @@ class NotesViewModel( is NotesUiEvent.ToggleNoteFavorite -> toggleNoteFavorite(event.noteId) NotesUiEvent.BackToDirectoryNotes -> backToDirectoryNotes() is NotesUiEvent.SaveNote -> saveNote(event.note) + is NotesUiEvent.PersistNote -> persistNote(event.note) is NotesUiEvent.DeleteNote -> { viewModelScope.launch { useCases.deleteNoteUseCase(event.noteId) @@ -95,7 +97,7 @@ class NotesViewModel( } private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { - val normalized = event.newName.trim() + val normalized = event.newName.trim().coerceDirectoryNameLength() if (normalized.isBlank() || isVirtualDirectory(event.directoryId)) return viewModelScope.launch { val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch @@ -229,21 +231,39 @@ class NotesViewModel( } } + private fun persistNote(note: NoteItemUi) { + val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return + viewModelScope.launch { + persistNoteToRepository(note, editor.directory) + } + } + private fun saveNote(note: NoteItemUi) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { - val targetFolderId = note.folderId ?: editor.directory.id.asDomainFolderId() - val existing = latestNotes.firstOrNull { it.id == note.id } - if (existing != null) { - useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)) - } else { - useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) - } + persistNoteToRepository(note, editor.directory) uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) startNotesCollection(editor.directory, uiState.notesSearchQuery) } } + private suspend fun persistNoteToRepository( + note: NoteItemUi, + directory: DirectoryItemUi, + ) { + val targetFolderId = note.folderId ?: directory.id.asDomainFolderId() + val existing = useCases.getNoteUseCase(note.id) + if (existing != null) { + useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)) + } else { + useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) + } + val editor = uiState.screen as? NotesUiScreen.NoteEditor + if (editor?.note?.id == note.id) { + uiState = uiState.copy(screen = editor.copy(note = note)) + } + } + private fun recomputeDirectories() { val countsByFolderId = latestNotes.groupingBy { it.folderId }.eachCount() val allNotesCount = latestNotes.size diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index 3dff26ae..b852540a 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -1,6 +1,7 @@ package com.itlab.notes.ui.editor import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn @@ -33,6 +35,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -63,6 +66,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -72,9 +76,16 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import kotlin.math.roundToInt import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -90,12 +101,17 @@ import com.itlab.notes.media.imageAttachments import com.itlab.notes.media.toCoilModel import com.itlab.notes.ui.asDomainFolderId import com.itlab.notes.ui.notes.NoteItemUi +import kotlinx.coroutines.delay import org.koin.compose.koinInject private const val EDITOR_TOP_BAR_TITLE_MAX_LENGTH = 35 private val EditorHorizontalGutter = 15.dp private val EditorHorizontalContentPadding = 15.dp +private val EditorContentScrollBottomInset = 120.dp +private val EditorContentScrollTopInset = 16.dp +private val EditorContentFieldMinHeight = 160.dp +private const val EditorAutosaveDebounceMs = 600L private data class EditorAttachmentsViewerState( val images: List, @@ -119,19 +135,36 @@ fun editorScreen( directoryId: String, note: NoteItemUi, onBack: () -> Unit, + onPersist: (NoteItemUi) -> Unit, onSave: (NoteItemUi) -> Unit, onToggleFavorite: () -> Unit, ) { val colors = MaterialTheme.colorScheme val context = LocalContext.current val validateDuplicateTitle: ValidateDuplicateNoteTitleUseCase = koinInject() - val editorVm = remember(note.id) { EditorViewModel(initialNote = note) } + val initialNote = remember(note.id) { note } + val editorVm = remember(note.id) { EditorViewModel(initialNote = initialNote) } var attachmentsViewer by remember { mutableStateOf(null) } val targetFolderId = note.folderId ?: directoryId.asDomainFolderId() var titleDuplicate by remember { mutableStateOf(false) } val trimmedTitle = editorVm.title.trim() val titleHasDuplicate = titleDuplicate && trimmedTitle.isNotEmpty() + fun persistDraftIfNeeded(force: Boolean = false) { + if (titleHasDuplicate) return + val draft = editorVm.buildUpdatedNote() + if (!force && draft == initialNote) return + onPersist(draft) + } + + LaunchedEffect(editorVm.title, editorVm.content, editorVm.attachments, titleHasDuplicate) { + if (editorVm.buildUpdatedNote() == initialNote) return@LaunchedEffect + delay(EditorAutosaveDebounceMs) + if (!titleHasDuplicate) { + persistDraftIfNeeded() + } + } + LaunchedEffect(editorVm.title, targetFolderId, note.id) { titleDuplicate = validateDuplicateTitle( @@ -145,6 +178,19 @@ fun editorScreen( editorVm.syncFavoriteFromNote(note.isFavorite) } + val leaveEditor = { + persistDraftIfNeeded() + onBack() + } + + BackHandler { + if (attachmentsViewer != null) { + attachmentsViewer = null + } else { + leaveEditor() + } + } + val pickImages = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(), @@ -152,6 +198,7 @@ fun editorScreen( if (uris.isEmpty()) return@rememberLauncherForActivityResult val imported = NoteMediaImport.importImagesFromUris(context, uris) editorVm.addAttachments(imported) + persistDraftIfNeeded(force = true) } Scaffold( @@ -161,7 +208,7 @@ fun editorScreen( directoryName = directoryName, title = editorVm.title, isFavorite = note.isFavorite, - onBack = onBack, + onBack = leaveEditor, onToggleFavorite = onToggleFavorite, onAddImage = { pickImages.launch( @@ -173,7 +220,7 @@ fun editorScreen( floatingActionButton = { editorFab( onClick = { onSave(editorVm.buildUpdatedNote()) }, - enabled = !titleHasDuplicate, + enabled = trimmedTitle.isNotEmpty() && !titleHasDuplicate, ) }, ) { paddingValues -> @@ -217,6 +264,7 @@ fun editorScreen( NoteMediaImport.deleteImportedFileIfOwned(context, item.source.localPath) } editorVm.removeAttachment(item.id) + persistDraftIfNeeded(force = true) }, modifier = Modifier @@ -347,21 +395,16 @@ private fun editorContent( ) } - editorTitleField( - value = title, - onValueChange = onTitleChange, + editorTitleSection( + title = title, + titleHasDuplicate = titleHasDuplicate, + onTitleChange = onTitleChange, ) - if (titleHasDuplicate) { - Text( - text = "A note with that name already exists.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } editorContentField( value = content, onValueChange = onContentChange, + scrollState = scrollState, modifier = Modifier.padding(top = 12.dp), ) @@ -787,40 +830,121 @@ private fun editorPlainTextField( minLines = if (singleLine) 1 else minLines, interactionSource = interactionSource, decorationBox = { innerTextField -> - TextFieldDefaults.DecorationBox( + editorPlainTextFieldDecoration( value = value, - innerTextField = innerTextField, - enabled = true, + placeholder = placeholder, + textStyle = textStyle, singleLine = singleLine, - visualTransformation = VisualTransformation.None, interactionSource = interactionSource, - placeholder = { - Text( - text = placeholder, - style = textStyle, - color = colors.onSurfaceVariant, - ) - }, - colors = - TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - ), - contentPadding = - PaddingValues( - horizontal = EditorHorizontalContentPadding, - vertical = 8.dp, - ), + innerTextField = innerTextField, ) }, ) } +@Composable +private fun editorPlainTextFieldDecoration( + value: String, + placeholder: String, + textStyle: TextStyle, + singleLine: Boolean, + interactionSource: MutableInteractionSource, + innerTextField: @Composable () -> Unit, +) { + val colors = MaterialTheme.colorScheme + TextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + enabled = true, + singleLine = singleLine, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = { + Text( + text = placeholder, + style = textStyle, + color = colors.onSurfaceVariant, + ) + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + contentPadding = + PaddingValues( + horizontal = EditorHorizontalContentPadding, + vertical = 8.dp, + ), + ) +} + +private fun editorScrollTargetForCursor( + scrollState: ScrollState, + fieldTopInScrollPx: Float, + textLayoutResult: TextLayoutResult, + cursorOffset: Int, + bottomInsetPx: Float, + topInsetPx: Float, +): Int? { + if (scrollState.viewportSize <= 0) return null + val safeOffset = cursorOffset.coerceIn(0, textLayoutResult.layoutInput.text.length) + val cursorRect = textLayoutResult.getCursorRect(safeOffset) + val cursorTopInScroll = fieldTopInScrollPx + cursorRect.top + val cursorBottomInScroll = fieldTopInScrollPx + cursorRect.bottom + val viewportTop = scrollState.value.toFloat() + val viewportBottom = viewportTop + scrollState.viewportSize - bottomInsetPx + + val targetScroll = + when { + cursorBottomInScroll > viewportBottom -> + (cursorBottomInScroll - scrollState.viewportSize + bottomInsetPx) + .roundToInt() + .coerceIn(0, scrollState.maxValue) + cursorTopInScroll < viewportTop + topInsetPx -> + (cursorTopInScroll - topInsetPx).roundToInt().coerceIn(0, scrollState.maxValue) + else -> return null + } + return targetScroll.takeIf { it != scrollState.value } +} + +private val EditorTitleDuplicateErrorLineHeight = 20.dp + +@Composable +private fun editorTitleSection( + title: String, + titleHasDuplicate: Boolean, + onTitleChange: (String) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + editorTitleField( + value = title, + onValueChange = onTitleChange, + ) + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = EditorHorizontalContentPadding) + .heightIn(min = EditorTitleDuplicateErrorLineHeight) + .padding(top = 4.dp), + ) { + if (titleHasDuplicate) { + Text( + text = "A note with that name already exists.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + @Composable private fun editorTitleField( value: String, @@ -839,14 +963,68 @@ private fun editorTitleField( private fun editorContentField( value: String, onValueChange: (String) -> Unit, + scrollState: ScrollState, modifier: Modifier = Modifier, ) { - editorPlainTextField( - value = value, - onValueChange = onValueChange, - placeholder = "Input", - modifier = modifier, - minLines = 12, - textStyle = MaterialTheme.typography.bodyLarge, + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val interactionSource = remember { MutableInteractionSource() } + val textStyle = MaterialTheme.typography.bodyLarge + val bottomInsetPx = with(density) { EditorContentScrollBottomInset.toPx() } + val topInsetPx = with(density) { EditorContentScrollTopInset.toPx() } + + var textFieldValue by remember { mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length))) } + var textLayoutResult by remember { mutableStateOf(null) } + var fieldTopInScrollPx by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(value) { + if (textFieldValue.text != value) { + textFieldValue = TextFieldValue(text = value, selection = TextRange(value.length)) + } + } + + LaunchedEffect(textFieldValue, textLayoutResult, fieldTopInScrollPx, scrollState.viewportSize) { + val layout = textLayoutResult ?: return@LaunchedEffect + val targetScroll = + editorScrollTargetForCursor( + scrollState = scrollState, + fieldTopInScrollPx = fieldTopInScrollPx, + textLayoutResult = layout, + cursorOffset = textFieldValue.selection.end, + bottomInsetPx = bottomInsetPx, + topInsetPx = topInsetPx, + ) ?: return@LaunchedEffect + scrollState.animateScrollTo(targetScroll) + } + + BasicTextField( + value = textFieldValue, + onValueChange = { updated -> + textFieldValue = updated + if (updated.text != value) { + onValueChange(updated.text) + } + }, + onTextLayout = { textLayoutResult = it }, + modifier = + modifier + .fillMaxWidth() + .sizeIn(minHeight = EditorContentFieldMinHeight) + .onGloballyPositioned { coordinates -> + fieldTopInScrollPx = coordinates.positionInParent().y + }, + textStyle = textStyle.copy(color = colors.onSurface), + cursorBrush = SolidColor(colors.primary), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + editorPlainTextFieldDecoration( + value = textFieldValue.text, + placeholder = "Input", + textStyle = textStyle, + singleLine = false, + interactionSource = interactionSource, + innerTextField = innerTextField, + ) + }, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt b/app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt deleted file mode 100644 index 9daeafb9..00000000 --- a/app/src/main/java/com/itlab/notes/ui/editor/_write_test.txt +++ /dev/null @@ -1 +0,0 @@ -test diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt new file mode 100644 index 00000000..f556a811 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt @@ -0,0 +1,98 @@ +package com.itlab.notes.ui.notes + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.SearchOff +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun appEmptyState( + icon: ImageVector, + title: String, + message: String, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .clip(MaterialTheme.shapes.medium) + .background(colors.surfaceContainer), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.padding(14.dp).size(32.dp), + tint = colors.onSurfaceVariant, + ) + } + Spacer(Modifier.height(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 50.dp), + ) + } +} + +@Composable +fun notesSearchEmptyState(modifier: Modifier = Modifier) { + appEmptyState( + icon = Icons.Rounded.SearchOff, + title = "No results found", + message = "Try a different search term or check another folder.", + modifier = modifier, + ) +} + +@Composable +fun directoriesSearchEmptyState(modifier: Modifier = Modifier) { + appEmptyState( + icon = Icons.Rounded.SearchOff, + title = "No results found", + message = "Try a different search term.", + modifier = modifier, + ) +} + +@Composable +fun directoriesEmptyState( + modifier: Modifier = Modifier, +) { + appEmptyState( + icon = Icons.Rounded.Folder, + title = "No directories yet", + message = "Tap + to create a directory and organize your notes.", + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 3edea8bb..c04554de 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -2,6 +2,7 @@ package com.itlab.notes.ui.notes +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -56,6 +57,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,6 +66,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector @@ -72,10 +76,13 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties +private const val DIRECTORY_NAME_TAKEN_ERROR = "A directory with this name already exists" + @OptIn(ExperimentalMaterial3Api::class) @Composable fun directoriesScreen( @@ -91,6 +98,10 @@ fun directoriesScreen( val focusManager = LocalFocusManager.current var showCreateDialog by remember { mutableStateOf(false) } + BackHandler(enabled = showCreateDialog) { + showCreateDialog = false + } + Scaffold( modifier = Modifier @@ -162,12 +173,8 @@ private fun directoriesCreateDirectoryDialog( onValueChange = { directoryName = it }, placeholderText = "Enter directory name...", isError = nameAlreadyExists, - errorMessage = - if (nameAlreadyExists) { - "Папка с таким названием уже существует" - } else { - null - }, + errorMessage = if (nameAlreadyExists) DIRECTORY_NAME_TAKEN_ERROR else null, + requestInitialFocus = true, ) }, actions = { @@ -328,6 +335,13 @@ private fun directoriesList( var pendingRename by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current + BackHandler(enabled = pendingRename != null || pendingDelete != null) { + when { + pendingRename != null -> pendingRename = null + pendingDelete != null -> pendingDelete = null + } + } + val allNotesDirectory = remember(directories) { directories.firstOrNull { it.id == ALL_DIRECTORY_ID } } val favoritesDirectory = remember(directories) { directories.firstOrNull { it.id == FAVORITES_DIRECTORY_ID } } @@ -341,6 +355,7 @@ private fun directoriesList( remember(totalNotesCount) { DirectoryItemUi(id = RECENT_DIRECTORY_ID, name = "Recent", noteCount = totalNotesCount) } + val isSearchActive = searchQuery.isNotBlank() Column( modifier = modifier.fillMaxSize().clearFocusOnTap(focusManager).padding(horizontal = 12.dp), @@ -378,27 +393,40 @@ private fun directoriesList( addSection("Everything", listOf(allNotes)) } favoritesDirectory?.let { favorites -> - addSection("Favorites", listOf(favorites)) + addSection("Favorite notes", listOf(favorites)) + } + if (!isSearchActive) { + addSection("Continue working", listOf(recentDirectory)) } - addSection("Continue working", listOf(recentDirectory)) addSection("Regular directories", regularDirectories) - if (regularDirectories.isEmpty()) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 80.dp) - .heightIn(min = 220.dp), - contentAlignment = Alignment.Center, - ) { - directoriesEmptyPlaceholder( - onOpenAllNotes = { - allNotesDirectory?.let { onDirectoryClick(it) } - }, - openAllNotesEnabled = allNotesDirectory != null, - ) + when { + isSearchActive && directories.isEmpty() -> { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 80.dp) + .heightIn(min = 220.dp), + contentAlignment = Alignment.Center, + ) { + directoriesSearchEmptyState() + } + } + } + !isSearchActive && regularDirectories.isEmpty() -> { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 80.dp) + .heightIn(min = 220.dp), + contentAlignment = Alignment.Center, + ) { + directoriesEmptyState() + } } } } @@ -473,48 +501,6 @@ private fun directoriesHeroPanel( } } -@Composable -private fun directoriesEmptyPlaceholder( - onOpenAllNotes: () -> Unit, - openAllNotesEnabled: Boolean, -) { - val colors = MaterialTheme.colorScheme - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = - Modifier - .clip(MaterialTheme.shapes.medium) - .background(colors.surfaceContainer), - ) { - Icon( - imageVector = Icons.Rounded.Folder, - contentDescription = null, - modifier = Modifier.padding(14.dp).size(32.dp), - tint = colors.onSurfaceVariant, - ) - } - Spacer(Modifier.height(16.dp)) - Text( - text = "No directories yet", - style = MaterialTheme.typography.titleMedium, - color = colors.onSurface, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = - "Tap + to create a directory and organize your notes.", - style = MaterialTheme.typography.bodyMedium, - color = colors.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 50.dp), - ) - } -} - @Composable private fun sectionTitle(title: String) { Text( @@ -620,6 +606,8 @@ private fun directoryRow( color = colors.onSurface, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Surface( color = colors.surfaceVariant, @@ -681,9 +669,11 @@ private fun directoryActionsDialog( }, input = { Text( - "Choose action for \"${directory.name}\"", + text = "Choose action for \"${directory.name}\"", style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) }, actions = { @@ -743,12 +733,8 @@ private fun directoryRenameDialog( onValueChange = { renameName = it }, placeholderText = "Enter directory name...", isError = nameAlreadyExists, - errorMessage = - if (nameAlreadyExists) { - "Папка с таким названием уже существует" - } else { - null - }, + errorMessage = if (nameAlreadyExists) DIRECTORY_NAME_TAKEN_ERROR else null, + requestInitialFocus = true, ) }, actions = { @@ -781,7 +767,14 @@ private fun directoryOutlinedTextField( enabled: Boolean = true, isError: Boolean = false, errorMessage: String? = null, + requestInitialFocus: Boolean = false, ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(requestInitialFocus) { + if (requestInitialFocus) { + focusRequester.requestFocus() + } + } val interactionSource = remember { MutableInteractionSource() } val scheme = MaterialTheme.colorScheme val shape = MaterialTheme.shapes.medium @@ -805,8 +798,17 @@ private fun directoryOutlinedTextField( BasicTextField( value = value, - onValueChange = onValueChange, - modifier = modifier.fillMaxWidth(), + onValueChange = { newValue -> onValueChange(newValue.coerceDirectoryNameLength()) }, + modifier = + modifier + .fillMaxWidth() + .then( + if (requestInitialFocus) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + }, + ), enabled = enabled, textStyle = textStyle, singleLine = true, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt index 9660d5a4..8e5fe7c6 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt @@ -4,6 +4,11 @@ internal const val ALL_DIRECTORY_ID = "all" internal const val RECENT_DIRECTORY_ID = "recent" internal const val FAVORITES_DIRECTORY_ID = "favorites" +/** Max stored/displayed length for user-created folder names. */ +internal const val DIRECTORY_NAME_MAX_LENGTH = 40 + +internal fun String.coerceDirectoryNameLength(): String = take(DIRECTORY_NAME_MAX_LENGTH) + internal fun isVirtualDirectory(directoryId: String): Boolean = directoryId == ALL_DIRECTORY_ID || directoryId == RECENT_DIRECTORY_ID || diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index ebce0db1..46311de7 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -1,5 +1,6 @@ package com.itlab.notes.ui.notes +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -87,13 +88,16 @@ fun notesListScreen( clearSelection() } val handleBack = { - if (isSelectionMode) { - clearSelection() - } else { - actions.onBack() + when { + showDeleteDialog -> showDeleteDialog = false + showMoveDialog -> showMoveDialog = false + isSelectionMode -> clearSelection() + else -> actions.onBack() } } + BackHandler(onBack = handleBack) + Scaffold( containerColor = colors.background, topBar = { @@ -175,6 +179,9 @@ private fun notesTopBar( Text( text = if (selectedCount > 0) "$selectedCount selected" else directoryName, color = colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) }, navigationIcon = { @@ -337,6 +344,8 @@ private fun notesMoveTargetRow( color = colors.onSurface, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -447,10 +456,26 @@ private fun notesListContent( onQueryChange = onSearchQueryChange, ) + val isSearchActive = searchQuery.isNotBlank() + LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 4.dp), ) { + if (notes.isEmpty() && isSearchActive) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 80.dp) + .heightIn(min = 220.dp), + contentAlignment = Alignment.Center, + ) { + notesSearchEmptyState() + } + } + } items( items = notes, key = { note -> note.id }, From da5d96d4dbc969f0beb2a9a2823dd6d2adf58e97 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 19:13:28 +0300 Subject: [PATCH 21/35] fix bugs with ui --- .../main/java/com/itlab/notes/ui/NotesApp.kt | 2 +- .../com/itlab/notes/ui/NotesUiContract.kt | 5 ++ .../java/com/itlab/notes/ui/NotesViewModel.kt | 37 +++++++++++---- .../com/itlab/notes/ui/editor/EditorScreen.kt | 7 ++- .../usecase/noteusecase/CreateNoteUseCase.kt | 10 ++-- .../noteusecase/DuplicateNoteUseCase.kt | 13 ++++- .../noteusecase/MoveNoteToFolderUseCase.kt | 15 +++++- .../noteusecase/ResolveUniqueNoteTitle.kt | 27 +++++++++++ .../java/com/itlab/domain/NoteUseCasesTest.kt | 26 +++++++++- .../domain/ResolveUniqueNoteTitleTest.kt | 47 +++++++++++++++++++ 10 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt create mode 100644 domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index d67de5ae..d17d2e11 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -75,7 +75,7 @@ fun notesApp() { directoryName = screen.directory.name, directoryId = screen.directory.id, note = screen.note, - onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectoryNotes) }, + onBack = { draft -> viewModel.onEvent(NotesUiEvent.LeaveEditor(draft)) }, onPersist = { draft -> viewModel.onEvent(NotesUiEvent.PersistNote(draft)) }, diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt index 60ca2db3..1217c1d7 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt @@ -51,6 +51,11 @@ sealed interface NotesUiEvent { data object BackToDirectoryNotes : NotesUiEvent + /** Saves pending editor changes (if any), then returns to the notes list. */ + data class LeaveEditor( + val note: NoteItemUi, + ) : NotesUiEvent + data class SaveNote( val note: NoteItemUi, ) : NotesUiEvent diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 0e6326a0..6d6498a2 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -76,6 +76,7 @@ class NotesViewModel( } is NotesUiEvent.ToggleNoteFavorite -> toggleNoteFavorite(event.noteId) NotesUiEvent.BackToDirectoryNotes -> backToDirectoryNotes() + is NotesUiEvent.LeaveEditor -> leaveEditor(event.note) is NotesUiEvent.SaveNote -> saveNote(event.note) is NotesUiEvent.PersistNote -> persistNote(event.note) is NotesUiEvent.DeleteNote -> { @@ -238,30 +239,48 @@ class NotesViewModel( } } + private fun leaveEditor(note: NoteItemUi) { + val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return + viewModelScope.launch { + if (note.title.trim().isNotEmpty()) { + persistNoteToRepository(note, editor.directory) + } + navigateBackToDirectoryNotes(editor.directory) + } + } + private fun saveNote(note: NoteItemUi) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { - persistNoteToRepository(note, editor.directory) - uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) - startNotesCollection(editor.directory, uiState.notesSearchQuery) + if (!persistNoteToRepository(note, editor.directory)) return@launch + navigateBackToDirectoryNotes(editor.directory) } } + private fun navigateBackToDirectoryNotes(directory: DirectoryItemUi) { + uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = directory)) + startNotesCollection(directory, uiState.notesSearchQuery) + } + private suspend fun persistNoteToRepository( note: NoteItemUi, directory: DirectoryItemUi, - ) { + ): Boolean { + if (note.title.trim().isEmpty()) return false val targetFolderId = note.folderId ?: directory.id.asDomainFolderId() val existing = useCases.getNoteUseCase(note.id) - if (existing != null) { - useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)) - } else { - useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) - } + val result = + if (existing != null) { + useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)) + } else { + useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) + } + if (result.isFailure) return false val editor = uiState.screen as? NotesUiScreen.NoteEditor if (editor?.note?.id == note.id) { uiState = uiState.copy(screen = editor.copy(note = note)) } + return true } private fun recomputeDirectories() { diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index b852540a..e853e0aa 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -134,7 +134,7 @@ fun editorScreen( directoryName: String, directoryId: String, note: NoteItemUi, - onBack: () -> Unit, + onBack: (NoteItemUi) -> Unit, onPersist: (NoteItemUi) -> Unit, onSave: (NoteItemUi) -> Unit, onToggleFavorite: () -> Unit, @@ -151,7 +151,7 @@ fun editorScreen( val titleHasDuplicate = titleDuplicate && trimmedTitle.isNotEmpty() fun persistDraftIfNeeded(force: Boolean = false) { - if (titleHasDuplicate) return + if (titleHasDuplicate || trimmedTitle.isEmpty()) return val draft = editorVm.buildUpdatedNote() if (!force && draft == initialNote) return onPersist(draft) @@ -179,8 +179,7 @@ fun editorScreen( } val leaveEditor = { - persistDraftIfNeeded() - onBack() + onBack(editorVm.buildUpdatedNote()) } BackHandler { diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt index 8c52ad24..8d62d669 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt @@ -2,7 +2,6 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.Note import com.itlab.domain.repository.NotesRepository -import java.util.UUID import kotlin.time.Clock class CreateNoteUseCase( @@ -19,13 +18,12 @@ class CreateNoteUseCase( ) require(!hasDuplicateTitle) { "Note with title '$normalizedTitle' already exists in this folder" } val now = Clock.System.now() - - val note = + val noteToPersist = note.copy( - id = UUID.randomUUID().toString(), - createdAt = now, + title = normalizedTitle, updatedAt = now, ) - repo.createNote(note) + repo.createNote(noteToPersist) + noteToPersist.id } } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt index fd3795b8..9ec189aa 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt @@ -4,6 +4,7 @@ import com.itlab.domain.model.ContentItem import com.itlab.domain.repository.NotesRepository import java.util.UUID import kotlin.time.Clock +import kotlinx.coroutines.flow.first class DuplicateNoteUseCase( private val repo: NotesRepository, @@ -14,11 +15,21 @@ class DuplicateNoteUseCase( repo.getNoteById(noteId) ?: throw IllegalArgumentException("Note not found: $noteId") + val folderId = note.folderId + val existingTitles = + if (folderId != null) { + repo.observeNotesByFolder(folderId).first().map { it.title } + } else { + repo.observeNotes().first().map { it.title } + } + val baseTitle = note.title.trim().ifBlank { "Copy" } + val uniqueTitle = resolveUniqueNoteTitle(baseTitle, existingTitles) + val now = Clock.System.now() val duplicated = note.copy( id = UUID.randomUUID().toString(), - title = if (note.title.isBlank()) "Copy" else "${note.title} Copy", + title = uniqueTitle, createdAt = now, updatedAt = now, contentItems = diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt index 9c264586..14461917 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt @@ -4,6 +4,7 @@ import com.itlab.domain.repository.NoteFolderRepository import com.itlab.domain.repository.NotesRepository import com.itlab.domain.usecase.requireNotBlank import kotlin.time.Clock +import kotlinx.coroutines.flow.first class MoveNoteToFolderUseCase( private val notesRepo: NotesRepository, @@ -18,7 +19,19 @@ class MoveNoteToFolderUseCase( requireNotBlank(folderId, "Folder id") requireNotNull(folderRepo.getFolderById(folderId)) { "Folder not found: $folderId" } val note = notesRepo.getNoteById(noteId) ?: throw IllegalArgumentException("Note not found: $noteId") - val updated = note.copy(folderId = folderId, updatedAt = Clock.System.now()) + val titlesInTargetFolder = + notesRepo + .observeNotesByFolder(folderId) + .first() + .filter { it.id != noteId } + .map { it.title } + val uniqueTitle = resolveUniqueNoteTitle(note.title, titlesInTargetFolder) + val updated = + note.copy( + folderId = folderId, + title = uniqueTitle, + updatedAt = Clock.System.now(), + ) notesRepo.updateNote(updated) } } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt new file mode 100644 index 00000000..06685bd4 --- /dev/null +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt @@ -0,0 +1,27 @@ +package com.itlab.domain.usecase.noteusecase + +/** + * Picks a title that does not collide with [existingTitles] in the same folder (case-insensitive). + * If [desiredTitle] is taken, returns `"$base (1)"`, `"$base (2)"`, … + */ +fun resolveUniqueNoteTitle( + desiredTitle: String, + existingTitles: Iterable, +): String { + val base = desiredTitle.trim().ifBlank { return resolveUniqueNoteTitle("Untitled", existingTitles) } + val taken = + existingTitles + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + + fun isTaken(title: String): Boolean = taken.any { it.equals(title, ignoreCase = true) } + + if (!isTaken(base)) return base + + var index = 1 + while (isTaken("$base ($index)")) { + index++ + } + return "$base ($index)" +} diff --git a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt index 2c61ae37..20144e99 100644 --- a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt +++ b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt @@ -21,6 +21,7 @@ import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -35,7 +36,8 @@ class NoteUseCasesTest { override fun observeNotes() = flow - override fun observeNotesByFolder(folderId: String) = flow + override fun observeNotesByFolder(folderId: String) = + flow.map { notes -> notes.filter { it.folderId == folderId } } override suspend fun getNoteById(id: String): Note? = store[id] @@ -231,7 +233,7 @@ class NoteUseCasesTest { val duplicated = repo.getNoteById(newId) assertEquals(true, duplicated != null) - assertEquals("Hello Copy", duplicated?.title) + assertEquals("Hello (1)", duplicated?.title) assertEquals(setOf("kotlin"), duplicated?.tags) assertEquals(true, duplicated?.isFavorite) assertEquals("summary", duplicated?.summary) @@ -260,6 +262,26 @@ class NoteUseCasesTest { assertEquals("Copy", duplicated?.title) } + @Test + fun moveNoteToFolder_renamesWhenTitleExistsInTargetFolder() = + runBlocking { + val notesRepo = FakeNotesRepo() + val folderRepo = FakeFolderRepo() + + val move = MoveNoteToFolderUseCase(notesRepo, folderRepo) + folderRepo.createFolder(NoteFolder(id = "f1", name = "One")) + folderRepo.createFolder(NoteFolder(id = "f2", name = "Two")) + + notesRepo.createNote(Note(id = "n1", title = "Report", folderId = "f1")) + notesRepo.createNote(Note(id = "n2", title = "Report", folderId = "f2")) + + move("f2", "n1").getOrThrow() + + val moved = notesRepo.getNoteById("n1") + assertEquals("f2", moved?.folderId) + assertEquals("Report (1)", moved?.title) + } + @Test fun duplicateNote_throwsIfNoteNotFound() = runBlocking { diff --git a/domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt b/domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt new file mode 100644 index 00000000..fe563b71 --- /dev/null +++ b/domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt @@ -0,0 +1,47 @@ +package com.itlab.domain + +import com.itlab.domain.usecase.noteusecase.resolveUniqueNoteTitle +import org.junit.Assert.assertEquals +import org.junit.Test + +class ResolveUniqueNoteTitleTest { + @Test + fun returnsDesiredTitleWhenNoConflict() { + assertEquals( + "Meeting", + resolveUniqueNoteTitle("Meeting", listOf("Other")), + ) + } + + @Test + fun appendsIncrementingSuffixWhenBaseTaken() { + assertEquals( + "Meeting (1)", + resolveUniqueNoteTitle("Meeting", listOf("Meeting")), + ) + assertEquals( + "Meeting (2)", + resolveUniqueNoteTitle("Meeting", listOf("Meeting", "Meeting (1)")), + ) + } + + @Test + fun isCaseInsensitive() { + assertEquals( + "Meeting (1)", + resolveUniqueNoteTitle("Meeting", listOf("meeting")), + ) + } + + @Test + fun blankTitleUsesUntitled() { + assertEquals( + "Untitled", + resolveUniqueNoteTitle(" ", emptyList()), + ) + assertEquals( + "Untitled (1)", + resolveUniqueNoteTitle("", listOf("Untitled")), + ) + } +} From 8efe4a67f953acfa12d97d7a851cd93b4eac027b Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 19:36:52 +0300 Subject: [PATCH 22/35] fix: insert text in fields --- .../java/com/itlab/notes/ui/NotesViewModel.kt | 7 +++++-- .../java/com/itlab/notes/ui/SingleLineText.kt | 6 ++++++ .../com/itlab/notes/ui/editor/EditorScreen.kt | 20 ++++++++++++++++++- .../itlab/notes/ui/editor/EditorViewModel.kt | 5 +++-- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 16 +++++++++++++-- .../com/itlab/notes/ui/notes/DirectoryIds.kt | 2 ++ .../com/itlab/notes/ui/notes/NotesScreen.kt | 2 +- 7 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/ui/SingleLineText.kt diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 6d6498a2..1d0e8582 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -10,11 +10,13 @@ import com.itlab.domain.model.Note import com.itlab.domain.model.NoteFolder import com.itlab.notes.media.withoutTextItems import com.itlab.notes.ui.notes.ALL_DIRECTORY_ID +import com.itlab.notes.ui.notes.canCreateNotesInDirectory import com.itlab.notes.ui.notes.DirectoryItemUi import com.itlab.notes.ui.notes.FAVORITES_DIRECTORY_ID import com.itlab.notes.ui.notes.NoteItemUi import com.itlab.notes.ui.notes.RECENT_DIRECTORY_ID import com.itlab.notes.ui.notes.coerceDirectoryNameLength +import com.itlab.notes.ui.toSingleLineText import com.itlab.notes.ui.notes.isVirtualDirectory import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -56,7 +58,7 @@ class NotesViewModel( is NotesUiEvent.OpenNote -> openNote(event.note) NotesUiEvent.CreateNote -> createNote() is NotesUiEvent.CreateDirectory -> { - val normalized = event.name.trim().coerceDirectoryNameLength() + val normalized = event.name.toSingleLineText().trim().coerceDirectoryNameLength() if (normalized.isNotBlank()) { viewModelScope.launch { useCases.createFolderUseCase(NoteFolder(name = normalized)) @@ -98,7 +100,7 @@ class NotesViewModel( } private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { - val normalized = event.newName.trim().coerceDirectoryNameLength() + val normalized = event.newName.toSingleLineText().trim().coerceDirectoryNameLength() if (normalized.isBlank() || isVirtualDirectory(event.directoryId)) return viewModelScope.launch { val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch @@ -201,6 +203,7 @@ class NotesViewModel( private fun createNote() { val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory ?: return + if (!canCreateNotesInDirectory(dir.id)) return notesJob?.cancel() val newNote = Note(folderId = dir.id.asDomainFolderId()).toUi() uiState = diff --git a/app/src/main/java/com/itlab/notes/ui/SingleLineText.kt b/app/src/main/java/com/itlab/notes/ui/SingleLineText.kt new file mode 100644 index 00000000..b9755919 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/SingleLineText.kt @@ -0,0 +1,6 @@ +package com.itlab.notes.ui + +private val LINE_BREAK_REGEX = Regex("[\r\n\u2028\u2029\u0085]+") + +/** Removes line breaks (typing, paste, or legacy data) so text stays on one line. */ +fun String.toSingleLineText(): String = replace(LINE_BREAK_REGEX, " ") diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index e853e0aa..5894580c 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -83,6 +84,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import kotlin.math.roundToInt @@ -100,6 +103,7 @@ import com.itlab.notes.media.NoteMediaImport import com.itlab.notes.media.imageAttachments import com.itlab.notes.media.toCoilModel import com.itlab.notes.ui.asDomainFolderId +import com.itlab.notes.ui.toSingleLineText import com.itlab.notes.ui.notes.NoteItemUi import kotlinx.coroutines.delay import org.koin.compose.koinInject @@ -815,18 +819,31 @@ private fun editorPlainTextField( textStyle: TextStyle = MaterialTheme.typography.bodyLarge, singleLine: Boolean = false, minLines: Int = 1, + stripLineBreaks: Boolean = false, ) { val colors = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } BasicTextField( value = value, - onValueChange = onValueChange, + onValueChange = { newValue -> + onValueChange(if (stripLineBreaks) newValue.toSingleLineText() else newValue) + }, modifier = modifier.fillMaxWidth(), textStyle = textStyle.copy(color = colors.onSurface), cursorBrush = SolidColor(colors.primary), singleLine = singleLine, + maxLines = if (singleLine) 1 else Int.MAX_VALUE, minLines = if (singleLine) 1 else minLines, + keyboardOptions = + if (singleLine) { + KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Next, + ) + } else { + KeyboardOptions.Default + }, interactionSource = interactionSource, decorationBox = { innerTextField -> editorPlainTextFieldDecoration( @@ -954,6 +971,7 @@ private fun editorTitleField( onValueChange = onValueChange, placeholder = "Title", singleLine = true, + stripLineBreaks = true, textStyle = MaterialTheme.typography.titleLarge, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt index 63e7411d..97d74486 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue import com.itlab.domain.model.ContentItem import com.itlab.notes.media.withoutTextItems import com.itlab.notes.ui.notes.NoteItemUi +import com.itlab.notes.ui.toSingleLineText class EditorViewModel( initialNote: NoteItemUi, @@ -13,7 +14,7 @@ class EditorViewModel( private val noteId: String = initialNote.id private val folderId: String? = initialNote.folderId - var title: String by mutableStateOf(initialNote.title) + var title: String by mutableStateOf(initialNote.title.toSingleLineText()) private set var content: String by mutableStateOf(initialNote.content) @@ -30,7 +31,7 @@ class EditorViewModel( } fun onTitleChange(newTitle: String) { - title = newTitle + title = newTitle.toSingleLineText() } fun onContentChange(newContent: String) { diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index c04554de..0468e81b 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.AllInbox @@ -74,7 +75,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.VisualTransformation +import com.itlab.notes.ui.toSingleLineText import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -707,7 +711,7 @@ private fun directoryRenameDialog( onSave: (String) -> Unit, onDismiss: () -> Unit, ) { - var renameName by remember(directory.id) { mutableStateOf(directory.name) } + var renameName by remember(directory.id) { mutableStateOf(directory.name.toSingleLineText()) } val trimmedName = renameName.trim() val nameAlreadyExists = trimmedName.isNotEmpty() && @@ -798,7 +802,9 @@ private fun directoryOutlinedTextField( BasicTextField( value = value, - onValueChange = { newValue -> onValueChange(newValue.coerceDirectoryNameLength()) }, + onValueChange = { newValue -> + onValueChange(newValue.toSingleLineText().coerceDirectoryNameLength()) + }, modifier = modifier .fillMaxWidth() @@ -812,6 +818,12 @@ private fun directoryOutlinedTextField( enabled = enabled, textStyle = textStyle, singleLine = true, + maxLines = 1, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), cursorBrush = SolidColor(scheme.primary), interactionSource = interactionSource, decorationBox = { innerTextField -> diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt index 8e5fe7c6..2ca3e55e 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoryIds.kt @@ -13,3 +13,5 @@ internal fun isVirtualDirectory(directoryId: String): Boolean = directoryId == ALL_DIRECTORY_ID || directoryId == RECENT_DIRECTORY_ID || directoryId == FAVORITES_DIRECTORY_ID + +internal fun canCreateNotesInDirectory(directoryId: String): Boolean = directoryId != ALL_DIRECTORY_ID diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 46311de7..f5839e4a 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -110,7 +110,7 @@ fun notesListScreen( ) }, floatingActionButton = { - if (!isSelectionMode) { + if (!isSelectionMode && canCreateNotesInDirectory(directoryId)) { notesFab(onAddNoteClick = actions.onAddNoteClick) } }, From e5dd9c87230cd6c676f31d78fcc0e40b4fd79088 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sat, 16 May 2026 21:01:34 +0300 Subject: [PATCH 23/35] fix errors --- app/gradle.lockfile | 641 ++++++++---------- .../main/java/com/itlab/notes/di/AppModule.kt | 8 +- .../java/com/itlab/notes/ui/NotesViewModel.kt | 7 +- .../itlab/notes/ui/editor/EditorViewModel.kt | 2 + .../com/itlab/notes/ui/notes/NoteItemUi.kt | 1 + 5 files changed, 300 insertions(+), 359 deletions(-) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index a2f72414..ad4f46f1 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -7,8 +7,8 @@ androidx.activity:activity-ktx:1.12.4=releaseUnitTestRuntimeClasspath androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.activity:activity:1.12.4=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.annotation:annotation-experimental:1.5.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-experimental:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.appcompat:appcompat-resources:1.6.1=releaseUnitTestRuntimeClasspath @@ -24,35 +24,26 @@ androidx.cardview:cardview:1.0.0=debugAndroidTestLintChecksClasspath,debugLintCh androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.animation:animation-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.animation:animation-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation-android:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.animation:animation-core-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.animation:animation-core-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation-core-android:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.animation:animation-core:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.animation:animation-core:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation-core:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.animation:animation:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.animation:animation:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.animation:animation:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.foundation:foundation-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.foundation:foundation-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.foundation:foundation-android:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.foundation:foundation-layout-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.foundation:foundation-layout-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.foundation:foundation-layout-android:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.foundation:foundation-layout:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.foundation:foundation-layout:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.foundation:foundation-layout:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.foundation:foundation:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.foundation:foundation:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.foundation:foundation:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.material3:material3-android:1.3.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material3:material3:1.3.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath androidx.compose.material:material-icons-core-android:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath @@ -61,98 +52,63 @@ androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClass androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.material:material-ripple-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-ripple-android:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.material:material-ripple:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.material:material-ripple:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-ripple:1.7.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.material:material:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.runtime:runtime-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-annotation-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-annotation-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.runtime:runtime-annotation-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-annotation:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-annotation:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.runtime:runtime-annotation:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-retain-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-retain-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-retain:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-saveable-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.runtime:runtime-saveable-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-saveable:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime-saveable:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.runtime:runtime-saveable:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.runtime:runtime:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.runtime:runtime:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-geometry-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-geometry-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-geometry-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-geometry:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-geometry:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-geometry:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-graphics-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-graphics-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-graphics-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-graphics:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-graphics:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-graphics:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-test-android:1.10.5=debugAndroidTestLintChecksClasspath -androidx.compose.ui:ui-test-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4-android:1.10.5=debugAndroidTestLintChecksClasspath -androidx.compose.ui:ui-test-junit4-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4:1.10.5=debugAndroidTestLintChecksClasspath -androidx.compose.ui:ui-test-junit4:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-manifest:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-test-manifest:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-test:1.10.5=debugAndroidTestLintChecksClasspath -androidx.compose.ui:ui-test:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-text-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-text-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-text-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-text:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-text:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-text:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-tooling-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-tooling-data-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-tooling-data:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-preview-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-tooling-preview-android:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-tooling-preview-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-preview:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-tooling-preview:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-tooling-preview:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-tooling:1.11.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-unit-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-unit-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-unit:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-unit:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-util-android:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-util-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-util-android:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-util:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui-util:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-util:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui:1.10.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose.ui:ui:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.11.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui:1.9.2=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose:compose-bom:2024.09.00=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose:compose-bom:2026.03.00=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.compose:compose-bom:2026.05.00=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose:compose-bom:2026.05.00=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath @@ -195,11 +151,10 @@ androidx.dynamicanimation:dynamicanimation:1.0.0=releaseUnitTestRuntimeClasspath androidx.dynamicanimation:dynamicanimation:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.fragment:fragment-ktx:1.6.2=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.fragment:fragment-ktx:1.8.9=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.exifinterface:exifinterface:1.3.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.fragment:fragment-ktx:1.8.9=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.fragment:fragment:1.3.6=releaseUnitTestRuntimeClasspath -androidx.fragment:fragment:1.6.2=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.fragment:fragment:1.8.9=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.fragment:fragment:1.8.9=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.graphics:graphics-shapes-android:1.0.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath androidx.graphics:graphics-shapes-desktop:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath @@ -248,30 +203,21 @@ androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClass androidx.recyclerview:recyclerview:1.1.0=releaseUnitTestRuntimeClasspath androidx.recyclerview:recyclerview:1.2.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-common-jvm:2.7.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.room:room-common-jvm:2.8.4=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.room:room-common:2.7.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.room:room-common:2.8.4=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.room:room-ktx:2.7.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.room:room-ktx:2.8.4=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.room:room-runtime-android:2.7.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.room:room-runtime-android:2.8.4=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.room:room-runtime:2.7.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.room:room-runtime:2.8.4=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-common-jvm:2.8.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-common:2.8.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-ktx:2.8.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-runtime-android:2.8.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-runtime:2.8.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.slidingpanelayout:slidingpanelayout:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.sqlite:sqlite-android:2.5.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.sqlite:sqlite-android:2.6.2=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.sqlite:sqlite-framework-android:2.5.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.sqlite:sqlite-framework-android:2.6.2=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.sqlite:sqlite-framework:2.5.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.sqlite:sqlite-framework:2.6.2=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.sqlite:sqlite:2.5.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -androidx.sqlite:sqlite:2.6.2=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.sqlite:sqlite-android:2.6.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.sqlite:sqlite-framework-android:2.6.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.sqlite:sqlite-framework:2.6.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.sqlite:sqlite:2.6.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.swiperefreshlayout:swiperefreshlayout:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.test.espresso:espresso-core:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath @@ -297,35 +243,30 @@ androidx.work:work-runtime-ktx:2.9.0=debugAndroidTestLintChecksClasspath,debugLi androidx.work:work-runtime:2.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath ch.qos.logback:logback-classic:1.3.14=ktlint ch.qos.logback:logback-core:1.3.14=ktlint -co.touchlab:stately-concurrency-jvm:2.0.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -co.touchlab:stately-concurrency-jvm:2.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -co.touchlab:stately-concurrency:2.0.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -co.touchlab:stately-concurrency:2.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -co.touchlab:stately-concurrent-collections-jvm:2.0.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -co.touchlab:stately-concurrent-collections-jvm:2.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -co.touchlab:stately-concurrent-collections:2.0.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -co.touchlab:stately-concurrent-collections:2.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -co.touchlab:stately-strict-jvm:2.0.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -co.touchlab:stately-strict-jvm:2.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -co.touchlab:stately-strict:2.0.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -co.touchlab:stately-strict:2.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +co.touchlab:stately-concurrency-jvm:2.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +co.touchlab:stately-concurrency:2.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +co.touchlab:stately-concurrent-collections-jvm:2.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +co.touchlab:stately-concurrent-collections:2.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +co.touchlab:stately-strict-jvm:2.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +co.touchlab:stately-strict:2.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.android.tools.analytics-library:protos:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools.analytics-library:protos:32.2.0=androidLintTool +com.android.tools.analytics-library:protos:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.analytics-library:shared:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools.analytics-library:shared:32.2.0=androidLintTool +com.android.tools.analytics-library:shared:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.analytics-library:tracker:32.2.0=androidLintTool com.android.tools.build:aapt2-proto:9.1.0-14792394=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools.build:aapt2-proto:9.2.0-15009934=androidLintTool +com.android.tools.build:aapt2-proto:9.2.0-15009934=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.build:builder-model:9.2.0=androidLintTool com.android.tools.build:manifest-merger:32.2.0=androidLintTool com.android.tools.ddms:ddmlib:32.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action -com.android.tools.ddms:ddmlib:32.2.0=androidLintTool +com.android.tools.ddms:ddmlib:32.2.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.android.tools.emulator:proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.emulator:proto:32.2.0=unified-test-platform-android-test-plugin-host-emulator-control com.android.tools.external.com-intellij:intellij-core:32.2.0=androidLintTool com.android.tools.external.com-intellij:kotlin-compiler:32.2.0=androidLintTool com.android.tools.external.org-jetbrains:uast:32.2.0=androidLintTool com.android.tools.layoutlib:layoutlib-api:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools.layoutlib:layoutlib-api:32.2.0=androidLintTool +com.android.tools.layoutlib:layoutlib-api:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.lint:lint-api:32.2.0=androidLintTool com.android.tools.lint:lint-checks:32.2.0=androidLintTool com.android.tools.lint:lint-gradle:32.2.0=androidLintTool @@ -333,39 +274,58 @@ com.android.tools.lint:lint-model:32.2.0=androidLintTool com.android.tools.lint:lint-typedef-remover:32.2.0=androidLintTool com.android.tools.lint:lint:32.2.0=androidLintTool com.android.tools.utp:android-device-provider-ddmlib-proto:32.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-device-provider-ddmlib-proto:32.2.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-gradle-work-action com.android.tools.utp:android-device-provider-ddmlib:32.1.0=_internal-unified-test-platform-android-device-provider-ddmlib +com.android.tools.utp:android-device-provider-ddmlib:32.2.0=unified-test-platform-android-device-provider-ddmlib com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.2.0=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-gradle-work-action com.android.tools.utp:android-test-plugin-host-additional-test-output:32.1.0=_internal-unified-test-platform-android-test-plugin-host-additional-test-output +com.android.tools.utp:android-test-plugin-host-additional-test-output:32.2.0=unified-test-platform-android-test-plugin-host-additional-test-output com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.2.0=unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-gradle-work-action com.android.tools.utp:android-test-plugin-host-apk-installer:32.1.0=_internal-unified-test-platform-android-test-plugin-host-apk-installer +com.android.tools.utp:android-test-plugin-host-apk-installer:32.2.0=unified-test-platform-android-test-plugin-host-apk-installer com.android.tools.utp:android-test-plugin-host-coverage-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-coverage-proto:32.2.0=unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-gradle-work-action com.android.tools.utp:android-test-plugin-host-coverage:32.1.0=_internal-unified-test-platform-android-test-plugin-host-coverage +com.android.tools.utp:android-test-plugin-host-coverage:32.2.0=unified-test-platform-android-test-plugin-host-coverage com.android.tools.utp:android-test-plugin-host-device-info-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-device-info-proto:32.2.0=unified-test-platform-android-test-plugin-host-device-info com.android.tools.utp:android-test-plugin-host-device-info:32.1.0=_internal-unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-device-info:32.2.0=unified-test-platform-android-test-plugin-host-device-info com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.2.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action com.android.tools.utp:android-test-plugin-host-emulator-control:32.1.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.utp:android-test-plugin-host-emulator-control:32.2.0=unified-test-platform-android-test-plugin-host-emulator-control com.android.tools.utp:android-test-plugin-host-logcat-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-logcat-proto:32.2.0=unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action com.android.tools.utp:android-test-plugin-host-logcat:32.1.0=_internal-unified-test-platform-android-test-plugin-host-logcat +com.android.tools.utp:android-test-plugin-host-logcat:32.2.0=unified-test-platform-android-test-plugin-host-logcat com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.2.0=unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.android.tools.utp:android-test-plugin-result-listener-gradle:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.utp:android-test-plugin-result-listener-gradle:32.2.0=unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.utp:gradle-work-action:32.1.0=_internal-unified-test-platform-gradle-work-action +com.android.tools.utp:gradle-work-action:32.2.0=unified-test-platform-gradle-work-action com.android.tools.utp:utp-common:32.1.0=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat +com.android.tools.utp:utp-common:32.2.0=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat com.android.tools:annotations:32.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action -com.android.tools:annotations:32.2.0=androidLintTool +com.android.tools:annotations:32.2.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.android.tools:common:32.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action -com.android.tools:common:32.2.0=androidLintTool +com.android.tools:common:32.2.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.android.tools:dvlib:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools:dvlib:32.2.0=androidLintTool +com.android.tools:dvlib:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:play-sdk-proto:32.2.0=androidLintTool com.android.tools:repository:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools:repository:32.2.0=androidLintTool +com.android.tools:repository:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdk-common:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools:sdk-common:32.2.0=androidLintTool +com.android.tools:sdk-common:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdklib:32.1.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.android.tools:sdklib:32.2.0=androidLintTool +com.android.tools:sdklib:32.2.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.firebaseui:firebase-ui-auth:8.0.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.github.ajalt.clikt:clikt-jvm:5.0.2=ktlint com.github.ajalt.clikt:clikt:5.0.2=ktlint +com.google.accompanist:accompanist-drawablepainter:0.32.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-ads-identifier:18.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-auth-api-phone:18.0.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-auth-base:18.0.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -388,24 +348,24 @@ com.google.android.material:material:1.13.0=debugAndroidTestLintChecksClasspath, com.google.android.play:core-common:2.0.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.play:integrity:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.recaptcha:recaptcha:18.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core -com.google.api.grpc:proto-google-common-protos:2.48.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control -com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.auto.service:auto-service:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.auto:auto-common:1.2.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.code.gson:gson:2.11.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher -com.google.crypto.tink:tink:1.18.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action -com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action +com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.48.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.service:auto-service:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto:auto-common:1.2.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-core,unified-test-platform-gradle-work-action +com.google.code.gson:gson:2.11.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +com.google.crypto.tink:tink:1.18.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action +com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action com.google.errorprone:error_prone_annotations:2.11.0=debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath com.google.errorprone:error_prone_annotations:2.15.0=releaseUnitTestRuntimeClasspath -com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.errorprone:error_prone_annotations:2.26.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -com.google.errorprone:error_prone_annotations:2.30.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath +com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.errorprone:error_prone_annotations:2.30.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,unified-test-platform-android-test-plugin-host-emulator-control com.google.firebase:firebase-analytics:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-annotations:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-appcheck-interop:17.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -422,31 +382,31 @@ com.google.firebase:firebase-installations:19.1.0=debugAndroidTestCompileClasspa com.google.firebase:firebase-measurement-connector:19.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-storage:22.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:protolite-well-known-types:18.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.guava:failureaccess:1.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool +com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.guava:failureaccess:1.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.guava:guava:31.1-android=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath -com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.guava:guava:32.1.3-android=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.guava:guava:33.3.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool +com.google.guava:guava:33.3.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.guava:listenablefuture:1.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.j2objc:j2objc-annotations:1.3=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath -com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -com.google.j2objc:j2objc-annotations:3.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -com.google.jimfs:jimfs:1.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -com.google.protobuf:protobuf-java-util:3.22.3=_internal-unified-test-platform-core -com.google.protobuf:protobuf-java-util:4.28.3=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:3.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.jimfs:jimfs:1.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.protobuf:protobuf-java-util:3.22.3=_internal-unified-test-platform-core,unified-test-platform-core +com.google.protobuf:protobuf-java-util:4.28.3=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.protobuf:protobuf-java:3.25.5=androidLintTool -com.google.protobuf:protobuf-java:4.28.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher +com.google.protobuf:protobuf-java:4.28.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.protobuf:protobuf-javalite:3.25.5=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.protobuf:protobuf-kotlin:4.28.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher +com.google.protobuf:protobuf-kotlin:4.28.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.re2j:re2j:1.6=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control -com.google.testing.platform:android-test-plugin:0.0.9-alpha04=_internal-unified-test-platform-android-test-plugin -com.google.testing.platform:core-proto:0.0.9-alpha04=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action -com.google.testing.platform:core:0.0.9-alpha04=_internal-unified-test-platform-core -com.google.testing.platform:launcher:0.0.9-alpha04=_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher +com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control +com.google.testing.platform:android-test-plugin:0.0.9-alpha04=_internal-unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin +com.google.testing.platform:core-proto:0.0.9-alpha04=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.testing.platform:core:0.0.9-alpha04=_internal-unified-test-platform-core,unified-test-platform-core +com.google.testing.platform:launcher:0.0.9-alpha04=_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.jakewharton.timber:timber:5.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.pinterest.ktlint:ktlint-cli-reporter-baseline:1.5.0=ktlint,ktlintBaselineReporter com.pinterest.ktlint:ktlint-cli-reporter-checkstyle:1.5.0=ktlint @@ -463,17 +423,22 @@ com.pinterest.ktlint:ktlint-logger:1.5.0=ktlint,ktlintBaselineReporter,ktlintRul com.pinterest.ktlint:ktlint-rule-engine-core:1.5.0=ktlint,ktlintBaselineReporter,ktlintRuleset com.pinterest.ktlint:ktlint-rule-engine:1.5.0=ktlint com.pinterest.ktlint:ktlint-ruleset-standard:1.5.0=ktlint,ktlintRuleset -com.squareup.okio:okio-jvm:3.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.squareup.okio:okio:3.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.sun.istack:istack-commons-runtime:3.0.8=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -com.sun.xml.fastinfoset:FastInfoset:1.2.16=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -commons-codec:commons-codec:1.17.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -commons-logging:commons-logging:1.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool +com.squareup.okhttp3:okhttp:4.12.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okio:okio-jvm:3.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okio:okio:3.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.sun.istack:istack-commons-runtime:3.0.8=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.sun.xml.fastinfoset:FastInfoset:1.2.16=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +commons-codec:commons-codec:1.17.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +commons-logging:commons-logging:1.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle dev.drewhamilton.poko:poko-annotations-jvm:0.17.1=detekt dev.drewhamilton.poko:poko-annotations-jvm:0.18.0=ktlint,ktlintBaselineReporter,ktlintRuleset dev.drewhamilton.poko:poko-annotations:0.17.1=detekt dev.drewhamilton.poko:poko-annotations:0.18.0=ktlint,ktlintBaselineReporter,ktlintRuleset +io.coil-kt:coil-base:2.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.coil-kt:coil-compose-base:2.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.coil-kt:coil-compose:2.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.coil-kt:coil:2.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath io.github.davidburstrom.contester:contester-breakpoint:0.2.0=detekt io.github.detekt.sarif4k:sarif4k-jvm:0.6.0=detekt,ktlint,ktlintReporter io.github.detekt.sarif4k:sarif4k:0.6.0=detekt,ktlint,ktlintReporter @@ -503,97 +468,91 @@ io.gitlab.arturbosch.detekt:detekt-rules:1.23.8=detekt io.gitlab.arturbosch.detekt:detekt-tooling:1.23.8=detekt io.gitlab.arturbosch.detekt:detekt-utils:1.23.8=detekt io.grpc:grpc-android:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-api:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-api:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core io.grpc:grpc-api:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-api:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.grpc:grpc-context:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-api:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-context:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core io.grpc:grpc-context:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-context:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.grpc:grpc-core:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-context:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-core:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core io.grpc:grpc-core:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-core:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.grpc:grpc-netty:1.57.2=_internal-unified-test-platform-core -io.grpc:grpc-netty:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-core:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-netty:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core +io.grpc:grpc-netty:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control io.grpc:grpc-okhttp:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-protobuf-lite:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-protobuf-lite:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core io.grpc:grpc-protobuf-lite:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-protobuf-lite:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.grpc:grpc-protobuf:1.57.2=_internal-unified-test-platform-core -io.grpc:grpc-protobuf:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.grpc:grpc-services:1.57.2=_internal-unified-test-platform-core -io.grpc:grpc-stub:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-protobuf-lite:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-protobuf:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core +io.grpc:grpc-protobuf:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-services:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core +io.grpc:grpc-stub:1.57.2=_internal-unified-test-platform-core,unified-test-platform-core io.grpc:grpc-stub:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-stub:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-stub:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control io.grpc:grpc-util:1.62.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.grpc:grpc-util:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.insert-koin:koin-android:3.5.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -io.insert-koin:koin-android:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-androidx-compose:3.5.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -io.insert-koin:koin-androidx-compose:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-compose-android:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-compose-jvm:1.1.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -io.insert-koin:koin-compose-viewmodel-android:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-compose-viewmodel:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-compose:1.1.5=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -io.insert-koin:koin-compose:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-core-jvm:3.5.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -io.insert-koin:koin-core-jvm:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-core-viewmodel-android:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-core-viewmodel:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.insert-koin:koin-core:3.5.6=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -io.insert-koin:koin-core:4.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.netty:netty-buffer:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-buffer:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-codec-http2:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-codec-http2:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-codec-http:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-codec-http:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-codec-socks:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-codec-socks:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-codec:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-codec:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-common:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-common:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-handler-proxy:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-handler-proxy:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-handler:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-handler:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-resolver:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-resolver:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-transport-native-unix-common:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-transport-native-unix-common:4.1.93.Final=_internal-unified-test-platform-core -io.netty:netty-transport:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control -io.netty:netty-transport:4.1.93.Final=_internal-unified-test-platform-core -io.opencensus:opencensus-api:0.31.0=_internal-unified-test-platform-core -io.opencensus:opencensus-proto:0.2.0=_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher -io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -io.perfmark:perfmark-api:0.27.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control -jakarta.activation:jakarta.activation-api:1.2.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control -javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.grpc:grpc-util:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.insert-koin:koin-android:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-androidx-compose:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-compose-android:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-compose-viewmodel-android:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-compose-viewmodel:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-compose:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-core-jvm:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-core-viewmodel-android:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-core-viewmodel:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.insert-koin:koin-core:4.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +io.netty:netty-buffer:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-buffer:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-codec-http2:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-http2:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-codec-http:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-http:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-codec-socks:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-socks:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-codec:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-common:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-common:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-handler-proxy:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-handler-proxy:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-handler:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-handler:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-resolver:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-resolver:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-transport-native-unix-common:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-transport-native-unix-common:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.netty:netty-transport:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-transport:4.1.93.Final=_internal-unified-test-platform-core,unified-test-platform-core +io.opencensus:opencensus-api:0.31.0=_internal-unified-test-platform-core,unified-test-platform-core +io.opencensus:opencensus-proto:0.2.0=_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-core +io.perfmark:perfmark-api:0.27.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +jakarta.activation:jakarta.activation-api:1.2.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control +javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -org.apache.commons:commons-compress:1.27.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.apache.commons:commons-lang3:3.16.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.apache.httpcomponents:httpclient:4.5.6=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.apache.httpcomponents:httpcore:4.4.16=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.apache.httpcomponents:httpmime:4.5.6=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.bouncycastle:bcpkix-jdk18on:1.79=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.bouncycastle:bcprov-jdk18on:1.79=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.bouncycastle:bcutil-jdk18on:1.79=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool +net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.apache.commons:commons-compress:1.27.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.commons:commons-lang3:3.16.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpclient:4.5.6=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpcore:4.4.16=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpmime:4.5.6=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcpkix-jdk18on:1.79=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcprov-jdk18on:1.79=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcutil-jdk18on:1.79=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.checkerframework:checker-qual:3.12.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath -org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher org.checkerframework:checker-qual:3.37.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.checkerframework:checker-qual:3.43.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool +org.checkerframework:checker-qual:3.43.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action org.codehaus.groovy:groovy:3.0.22=androidLintTool -org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.codehaus.mojo:animal-sniffer-annotations:1.24=_internal-unified-test-platform-android-test-plugin-host-emulator-control +org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-core +org.codehaus.mojo:animal-sniffer-annotations:1.24=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control org.ec4j.core:ec4j-core:1.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.glassfish.jaxb:jaxb-runtime:2.3.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool -org.glassfish.jaxb:txw2:2.3.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool +org.glassfish.jaxb:jaxb-runtime:2.3.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.glassfish.jaxb:txw2:2.3.2=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath org.jacoco:org.jacoco.agent:0.8.14=androidJacocoAnt @@ -601,136 +560,116 @@ org.jacoco:org.jacoco.ant:0.8.14=androidJacocoAnt org.jacoco:org.jacoco.core:0.8.14=androidJacocoAnt org.jacoco:org.jacoco.report:0.8.14=androidJacocoAnt org.jcommander:jcommander:1.85=detekt -org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.6=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.androidx.savedstate:savedstate:1.3.6=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.animation:animation-core:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.animation:animation:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.annotation-internal:annotation:1.10.2=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.collection-internal:collection:1.10.2=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.foundation:foundation-layout:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.foundation:foundation:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.runtime:runtime-saveable:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.runtime:runtime:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.runtime:runtime:1.9.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.compose.ui:ui-geometry:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.ui:ui-graphics:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.ui:ui-text:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.ui:ui-unit:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.ui:ui-util:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.compose.ui:ui:1.10.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.androidx.savedstate:savedstate:1.3.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.animation:animation-core:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.animation:animation:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.annotation-internal:annotation:1.10.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.collection-internal:collection:1.10.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.foundation:foundation-layout:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.foundation:foundation:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.runtime:runtime-saveable:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.runtime:runtime:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.ui:ui-geometry:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.ui:ui-graphics:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.ui:ui-text:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.ui:ui-unit:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.ui:ui-util:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.compose.ui:ui:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.intellij.deps:trove4j:1.0.20200330=detekt,ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:compose-group-mapping:2.3.20=composeMappingProducerClasspath +org.jetbrains.kotlin:compose-group-mapping:2.3.21=composeMappingProducerClasspath org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-bom:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.21=detekt org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.0.21=kotlinCompilerPluginClasspathReleaseUnitTest org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.3.21=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease -org.jetbrains.kotlin:kotlin-daemon-client:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.0.21=detekt org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-reflect:1.8.21=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-reflect:1.8.21=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlin:kotlin-reflect:2.0.21=detekt -org.jetbrains.kotlin:kotlin-reflect:2.2.10=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool +org.jetbrains.kotlin:kotlin-reflect:2.2.10=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.jetbrains.kotlin:kotlin-script-runtime:2.0.21=detekt org.jetbrains.kotlin:kotlin-script-runtime:2.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-script-runtime:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath -org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core -org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,unified-test-platform-android-test-plugin,unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher org.jetbrains.kotlin:kotlin-stdlib-common:2.0.21=detekt,releaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0=ktlintReporter -org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-gradle-work-action -org.jetbrains.kotlin:kotlin-stdlib-common:2.3.20=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-gradle-work-action,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=detekt,ktlintReporter -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=detekt,ktlintReporter -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -org.jetbrains.kotlin:kotlin-stdlib:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core -org.jetbrains.kotlin:kotlin-stdlib:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,unified-test-platform-android-test-plugin,unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher org.jetbrains.kotlin:kotlin-stdlib:2.0.21=detekt,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:2.1.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-stdlib:2.1.21=composeMappingProducerClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool -org.jetbrains.kotlin:kotlin-stdlib:2.3.20=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.3.21=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.3.20=kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath -org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher -org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.10=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-gradle-work-action,androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=detekt,ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.9.0=debugAndroidTestLintChecksClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0=debugAndroidTestLintChecksClasspath -org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.8.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-datetime:0.6.0=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlinx:kotlinx-datetime:0.8.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.8.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.8.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-html-jvm:0.8.1=detekt -org.jetbrains.kotlinx:kotlinx-serialization-bom:1.11.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.11.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1=detekt,ktlintReporter -org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1=detekt,ktlintReporter -org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.11.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.4.1=detekt,ktlintReporter -org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.7.3=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath -org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1=detekt,ktlintReporter -org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3=debugAndroidTestLintChecksClasspath,debugUnitTestLintChecksClasspath org.jetbrains.kotlinx:kover-jvm-agent:0.9.8=koverJvmAgent,koverJvmReporter org.jetbrains:annotations:13.0=composeMappingProducerClasspath,detekt,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset -org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-gradle-work-action,_internal-unified-test-platform-launcher,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jvnet.staxex:stax-ex:1.8.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool +org.jvnet.staxex:stax-ex:1.8.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.ow2.asm:asm-analysis:9.9=androidLintTool org.ow2.asm:asm-commons:9.9=androidJacocoAnt,androidLintTool org.ow2.asm:asm-tree:9.9=androidJacocoAnt,androidLintTool diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index 38e6975a..739aa54e 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -18,7 +18,7 @@ import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import com.itlab.notes.ui.NotesUseCases import com.itlab.notes.ui.NotesViewModel -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val appModule = @@ -61,9 +61,5 @@ val appModule = ) } - viewModel { - NotesViewModel( - useCases = get(), - ) - } + viewModelOf(::NotesViewModel) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 1d0e8582..af143ff5 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -205,7 +205,8 @@ class NotesViewModel( val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory ?: return if (!canCreateNotesInDirectory(dir.id)) return notesJob?.cancel() - val newNote = Note(folderId = dir.id.asDomainFolderId()).toUi() + val userId = useCases.getUserIdUseCase() ?: "local_user" + val newNote = Note(userId = userId, folderId = dir.id.asDomainFolderId()).toUi() uiState = uiState.copy( screen = NotesUiScreen.NoteEditor(directory = dir, note = newNote), @@ -330,6 +331,7 @@ internal fun NoteFolder.toUi(noteCount: Int): DirectoryItemUi = internal fun Note.toUi(): NoteItemUi = NoteItemUi( id = id, + userId = userId, title = title, content = contentItems @@ -342,12 +344,13 @@ internal fun Note.toUi(): NoteItemUi = internal fun NoteItemUi.toContentItems(): List = buildList { - if (content.isNotBlank()) add(ContentItem.Text(content)) + if (content.isNotBlank()) add(ContentItem.Text(text = content)) addAll(attachments.withoutTextItems()) } internal fun NoteItemUi.toDomain(folderId: String?): Note = Note( + userId = userId, id = id, title = title, folderId = folderId, diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt index 97d74486..cab4d3dc 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt @@ -12,6 +12,7 @@ class EditorViewModel( initialNote: NoteItemUi, ) { private val noteId: String = initialNote.id + private val userId: String = initialNote.userId private val folderId: String? = initialNote.folderId var title: String by mutableStateOf(initialNote.title.toSingleLineText()) @@ -54,6 +55,7 @@ class EditorViewModel( fun buildUpdatedNote(): NoteItemUi = NoteItemUi( id = noteId, + userId = userId, title = title, content = content, folderId = folderId, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt index 17da6bac..fe0d0abf 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt @@ -4,6 +4,7 @@ import com.itlab.domain.model.ContentItem data class NoteItemUi( val id: String, + val userId: String, val title: String, val content: String, val folderId: String? = null, From e88b7686ef68707ee040b4ec4c8f6c9a40076420 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 10:19:22 +0300 Subject: [PATCH 24/35] feat: add auth --- .tmp_docx.zip | Bin 0 -> 20864 bytes .tmp_docx/[Content_Types].xml | 2 + .tmp_docx/_rels/.rels | 2 + .tmp_docx/docProps/app.xml | 2 + .tmp_docx/docProps/core.xml | 2 + .tmp_docx/word/_rels/document.xml.rels | 2 + .tmp_docx/word/document.xml | 2 + .tmp_docx/word/fontTable.xml | 2 + .tmp_docx/word/settings.xml | 2 + .tmp_docx/word/styles.xml | 2 + .tmp_docx/word/theme/theme1.xml | 2 + .tmp_docx/word/webSettings.xml | 2 + app/build.gradle.kts | 3 + app/gradle.lockfile | 24 +- .../com/itlab/{notes/di => }/AppModule.kt | 4 +- .../java/com/itlab/notes/NotesApplication.kt | 2 +- .../main/java/com/itlab/notes/ui/NotesApp.kt | 15 + .../com/itlab/notes/ui/auth/AuthScreen.kt | 269 ++++++++++++++++++ .../com/itlab/notes/ui/auth/AuthViewModel.kt | 144 ++++++++++ app/src/main/res/values/strings.xml | 16 +- 20 files changed, 485 insertions(+), 14 deletions(-) create mode 100644 .tmp_docx.zip create mode 100644 .tmp_docx/[Content_Types].xml create mode 100644 .tmp_docx/_rels/.rels create mode 100644 .tmp_docx/docProps/app.xml create mode 100644 .tmp_docx/docProps/core.xml create mode 100644 .tmp_docx/word/_rels/document.xml.rels create mode 100644 .tmp_docx/word/document.xml create mode 100644 .tmp_docx/word/fontTable.xml create mode 100644 .tmp_docx/word/settings.xml create mode 100644 .tmp_docx/word/styles.xml create mode 100644 .tmp_docx/word/theme/theme1.xml create mode 100644 .tmp_docx/word/webSettings.xml rename app/src/main/java/com/itlab/{notes/di => }/AppModule.kt (96%) create mode 100644 app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt create mode 100644 app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt diff --git a/.tmp_docx.zip b/.tmp_docx.zip new file mode 100644 index 0000000000000000000000000000000000000000..650fe421979654f8c54d6d5cd597f44527e1d53c GIT binary patch literal 20864 zcmeFZV~{4nwl3VZF>TwnZBE;^t!dk~ZQGi*-Tk(vZJT%Y-e3o#F)>EnpxZLU14;R3Io2Z4rAr7gIYIeHBj! zQ)gXz4_h0;LNE}@d?1j&`v33xA6x^?$&+@2j7Vb7DQ}1gZK_5mg_YDGQT(ZN%BPT+ z-XLn9iKE?Ly{I5cs=x^_HpFBs&)Y0&BSG^UnKsbKE$(FJ7<{P#NqUwXH0!GebbcmS zuv*?sQW z22I;0bD&aafy6=3%SVAtYFuUeWhY6DX|nFTs9~NW8*I>w7IEftx|EmflcpH&1PF4* zyNSid6i8Q8kA=K&A#h<;q8S?@E_FAoh^q5Z(edo4FQ%0T;O;)CmXI~SZP1A_04L0B zW){|H6l~>wQ$m);0C)3>?E~--sScnp+yHSe^HzU%q+j+&Ux1bX& zKnpydW{=i3vLlOU93Ox!i}nRZw(D$aW-NDsm`)e{bh9K^%x57VZSw{3#*P5*wlAPS zK;Pe>K#Ko|OX4SBw_X2jXYzkN4)(80>N}a*I5W`yBmIA__&?bH{^QfD69=q@7~ur3 zgFb`idz3Z?FpK0FjOPHD>yS{|QnILkHLKOHPu{gP5ZzNliOGe)QZV#Z8knR#Dj}?bKxU8cmv(E_QSW9B6*(GDrrlMR&)z#;8UzYp~rsWkYNo{Y)NW;!TZU&oP0 zSOVstQZSl!`+Z2=dqbv=p zBX;CtglhnsAD)_E@ut?a0Bo!^{kad=SF#Fc`NtkAj7hnqy*0$tB@=aZ^_+8e`p2d` zzjaFtyN)2ks@2bf=+44ys`jmEy{6@@w}Yvcs+o|xj6sK`6Ly?Sy}$A)Ja>n0j?%K?8j9#^{^~}T^WSCG8%@E5L4jT?4Q7SAWF&91NUQs=f+o4EU0dgLF(MGIGZ z>8bE7^yssT(UFP|8J?}lgAE&|_wc9@Vn{W^ai4Fd1@ zxbC*DYdv%hU8)J}-o##5TnyRz#cNaa4A;m5zPB%XsmJJ(hLhxEun;JjfAxOluup#+ zAghWQNEn3gA|y}^=bg?L50x|e)GsZF{!m9pBm4r=vH~(*L`Qr1!ZhU{IWrq8V3e1M zXZvupP&dd{Yp<`&$CZ&MXYQ}(va(ZPi#)13r``l$vbEw2`Og`Ox_3&Y1pc{lA+}!H z@e&Y!ahU_0h84AJ+c(E3R&POo8b>y8iilu)jKa$mRtn@nZ6nqlc)vt9Sk^1oNNuh_$3<3v+Nk%=v<#+(AT zPdrRO)n1!%uq^;saM5f>vXdEM;$uNQ!NKZwR#sdEaI2+`BFJSn>$+i z3)DtmHLriH+OciqWm}$@tNl6i=kJd7)2V}BpH=mBWovhh=Spzj^!2uFzWUq--0OJ> zczN%KZ3Dl1p5UR4bZz5Ydwihvaur@L4lT&2RW3~_H=N1#nRr&qn*g7Yv|aZYeiKAr$GDFw!0!8nw3+yAnqGgJeWZVWjqy->gn?^-K0VE@{jy(kH$WeG zUMn%bFnST=zXRU9Plb01_xlo*;qdKLzWLhygxcYL(yI^KHYLD|y+(IH(>Mycy#nt( zVKUUwY#qGi91C*kl}ARxoe?%me44_Tkj%B@}2wJ%|9W590bg1inSalzz3 zu%m&A!yoKwavfUt+s@pD(RWJC2yy#8eGBXziYEr(=l22s&VmVPCiMV{TF?>}Mz)ei z8E{P_dtmSpY)jVVo(2zB^?-ehroG{R-aUj4+)BeOt=b@6Ca-MT7o%C3Je{cZNj1K8 zo$z$^ZZb7y2vcGG!?s14c5s2Va7k?7CYzJ!Ad>3!8?%^>@lpmUaJYR~WBfhAyvSyv zw#N+jH5fXM?**UkGT<2mjAq|*mpz%W&M{fS8bqcxqf<@!@y>{rEB&YSkgsu)rod+a zj|*hmCd)YnOaq4DGn`is&Lgnm>4|*aFZVTfz$FdI5vwzC8N6uU&y#JhHmn{NZ~Tz& zuJ@XJ!5&C&_&d%*w=bdLD@C{(Bh|-}&Da3IHN1Aw_OAT4cVZ*x+Cq^qq_1~GCb~x& zQ+S(SE+@)bC@oQQu`q;DDYB10@7qS3nBLqK^T5*&*3I*=5ibd19P3KN9mA4WYo9{N;pjl0qs@QtSiRKlmkkjSpwy($B zgSXgN-Mf=t`r zqzOOBD{{%J`)~JUF%Q8OK}Q572tLld^VgAl!iVVApZLQ!%7|O}0XSP!DVHTG_BCeP znV0-wal(R^=o&u=hx$7?O-iACWk*bHp1Va_muDRThR+D|WKRv;xOc99Hd^4dhbgJl z;6`gxNJ)pN)>@RTmu3K@12G;5HUTS;d&&(x$eOMq9MY{NG%_7$9A1~DJZocF#_|RT z!7};|;VfW|`T=A>8q`>)2SvcwxCv{b;Tc%agovU044`VhAJ!@nP-DAh4vh4QS(4JS z*ww(4pe7@%48u@!9G;sOw2Yd-|`+BppYg*=s*m8F)y4>#un0GJ(e*2xqc zc+%cYpJrE0LRhVF3zRT7A(26nu>Jr-Y+*<09Y)Y!*Ca>`uY?q9zLL#xoLb>yH{uvh z+u|PIOEO7sgDd&-QVd(u+vQtlwb5ERRBZM~FA*iW7=KWEjM7t0>B@+>t!xhIZZXM# zo55ic`fH6*%nS|;EKpTZEVa8My;xDCV=QTiq|}ruO{X>FrB<-HW3+pOX#V&fU%lK$0T{fL4fiQ?YR!CJ_;Kgd$xt;j$} zchTKpi_b>^E8F))M5qk~>{ zS;5kJyseh2*U=}n;Ck?(g@k>`K|SDfDm)6+TXB9LytiqJyp*GewOr^AQk41V+9o5)Av_}38!yagLK@?uKpU@EJu{Qj zojItrGuGlf%>jV6V()Y13W98K7=MNYa%EaBQ>SnphfP`h5!h=)by!cXAQz^#YEf3U zHRrm2;JzwPXiAs?Wk$0K;f7eMjvoqvaKya5mtoi@h8TFHBw{*NYD#RtC4CiR#73Fa zVG}SG#z7(My{)$ctsY%Vlh*HR^h_1$K_zNlW% z3Wt{!>0XDeuF5pSE(tkuyynRTlS&a<@E@47ZQjDYEQM7J6wHS(*WGn3H}whz>7O+v zM#W^5p&Tj{^%S7u=Ml`3To8N#GiqXp|J--fi#g>ZPZK9E zk3pxI{(IqTFOMIR2~;UVU#I6~oy%#7rT;)w+J_VsDGYV*SJ@e6DBm+R9aihw32Up< zGaSo;dv}oLO@DnG%i5IlTQN8msHFUu+0%PUH-&4elv6pjFI=@^MX=|eAT|5 zss5OAECvVt3d7u;+}I9CH9i?^GU^8qJu!@_LFQ|s2s zid(Ny94-4CrF%mnZaQS-tNVO_H@B~Yyr>ElTUVj={iv3SXv*S*02UX-St8Zs>v>Uo zV)hb<6xnIT0bE)xI1A1uy}b5jX(r*fo>Y?hxq8P8H?_0k9&gZT5D-QRd#7cksqs~O zXLR#x^>R-}Q*#6t`96hwf>TS z^hCp`qWe+|#-T8-s@g!*NJ z*gPs3N!7+{kI9mJK#gf7+{Hkg_yN=oMMPUElq60`&kn(n)dSF_`c0(bW}POtOfBQ_ zz*0ybo)t+)7{=62!eW;d~6ut8R1u^8lHQX}3_F$Yz(S&f?3l&@7}W!Cau;M()~wmY<2%5ohlFZgY5>iuaE z`PSmkrvT#VCDx((oWjmIIoloqQyY@7o(4IvxK1NzXegn+W6Y%(UM~rWg{h}=Qjhvi z?$|K!YC@x-)(oka*bWlLX%%ZXMmv0eT%-NFQnmMm?Mf5jyUv|-&9O8lm!7?)NN0K4 z$y{wE8lK;kcMW5gK~i*-PB?n0D7x#~eih#l zZL~spDNk-8Xb32f0=s>2-6lBi_0E{G-uNx|GIzW@|7HrZn{CasocTD4cf8rx4k}Ag z1?5Pd%Wd0?w9I6;L0#)e21iWTxFN~qbx7NgTO9;Mggj1h`xH3KJRd2U0J~{wc$D)Q zyQS((@4;Myeut950iE7OR#?bB(zW1ZDP9Ts z7OO0qC{Pdw2APu^1Z7Y8 zD^-s+Ks_o)_8e%>u>4>rW@6Qo!q6m-_5ri+ z5)!C{k#d5}zK$DYgPt_8CHxHToLsKyETWuTUrdLY6FdC+b2LvXE9FWMB*m z6r)t4I!Bja_&YP%asgc7QaWPMM!|KQ9+M-(%UknTLm?H_G8xgT{vgHV1E%{5!r(!t zK%`(0aPo}fE6W|Wm6j`n^N{PRNnio^uRiNXSJfysa&x%(b{QpJXqZ8K%6R1`hqMc;OToU zxX5Cme({3RWyk#pZn%<>Pv8tXP=YUI;sG)jzhPnF!b?+MPz-QWRT8TDdKg~p4|&^0 zl;S5|ux7%JomAt0H%k4;2aV$vyuAVxq+|;n2y4`;wy3mN(3P-~h#Da*$TnL3NHc+6 z6)-m6fd_&D@5J4jvzlIlMrX<@~g4u4X2@ zm7FnkbKgS`xKyij6Jy|Uh8Su(F3k{^n?FM#Dk9_Q;f4qAFA$0k#O6!!rKehpSxFY9 zdnZg-vTjmgQ+3@g0rep&t$i3@Gn29e1GM%{;+~eDHGgKE~jM3XbO*cnKACmCIO(Tq!o@z*s6+?b>3YedV7X?%rs+dxD3 z$9}98k^D%w-@U=Pph1rH#Kg5taI~3W>4u7NbZ6L&&MOKbFK1?JDV^p)X7RSu5_7le zgu8LnQYFW&MiE@qTt4y^_Hfg!T9(c56)EdKD``R`>nfAV>aGu0Mtmsv!Df6Vp5?uk%x}BF)Fc^~cJNqm;62lGlcGKj`)= z<*~t;Ln+NHxiEjl7h0EJc@grpPpQ8!-s%#>bk-vY!dpY^g}V;beVJn+Sq0{XBMQNf zm@hLbcU_!2-t;H(wkwHLWqPaxyQalTf8;B2B{bxP$6g#Nw4;OKCq^DAbrx#==W>&? zriS*N6V?b6b{~W zh@Q=m-uQcwi9`^%85jP}U{pbkIE=RF8f(=21C&!}&ap@P1)yJqvLn-qAs+H&Z=taL z4!}`43cv@Yqo=vo-qP;La=Ow_$API!_171q>p2KxC}0gUp1+A=)P>VPOQepi*+3&E zEa~JMvjk8VjQ5!+iD_(|)SYFpPpak#A548+R%y+MG%(Jv*!veU2boqGpH+fhU&wckvJ73o86MkGbYZQ`)PTB8phemo;S=aDSuNO_UzX0v`M^{et{bR`E9?OXt9= z0~`f14tGq<>I+mE?z6PiSgGN9-mJJ#rc%I%!@O5{)w@&3Bqj+`BIfYL@v1)0iA!aY zUv^<~OCj7@tyi1T%H{4ay~Ya{$za~Bt9eMr<@}||8lcz2z#q^A=)Vb=d6F>5C1n=a zHLAQq#^fXsA)RUU&~}J|qAGu^RBB7MN!SQ7kim)%yz2bK7QFl zi|SppZ(|pz-vehMkcS#_Yjc$Uc!m=bj~0rwzI)<@@d+4iErd8ghmFsn_qjOnyyM$7 zDG;T*SAVcE3;MdO6Rt`zyn1+BWalr&$*?}n`-6onQm*<1`ibu}-VXa)ckv;D1uBLm z0YcD~n=PQb~DqefD!f5Mv z8>Qfn27VX|J7LQ_B)R1ar;gfO>`88EF5l_LXiCbBHfqW9NVfJp7#8zcgr-;i+qbS5X)mxWw;C`314D7k7~tL$TCErF8|~4CZVEK5tCFU zR3i7gc^cJGrR9!QfB6V1#tQeA1~&5qiB?3E`DdNV;DAGmX0DAsCv56`$SId^)tAZX~vc9ert7 z=m_wwhBNWQrSK4%HtnKmTR%-RadefoGZI;T+ZP^~A1To5_em_^Lx{)GHNC-1JXl8oe7?B{jH6P!7Bomh9c&1uDe>$EZN*|KCdd=PzMopE0&K}rQY=U&49fd|194eD zE4Ac1lylyNY6p#qcIUy3!(v9wWUfp<`hUh|n)fpiN4A;tXC@3rI%MGE7_hy>3{Z{ zuspf^1kx;>jgX)6E_Fh)&fH5j@fw<8NwF%O%*&8XVQ`kukxl_N4!^I1!m}{zC|r&z zBQJ^N?ahEWQ6X1lhyS1uheaS^+afhqB6X5stb$B)NtF_z7tH*ujJE(a*BjY!`#=kS z2NPh^CYI_&nxHj+f`VpFoN**cVpLQkg`C6o3HbFy_d+^nREK%@ajEHe1fqh6O?Plf(^r8SYb~r-`u?_% z-FqtmfJc{$qpB?JrknMR{AeV6RQ$l}RBzQhheOhKAJAPbsz(cx6J4!g2MAyUVWD=K z3eGEikNn7c@z8)$I1BOG{^M@}-@m@>AP}hLFNgSn&r`3KW}*M+`HSnaq&H? zwUu=&N}8irmGRr_Hy0iEs1>_;@BVr~@j( zzt)@V)2ie#UUXl;_8yvcWFK@=2t!9rM4kTu+Hl@SK=&`A1*%k5^6ZPB`_D{y-cQ9z zZr$L&e|P=5tq%T7>dp13AIr@|7n^_dOnZsErh0W~S_Q5+(UrzF5x)%#cgW1UE^0z~ zgSkGkHnFJp(Vu^249BfmKGt*~_3b6MMU}HM7Wp+D`s+xDI~jWD6aVvuFx>FXA z<0}05tk;FWg@SY#oMZ$CW$x7T=YvE><^>>7|DJ0WmZj7}9o3uK2zPCkFHH{nVI;lj z?S1-TguRS$9%yJ(qtdF{L=Vg|E$br9GIX&3$;=6SntBXtlA>s){Aq;xKI}v{p)Qzr z9~|n>;zQzM?(9PrnI7*cpA%KQc(lP#RsCh>yp+exS!fc`u zolBtz0mZ$9LI@^w7KDYJZ4nK@dW>zQn~oHzVSG!0)HIbkz>7U6Ft^7z=#S|rnfi3Y z?jvM>F*Lkmq)WVbCqTIm6;m-%Pyu8P`kFAXq(MV!~JdxSEcND z<{BZ6Amb)U)d~r&B(Vm9M03p%K`&Izqy4cI5MiRsiqV}__ndZ9av=8J|CyGv0%V0; zc`5#E7JG5}>;aqO#EJ3BpBY2w&L~Z}#aI6u?KmGW_VX*rBQM_J3LxNb3F-tDM)anX z-j5=loaJ>Wgvx72g)ACbN{d)(MAB)|#!K!|8HQx%M&4Xvwztp$s@?PZgyNh58WEpL#c9*KC`QvUJ zh(pV+>$T$`BLLNmhAU-ehEah<&FRP1Hi+!r8;H%Qt0q?aR~yGM5fZXTEA+}i#~gmn zi!c&ZvGrn$$d0=3BD@B^3Dg99buX|cq0ts*rX~%|J%X#*Y>i1;b*AnoG+ue{6|Aia znteg=ggfqyB3Y!RD4|sV9(zt?t;(dc{gx}r{jJ`mBYfIIjnXFsr$RF)pG#(<20dvU zDyit!C{z4yk%F&Jz*`MPJjn%shUy1>mq@G2YXn7U%s0*|2+pGNsHy0B;gvr5lUOTM z_sRLQcSg#enWIvkk9u=%M&*rhr3qtiUIG{s2;f2u&+6$I@v0t{_$swjU7Y7%vXo}s zAm;wnuM-OP;Iy!Ec2F&*n##o3B^Fk#wp_65z#SS3QSxHsTJBzK6YdG5CrIAXT2Be| zxb^oyYP~lFl-po9Bv0PE?%}w_B`cBQ>lzP5)t<3Zq<*}3%7R2hp275di{I>$?)XeL0~~s#6YDRR6Bm`5My+=Y{1j_6}{x6WRk(E$5k0$fyFGlTp6IFCy*EF+=v8HPqbk!a+q;F}oQ zgc9W#qXwNC`cOPC<(UKUK*99K>7nTH;!mgwMW4kwu86g=h&M2MBbg{f-8>*UMzNPZ zJyp0Ny)jf5m*14jId{b1=JtLTeMqO2srL$v3MSU7G8|SL*}A@Y_!OMMJ1B0M)JD5b zS%Fr=u0rML8q7%6R4!;WSdET}Q4%P~!?=Ppg!iPLcWFIJT`6TwY1tCJIZ_$duYL8* zq!{}YeL6$+q`2{S#XwAoIzm^{2GmfPdBq|iuqc<+Dz5~~-#d?>r3pQ9moyBWAr`>8 z2|DD%J3EXbFRQ`B$DJm9QDF9D$E#7zlGFBf$M@;6I}L0ST50Ay{7iZ#DaXl9qQQ5E z{n<$T6^(4hm3k=Sf<(I!I6^m7#pgO8G%Zv@FPE+%BmD8j1WY?W8ZjUj*mD5@iG*UM-6tTtEF+q& z*OqL2=6PpW#v=_~fW4I}QK>>d-XSK{8IWDd#$EwSG^DdhOGioY?PP=aC~DxYmP;Lb zV}kE0#fbSm1AQx}ZUsiTC9ShPCC%7vlfG?;DmZn}2)I6^7%w)&O-B?WgJmVsAgpo? ziCNm1V%Y(8L}t7m&w7sA6SrO+O%}Rwh{_D+TN6aBIsFBLjL&rHiZ~0mKL-4ZD>4Sc zPIT8NApnU-n%518O*L{?2b5r6f;Tif(Yx?ixyV4awa=ek`nn1JwT73AY8>5UFt05u zyi#KvtBd+~f=!^a;8haH$|88(_^vf(ADH^h-Exr~c2qqa( zQ4R>xOjJy98TMX!5D$G`Sgfd$vy*5~DAh%zE-Z~$E8H(ohRuaTBFz9V>G@AnTkS{W zFPE%W6e}@c4P#pzh1Qix7k9sNBT$Zl)v)W6UF&LiEp5OB*CEOQOO5VzjChayizT3k zDG(nBrgFfxZ6KK18x*{{tp$gVd~r~q%}xBJOjrSM(Gqq+y`%hsplA2QE`ZmZ$>>&f zt`~?ZlrCqnD_8Y}vsbt3J<5{$*AD&%UY?C;!st{xVrZzJk(!(eeCJ6h5$#CIYN{0*T=Zl+!{_ zKm6&dlaxB2Gw8f=s!eyE^=Ajn4n(>~FL{^ck2xWoMAvz*QhQY3*IfxAS*u&0<56QK z7-;M1+2qby$;&J~28b4ND$g;6wIrKxbXWar5w96%VR0# zGXAbrLvnv)3%7!MA!xPF+};i1um|Pl|4Ag^@EtvM$|hB#3&;ge=MdZ6FEl^ zWz1ZrNL$%o^t++ zRG`h{N!F1{^ble_TSxzU`tS&3$Wd(a*1?|Y#Isl3n&HtJDCN9euSflSIrWVd2p*#T zJ~^tQNe{Z4iycxr>&MoXSDP054cF%~n-4&p0JKiXa#4He`CZ6t2$$0K&Dx`H@xn#! z6FZsR+WN(-V(ak?LNAC|I`I~}^I)}dz^F}TEPNhT&13QT?QsG(w;-~^Lgo)B&W_I2zF;HV6gc7>Gh{(5^!DD5LsP$DeCwiCDCVd04zy z5TJph{DI4~YeKAjMK*VKL^zwr>*@A=+w?9V;C-{Z{z-6TT{o#KD3zY}ehyIh_H~(k zy&tXn@_Na@W4L?4*ztSVOWXO9>tVI{<@>UC`{$m4|7*W1?1x8IQ4g<2%LXyYy#9gzw=j1FZJDdT!5N017~I!209z*rQ*&w>aP8|Sw; zpxHM+@0xyrF2wT*E=?r^V!_OgJhSJK2iXs8@o}hHrz6<7xfkc4F6pAFxCKR`b8s+h zJ=mFSaua&dgw#6xZCg8nr!-VQ!C)^=j7Ik~h@<1T28aOt=H9J|9%hsg$UDWa6Kil8 z_&Tl)YgQ3P(ogA(*P>C64To()Lq=wp5U zMuWJr?FP`lr%2Y5W!28J||>Z@J%(9=x{kVoyOR2rNLH2=qzkH1e9wp<_c_YljRww z9R3PY&FoZn)^$!eXWv4x_a!oB#j>m-SNXV-|)auuRI zlikwxMxJ@H6FG1%@XAr9VpV-XA5yCKFwHOn;p-fucQ0l+EI#zrxC7`Z-UbO*{CS4a zTc$zB_U+QLc=v7Dyq_$wD|5n#CN65fD6@Y4Gb)fXt^as_+p_w3Tlm1wX5g4`+V&zw zSFQh?Ml*8$^+4CxRCS~q=5NYht@<`?)%25wwd&G$;M@&6#7yxVt+OS6`_dh>Z~kJ( zHRl)n)5dat)gyL@78ewZUQO`{M`lQ;dPQ@OSJ(XghWl}AXbwX!#?f;3%-4T=AVWBP z;1L@P2xt-(=zkN=olRX_EbYvl|1nTDtJ&HE*pa^Y4Za8Fj=8)V=qR*u3S;-l#JUk2 zBY%~OP7n)6f=R7AhJ8NcQO!GL);ee%M9FPUI*;xT@$`Cs53d`@B$pQEg5PSQ!Aa^u z$(~QszI@c<=0bwnD#jqAvSomK7Y042^?lvhrCA!mrJn)|E^+VwrY(<7(8N3|B*r~u zA(DchP^OH9^Qn{@OYpKes7KNv7IB^^Uk{T)D-nVuF%v2}s}90+(xNO7v#gdOpGf>s z{Cx*2T$f*u_(DGq9ih zP#vUFNT@Xg?0AG)>`LJA0dj)$4rk=9?V`AgFJb4J{d(XEG!Ed^13wB#Q2!WHum% z7OaMFfp)k-2nv;v8qBon03(hBwrPqJ!r2VN;K-kzTyA&-d}vKVifM~{VU3;Bc2+11 zgC1JmA_|_b8?iw-(2e)Q55^hXR$`#$gjWakO#QsIAV4C9^Ws4>Nef!m#w@0=j3C0* zVSazyajXs$+M++w=+1zqo84PHHU1;hmx#dXqhUT2q|jPbAYtGcg|H>&1Df-;KFvfb z-ZEd-9&IXTI8mviP#W4*+cJ%-#bh)HLB z9(3-`kvI)mV8nIgZ|C)aqruyj@fM$X!*x!f;iyYpg<_^LVJ4T3T5NX-G{c zOMOshLBu&yBItnOd<;E=JK}tjTsntO6V;CS6%E}XR<;V$$n@MyIPMyi7;wDT+tcK_ zZ5Ae#8Dwgz1HTILGXy{yJ{Klr2GZA%eHPF4!#9XeV5Ax?y!qy2ngeTJ&j;8^6c{C; zN+I-06OCeb(UB#jthRWpL!SznX8)eU!k#>aayxQU=khtap!`y$cY{DjJ89p(?YA(rzkf=M zAA4MMcw@w$iC8AZ-K$qTZowq?eQ8(V7oz_K`QOdDmj^(ku;74zB3yug5dS^vc6RZ! zG5u#Z(w#Hy2Ixc{eda5>?k`g9axi%nk@u-}j=LOgQMerDXv|_075-5xC44Ah^UT+I zTKgPvCkZYn9mosz8@OznG`0K{c$nauD71K3-}jgN-Y(><6VYPQz{%DX;jSemNdkX1 z+|@O`+csigw}IubQN~@;0PHMepYO+gp06kOOjKI>aR5m1t0PJ-+>uxVCqBZx!whO_ zUlMF?WeiIOS+ycVQ<&jku>V(Mv3=?_-rCz9aSJ%E!%y_5bfepFvmsF_f;Nsvg zA`3oEq3nH!#(~VnPmGPEsX{Swk=$devZVGsGTCC9sS_Z76l62zw5GkBjf_qSh>}5x zuA@3qJx~Nke?B10un(+J4}F$U>V>0ls8OssPU1#R-<$>@PZd#TG*f%7S`oZS7!rlG6ePZG|@OE6nuTYVrJt-jfCX002F#ggU`ARzt6naB!R5wM|wDZY6el}k74ub z=qR1ODZ8dO)6aF?v?s6@uV5^%u*6H7Zwtr|D$mw)6ENXiqh9-#MhIy6u`Vv@cDPpg);t)@PKWM#syAQV?4J)z`WJKFBYdqZ+bKX( zFJ$MsfG1ItRn+*n(G5x9%fYn#F)`Rsw%^v@G7~nVzRZT>PiJ4wLd^}AK^Eg} z;ZL6t8}6I+^vb)p`24cRAa`A|4{nrQ-g%cWW`W(~efz+@3y~MKvYu!r*LM-D7SN4! z-6#LZlUXZQE2Q$B$(R`#IAI%fAqZJpMDA#5o1VH`?#)stz~1ny3PsqiEEsCDviMMg z$LmoQx!$QH3VQWpCtZ*v^S$?6gUh$u=iCfG;+zUS;_MDS;;arl;>`9x;w0zJ+)p31 zh&9e6<_v+;ZJNuxc#mD*p4sw^zS*}EAboLcBLMzr2cY&6xO@9P@7pOH`)*#Y1LC^) zH1|7}m1)|Q*=3@(>9eFXiyL=^2J+q)H7YeqMN9si2I0?2x+MbrG;Mto+|2sHeDC!R z$3*@pkc-i|0DC~29uda8?|B`2{{R_x7zjxli57>Hep@(&OR~pO#=^gkr63~D)o_~M zi2}F4oPjbm4em{7e9KCV-%FN2N>xqXT$y*gK^kbFOhG$k@?9$>o=Q+Fw(VEVO3F#I zpN`tlJ15IDbf%AkDf_+9Z}6rL0kPP}SkGF7U6?7(s>~K+(Qk!4->!O}WVQK^n`PEQ z$12Ou(Mx%>2C3VpO5>%^%`H#bN3oFY&-C!H88)R~P zEL3G;MV9atNtP6}^i|-ter*kf-*DPEyTT@18St46U@R|DdNWpBRLnb_X`ei;W{+*l z=hdPcJ=junlNzCMtd=i#rUNXhseDi5x?`NF3cQ6R@{zJ?&eN?Qxe3KdhXNE)T$3Rc zDeOpFh>w#1Xw5w6 zk4bm_PZ~K$1y$tiUZl%Qs`*nQP#z30$c#u*65>QHvq+VAToL%|>km_E%^&k~b}V{Z zz$y;Jd%XJfr- z{W$t4(mczw;MVyz9fYX;Mj0rtKtS~RLbTkiJ6YvxIxou=61snXSTpmc5j zy;pZMu3^^1T#D0A7H9H{|5mF|M3e&9&3n%lg!-1Z&Fm0JTyRw z%FsZC851)F0z#Zx@SDelR^uD+kHp)R8gm|{KTv6!WnoBQUKJl!oNX2^+A0?dbD5Wg zvC^~kn`*ZyLknyX&xn?EIlC#|1v0aFcU;(v)|*J!H}(~jgi}as-j0Q9Q6E0R(iN2h zSUImK78(P99xHut?IXRyHeGmuF@UA?V^kR>M_Ze~LH6LN#~Ls=#lhv}{)gR+C~uN; zFT*%*v!PNiS4Dr)W9!#qjC>hi-S44pDDwN|bzdBo?JevM+BJ^BR*p5+qy58Y&CEeN z57G&fb)^^6$XnlM1C}C=K*UzaI&~DBgfKi3!>s+wgmcw9@dKq%$+e^R*o2WXoCI6n zm*K|IkqCz+c0V=AR`9qDv-wcfz(vL-b=Z9FE#CduYcqUz z-Vrf7m=Sq9X>$5kIJ3CZnWsi`!>c(DBdH#GOBnK5CMeJ>O$s<+u8lTuXk) zTh0WD&-gyWs^e&m@WtjV#^B6zUAH6WpvL3OQ=xJVDN&8fon~ikhR;Y%#0Z+xc!>6! z(qP6-sxfX;XbVLuw4OD%tD>WIO7^$O2 zvCv1}|62I`r;J)OKL!T*AF31OgqxG)@qz6B&QjB7d*Jy0Z*xDU$0&Jo>pEhalBV;vRn1> zSgq4=RK2?+&lLN>VfT5o+n>eQAu+{-u$GnMr7hdmc94O%{<%N*eCP17@tP&jt?Yv} ztjz#<5n7dWkUz|~od6`{kMUS!ow|`~xZ*dP>hqxBLLhD=*4`f8m7=+Ff(+PNj=6i`5B7d{L4=%_(q~IC9ltq%;mXHo9*m#o-OG3e_m*V|XI*)$c%Qogjf3_P?(1rhLHjsL(~CU4=2Y9wNlHl)FA zgK7eCgfyW+O3SVhjI$b#e5y{R4K!~7YtgcbtxC0--wduj>wwlZbwO41;%4$N+@fqA zwEGL*ZdD;=HqSN#?IkX7T@q`2WQJFO+er|Kk;QD&d4L(B)Et zjl}uyJ?%27v#=}QqtMrj(Z|eR14aIB>UB!qTs|8fA1P*8=Jyq|7*XDWD z{xtzdLF$piQ>fK06oGG=29H5ivij#VEvm-k5UnUPsw{?|O{%qOZbB@v`o(P1wdNal zi;rtHkfm>%BlXQse@Q`IHDM&9f-1QVkLMz`ceZ#xx6((JN#>Ub)GdpF6dw2cD-pzJ ztA#5rk9lM5H6ATH9Q;_1nhH0#H`oNEZmiy$-(303easf>Q5iQcWFH%SX|s&et9Af& z*N6`Ak9tFjSvab1v5j5&IBHOnI`C1B@FxKN6Tqy1@7MdWpYPZ6F~MU#^YWUyt!`5X z{zS{qrTQb`b8GhNE8jJO9Nne+)khWH^9+yobI1c*e$?wzjdV+vMFD`9Ex&iOHsaLv zpGH2i*Y1^CyXsq}V2U7~gd5(M!Ms3wW<&Cz3<`L5J80(@x010prJzSS1e`c*)@w}# zxawuiuN1$JzZ*XPP3-;}hDu)k?;eit-(UsWzgH3Nrba*iBO;N#sBiuEUmZX<&jY<8 z<6Wf_XjE#3aHD!71^~$L)8$aIj>M>{tHtY$3g;K2=fv;leR`t%!fs3r%7c+*Q>QvAOe-15#WW6) zp&CKQ(}u><%s%7*8Ri8T>0k zbp?#w7)d2^c}h=?WffwnX0pG@oFNpEqbk#w`M0ZVj8#=q3eR+ceWm2&H~-h}Z7qLN z@@>mUi7U4@9a%r`!(mm~JKJLFZ2=1OpG~4|zZ-ZRC%oOXNo@A? z=kD`P*zZsK$crufmxV;E`v468D&YKy5G?%DfXC*9Bqrsgg2sdHOg`8*+d!c0{nTH4 zC%*Hxwd8b7TXUsEIdoOkif#N4B$&fC_S*P)+}oGzoUtTh>C$$&QwMMSudqw2o4x$` z=B&k&Hz@S#d2CvsA~b7u(5qbv_VM5OrG*-kccrYoxJW^q@k!jSg8KieEjOF1&5^7O=~Hs`7yo%q!Dx%}uIh6N?5#@&Jjz_ep%#xAjv(Do2Trv;gNZzcsoX-!?3l{JM8ZiR<(_m5ujLNT)egc-+`=o44rb zlpOtV(JD>@7OR&P#aI2$c*TDeV2VtBz`-6hVY=wjm#6Fe*;E~kTvNaPs0<0x$*QpH zJyjI6BD~^3OR7;p#RH$}53@f$I51aueGq5IzHYl;i6WQtEhb$uwVJq4{@%=|I_^0S z&J`W`UjO{WW3}JveLdyMztmnI+gs`UXYUXH{1X<9Z}YL{&of)Dz0L-vkB7kXTLghK zvcPHBfTH|@V*TX&B5?8mp5ih(4GRpqo=JiJ%?2W^_kI8HFL@u3Fj<00Cvda+^cfb5 zZ${_7-)0pxYn$|^_qql%j@bSv%Rj$Ue){FK?)<c2x3wr8 zo_;Cx*^i&BSH2xjew?+c@rIZ9>`Sf}S0479{grXnu1jZj?DTw|!^3kmCbGQQwzTNM z9aY1!r|U{iH`{KKkoj{DF289trN3;NT3X>?<+K@#$1YwCb0^b#jDHH9q`U-*qoX##C?2QPV z*GTMB-@j})|AvQHTK?$v|9Wt;T)R|tzKc_xxzDWDqLu7s3j=nm_^hpd zB6={GnSasI6XzRxS#5SNP}0ACROm*;;dR1}CtL(SZCY{7<&40N5BD-Og^r%i-^!!6 zOD*;L{CS%l8E^dIxFAT64?ezfD2}Vy z`c#AYAs3c!%m>bWjf)UU33hGL|9J8GyP}TA*-y+ZqQ68g&wJmh!k-$trf#jx;{<;V zty_skhKvQi^=dO7vd!A6yXw7heW&7)Zgc@!RLE^y0J2w;#3#hCF1&0wa?y zu!V#Ds5)>2ArV*(t_$!+H30QsEhN1RlYvbsq+_JlA< zW(J0X+EDE{7VMy#guZM8Vb*tD9E&*6jXuo2`DWwbp5DxGeS25L$D_U14d1at{t`DMArV* c8%aB~7!B}d1tt&BA#U6Z2Z2THN + \ No newline at end of file diff --git a/.tmp_docx/_rels/.rels b/.tmp_docx/_rels/.rels new file mode 100644 index 00000000..32548d42 --- /dev/null +++ b/.tmp_docx/_rels/.rels @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.tmp_docx/docProps/app.xml b/.tmp_docx/docProps/app.xml new file mode 100644 index 00000000..94823e96 --- /dev/null +++ b/.tmp_docx/docProps/app.xml @@ -0,0 +1,2 @@ + +27180410284Microsoft Office Word08524falsefalse12064falsefalse16.0000 \ No newline at end of file diff --git a/.tmp_docx/docProps/core.xml b/.tmp_docx/docProps/core.xml new file mode 100644 index 00000000..0bc997b6 --- /dev/null +++ b/.tmp_docx/docProps/core.xml @@ -0,0 +1,2 @@ + +ZlataZlata22026-05-16T12:44:00Z2026-05-16T12:46:00Z \ No newline at end of file diff --git a/.tmp_docx/word/_rels/document.xml.rels b/.tmp_docx/word/_rels/document.xml.rels new file mode 100644 index 00000000..3b2b7f8c --- /dev/null +++ b/.tmp_docx/word/_rels/document.xml.rels @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.tmp_docx/word/document.xml b/.tmp_docx/word/document.xml new file mode 100644 index 00000000..94363c72 --- /dev/null +++ b/.tmp_docx/word/document.xml @@ -0,0 +1,2 @@ + +Назначение слояDomain-слой является центральным и наиболее стабильным слоем приложения. Он содержит бизнес-логику, не зависящую от деталей реализации пользовательского интерфейса, источников данных и внешних фреймворков. Слой спроектирован в соответствии с принципами чистой архитектуры: все зависимости направлены внутрь, а доменные сущности и сценарии использования не имеют ссылок на внешние слои.Структура слояDomain-слой организован в соответствии с принципом разделения ответственности и включает следующие пакеты:- model — доменные сущности и типы данных;- repository — интерфейсы репозиториев, представляющие абстракции доступа к данным;- ai — интерфейс сервиса искусственного интеллекта;- usecase — сценарии использования, реализующие бизнес-операции.Модели данных (пакет model)NoteЦентральная сущность приложения, представляющая заметку. Содержит следующие поля:- id — уникальный идентификатор заметки, генерируется как UUID;- title — заголовок заметки;- folderId — идентификатор родительской директории, может быть null для заметок без папки;- contentItems — список элементов содержимого: текст, изображения, файлы и ссылки;- createdAt и updatedAt — временные метки создания и последнего изменения, представлены типом Instant из kotlin.time;- tags — множество тегов для категоризации заметок;- isFavorite — флаг избранного;- summary — краткое содержание, сгенерированное ИИ, может быть null, если саммари ещё не создавалось.Все поля, кроме id, title и contentItems, имеют значения по умолчанию, что упрощает создание новых заметок. Класс является иммутабельным (data class), любые изменения выполняются через метод copy. NoteFolderМодель директории для организации заметок. Директории образуют плоскую структуру без вложенности. Поля:- id — уникальный идентификатор директории (UUID);- name — название директории;- createdAt и updatedAt — временные метки создания и изменения;- metadata — словарь дополнительных параметров, зарезервированный для будущих расширений.ContentItemИерархия sealed-классов, описывающая различные типы содержимого заметки. Каждый элемент имеет уникальный идентификатор id, генерируемый как UUID.Типы элементов содержимого:ContentItem.Text — текстовый блок заметки. Содержит поля: text (строка текста) и format (формат текста: PLAIN, MARKDOWN или HTML). По умолчанию используется PLAIN.ContentItem.Image — изображение. Содержит поля: source типа DataSource (путь к файлу), mimeType (MIME-тип изображения), а также опциональные width и height (размеры в пикселях).ContentItem.File — вложенный файл. Содержит поля: source типа DataSource, mimeType, name (имя файла) и опциональный size (размер в байтах).ContentItem.Link — ссылка. Содержит поля: url (адрес ссылки) и опциональный title (заголовок ссылки).DataSourceВспомогательная модель для описания источника данных вложения. Содержит поля:- localPath — путь к файлу на устройстве, может быть null;- remoteUrl — URL удалённого ресурса, может быть null.Вычисляемое свойство displayPath возвращает первый доступный путь: localPath, если он задан, иначе remoteUrl. Это упрощает отображение в пользовательском интерфейсе. TextFormatПеречисление форматов текста со следующими значениями:- PLAIN — обычный текст;- MARKDOWN — текст с разметкой Markdown;- HTML — текст с разметкой HTML.Перечисление закладывает основу для будущей поддержки форматированного текста.Интерфейсы репозиториев (пакет repository)Репозитории определяют контракты доступа к данным, не зависящие от конкретного источника. Реализации находятся в data-слое. NotesRepositoryИнтерфейс репозитория заметок. Предоставляет следующие методы:- observeNotes() — возвращает Flow со списком всех заметок, реактивно обновляется при изменениях;- observeNotesByFolder(folderId) — возвращает Flow со списком заметок, отфильтрованных по идентификатору директории;- getNoteById(id) — suspend-функция, возвращает заметку по идентификатору или null;- createNote(note) — suspend-функция, создаёт новую заметку и возвращает её идентификатор;- updateNote(note) — suspend-функция, обновляет существующую заметку;- deleteNote(id) — suspend-функция, удаляет заметку по идентификатору.Операции observeNotes и observeNotesByFolder возвращают реактивные потоки (Flow), что обеспечивает автоматическое обновление UI при изменении данных. NoteFolderRepositoryИнтерфейс репозитория директорий. Предоставляет следующие методы:- observeFolders() — возвращает Flow со списком всех директорий;- createFolder(folder) — создаёт новую директорию, возвращает её идентификатор;- renameFolder(id, name) — переименовывает директорию;- deleteFolder(id) — удаляет директорию по идентификатору;- getFolderById(id) — возвращает директорию по идентификатору или null;- updateFolder(folder) — обновляет данные директории.Интерфейс ИИ-сервиса (пакет ai)Интерфейс NoteAiService абстрагирует работу с нейросетевыми моделями. Реализация в data-слое использует OpenVINO для локального выполнения моделей. Такое разделение позволяет заменять модели без изменения бизнес-логики.Методы интерфейса:- summarize(text) — принимает текст заметки, возвращает строку с кратким содержанием;- tagTXT(text) — принимает текст заметки, возвращает множество тегов, извлечённых из текста;- tagIMGs(img) — принимает список путей к изображениям (локальных или URL), возвращает множество тегов, извлечённых из изображений с помощью компьютерного зрения.Все методы являются suspend-функциями, поскольку выполнение нейросетевых моделей может занимать продолжительное время.Сценарии использования (пакет usecase)Каждый сценарий использования (Use Case) реализует ровно одну бизнес-операцию. Классы используют паттерн «функциональный объект» с методом invoke, что позволяет вызывать их подобно функциям. Сценарии использования являются единственной точкой входа в доменную логику для внешних слоёв.Операции с заметками (пакет noteusecase)CreateNoteUseCase — создание заметки. Проверяет уникальность заголовка в рамках директории, нормализует название (удаляет лишние пробелы), генерирует UUID и временные метки.GetNoteUseCase — получение заметки по идентификатору. Делегирует вызов репозиторию.UpdateNoteUseCase — обновление заметки. Проверяет уникальность заголовка (исключая саму обновляемую заметку), обновляет временную метку.DeleteNoteUseCase — удаление заметки по идентификатору.DuplicateNoteUseCase — создание копии заметки. Генерирует новые идентификаторы для заметки и всех её элементов содержимого, добавляет суффикс «Copy» к заголовку. Если исходный заголовок пуст, используется заголовок «Copy». Сохраняет теги, флаг избранного и краткое содержание.ObserveNotesUseCase — реактивное наблюдение за всеми заметками. Возвращает Flow из репозитория.ObserveNotesByFolderUseCase — фильтрация заметок по директории. Если folderId равен null, возвращаются все заметки.SearchNotesUseCase — поиск заметок по запросу. Проверяет совпадение в заголовке и текстовом содержимом без учёта регистра. Если запрос пуст, возвращаются все заметки.GetNotesByTagUseCase — фильтрация заметок по тегу. Сравнение выполняется без учёта регистра. Если тег пуст, возвращаются все заметки.GetAllFavoritesUseCase — получение избранных заметок. Фильтрует заметки по флагу isFavorite.SwitchFavoriteUseCase — переключение флага избранного. Инвертирует текущее значение isFavorite.MoveNoteToFolderUseCase — перемещение заметки в другую директорию. Проверяет существование целевой директории и заметки.AddTagUseCase — добавление тега к заметке. Нормализует тег (удаляет пробелы по краям). Если тег уже существует, он не дублируется, поскольку используется Set.DeleteTagUseCase — удаление тега из заметки. Нормализует тег перед удалением.ApplySummaryUseCase — сохранение сгенерированного краткого содержания в заметку. Обновляет временную метку.ApplyTagsUseCase — сохранение набора тегов в заметку. Нормализует все теги (тримминг, фильтрация пустых). Обновляет временную метку.Операции с директориями (пакет folderusecase)CreateFolderUseCase — создание директории. Проверяет уникальность имени (без учёта регистра), нормализует название, генерирует UUID и временные метки.GetFolderUseCase — получение директории по идентификатору.UpdateFolderUseCase — обновление директории. Проверяет уникальность имени (исключая саму обновляемую директорию), нормализует название.DeleteFolderUseCase — удаление директории. Защищает системную директорию «all» от удаления. Перед удалением директории каскадно удаляет все заметки, привязанные к ней.ObserveFoldersUseCase — реактивное наблюдение за списком директорий.Операции с содержимым (пакет contentusecase)CreateContentItemUseCase — создание элемента содержимого. Присваивает новый UUID элементу в зависимости от его типа (Text, Image, File, Link).AddContentItemUseCase — добавление элемента в заметку. Проверяет, что заметка существует и что элемент с таким идентификатором ещё не добавлен (защита от дубликатов).GetContentItemUseCase — получение элемента содержимого по идентификатору заметки и идентификатору элемента.DeleteContentItemUseCase — удаление элемента из заметки. Если элемент с указанным идентификатором не найден, операция завершается без ошибок.Интеллектуальные операции (пакет aiusecase)SuggestSummaryUseCase — генерация краткого содержания заметки. Извлекает весь текст из элементов ContentItem.Text, объединяя их через перевод строки, и отправляет в AI-сервис. Если заметка не найдена, возвращает ошибку.SuggestTagsUseCase — генерация тегов для заметки. Извлекает текст из элементов ContentItem.Text и пути к изображениям из элементов ContentItem.Image (используя localPath или remoteUrl). Отправляет текст и изображения в соответствующие методы AI-сервиса. Результаты тегов от текстовой модели и модели компьютерного зрения объединяются оператором union множеств. Вспомогательные функцииФункция requireNotBlank является внутренней (internal) и используется для валидации строковых параметров во всех сценариях использования. Принимает значение и название поля. Выбрасывает IllegalArgumentException с сообщением вида «fieldName must not be blank», если значение пустое или состоит из одних пробелов.Принципы обработки ошибокВсе сценарии использования, выполняющие операции с возможными отказами, возвращают Result. Успешный результат возвращается как Result.success(value), ошибка — как Result.failure(exception) с информативным сообщением.Исключения выбрасываются в следующих случаях:- сущность не найдена — IllegalArgumentException с указанием идентификатора;- нарушение уникальности — дубликат названия заметки или папки;- некорректные входные данные — пустые строки после нормализации;- попытка удаления или переименования системной директории «all».Принципы реактивностиДля наблюдения за изменениями данных используются реактивные потоки Kotlin Flow. Это позволяет UI-слою автоматически получать обновления при изменении данных без явных колбэков. Методы observeNotes, observeNotesByFolder и observeFolders возвращают Flow, который эмитирует новое значение при каждом изменении в репозитории. Доменные модели являются иммутабельными, что гарантирует потокобезопасность и предсказуемость при работе с реактивными потоками.ТестированиеВсе сценарии использования покрыты модульными тестами с использованием fake-реализаций репозиториев и AI-сервиса. Тесты проверяют:- успешные сценарии: создание, обновление, удаление сущностей;- обработку граничных случаев: пустые строки, дубликаты идентификаторов и названий;- корректность передачи данных между слоями;- иммутабельность доменных моделей: оригинальные объекты не изменяются при операциях;- каскадное удаление: при удалении директории удаляются все связанные заметки;- нормализацию данных: теги и названия обрезаются от пробелов.Тестовые fake-объекты (FakeNotesRepo, FakeFolderRepo, FakeNoteAiService) эмулируют поведение реальных репозиториев в памяти, что позволяет тестировать бизнес-логику изолированно от внешних зависимостей. Fake-репозитории хранят данные в HashMap и обновляют MutableStateFlow при каждом изменении, полностью имитируя реактивное поведение реальных реализаций. \ No newline at end of file diff --git a/.tmp_docx/word/fontTable.xml b/.tmp_docx/word/fontTable.xml new file mode 100644 index 00000000..65e83160 --- /dev/null +++ b/.tmp_docx/word/fontTable.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.tmp_docx/word/settings.xml b/.tmp_docx/word/settings.xml new file mode 100644 index 00000000..0075fbdc --- /dev/null +++ b/.tmp_docx/word/settings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.tmp_docx/word/styles.xml b/.tmp_docx/word/styles.xml new file mode 100644 index 00000000..2f8b3a4e --- /dev/null +++ b/.tmp_docx/word/styles.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.tmp_docx/word/theme/theme1.xml b/.tmp_docx/word/theme/theme1.xml new file mode 100644 index 00000000..88a9084c --- /dev/null +++ b/.tmp_docx/word/theme/theme1.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.tmp_docx/word/webSettings.xml b/.tmp_docx/word/webSettings.xml new file mode 100644 index 00000000..67b79831 --- /dev/null +++ b/.tmp_docx/word/webSettings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6c39f26..902fa0e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,4 +99,7 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) + implementation(libs.firebase.auth) + implementation(libs.kotlinx.coroutines.play.services) + implementation("com.google.android.gms:play-services-auth:20.7.0") } diff --git a/app/gradle.lockfile b/app/gradle.lockfile index ad4f46f1..6691f85c 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -19,7 +19,7 @@ androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndro androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.asynclayoutinflater:asynclayoutinflater:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.browser:browser:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.browser:browser:1.4.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.cardview:cardview:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -122,8 +122,8 @@ androidx.core:core-ktx:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestL androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.17.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.credentials:credentials-play-services-auth:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.credentials:credentials:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.credentials:credentials-play-services-auth:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.credentials:credentials:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath @@ -327,12 +327,13 @@ com.github.ajalt.clikt:clikt-jvm:5.0.2=ktlint com.github.ajalt.clikt:clikt:5.0.2=ktlint com.google.accompanist:accompanist-drawablepainter:0.32.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-ads-identifier:18.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-auth-api-phone:18.0.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-auth-base:18.0.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-auth:20.7.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-auth-api-phone:18.0.2=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-auth-base:18.0.4=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-auth:20.7.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-base:18.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath com.google.android.gms:play-services-base:18.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-fido:20.0.1=debugCompileClasspath com.google.android.gms:play-services-fido:20.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-measurement-api:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-measurement-base:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -345,9 +346,9 @@ com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspa com.google.android.libraries.identity.googleid:googleid:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.material:material:1.10.0=releaseUnitTestRuntimeClasspath com.google.android.material:material:1.13.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.play:core-common:2.0.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.play:integrity:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.recaptcha:recaptcha:18.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.play:core-common:2.0.3=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.play:integrity:1.3.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.recaptcha:recaptcha:18.6.1=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.48.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control @@ -368,10 +369,11 @@ com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-plat com.google.errorprone:error_prone_annotations:2.30.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,unified-test-platform-android-test-plugin-host-emulator-control com.google.firebase:firebase-analytics:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-annotations:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-appcheck-interop:17.0.0=debugCompileClasspath com.google.firebase:firebase-appcheck-interop:17.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-appcheck:19.0.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-auth-interop:20.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-auth:24.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-auth-interop:20.0.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-auth:24.0.1=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-bom:34.12.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-common:22.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-components:19.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/AppModule.kt similarity index 96% rename from app/src/main/java/com/itlab/notes/di/AppModule.kt rename to app/src/main/java/com/itlab/AppModule.kt index 739aa54e..060064f3 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/AppModule.kt @@ -1,4 +1,4 @@ -package com.itlab.notes.di +package com.itlab import com.itlab.domain.usecase.folderusecase.CreateFolderUseCase import com.itlab.domain.usecase.folderusecase.DeleteFolderUseCase @@ -18,6 +18,7 @@ import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import com.itlab.notes.ui.NotesUseCases import com.itlab.notes.ui.NotesViewModel +import com.itlab.notes.ui.auth.AuthViewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -62,4 +63,5 @@ val appModule = } viewModelOf(::NotesViewModel) + viewModelOf(::AuthViewModel) } diff --git a/app/src/main/java/com/itlab/notes/NotesApplication.kt b/app/src/main/java/com/itlab/notes/NotesApplication.kt index 3f4764b3..90a23692 100644 --- a/app/src/main/java/com/itlab/notes/NotesApplication.kt +++ b/app/src/main/java/com/itlab/notes/NotesApplication.kt @@ -2,7 +2,7 @@ package com.itlab.notes import android.app.Application import com.itlab.data.di.dataModule -import com.itlab.notes.di.appModule +import com.itlab.appModule import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index d17d2e11..124fe927 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -1,6 +1,10 @@ package com.itlab.notes.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.itlab.notes.ui.auth.AuthViewModel +import com.itlab.notes.ui.auth.authScreen import com.itlab.notes.ui.editor.editorScreen import com.itlab.notes.ui.filterDirectoriesByName import com.itlab.notes.ui.notes.NotesListActions @@ -10,6 +14,17 @@ import org.koin.androidx.compose.koinViewModel @Composable fun notesApp() { + val authViewModel: AuthViewModel = koinViewModel() + val authState by authViewModel.uiState.collectAsState() + if (!authState.isSignedIn && !authState.continueOffline) { + authScreen(authViewModel) + return + } + notesMain() +} + +@Composable +private fun notesMain() { val viewModel: NotesViewModel = koinViewModel() val state = viewModel.uiState diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt new file mode 100644 index 00000000..0c6e3007 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt @@ -0,0 +1,269 @@ +package com.itlab.notes.ui.auth + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.itlab.notes.R +import org.koin.androidx.compose.koinViewModel + +@Composable +fun authScreen( + viewModel: AuthViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + val webClientId = stringResource(R.string.default_web_client_id) + val googleSignInEnabled = webClientId.isNotBlank() && webClientId != "YOUR_WEB_CLIENT_ID" + + val googleSignInClient = + remember(context, webClientId) { + val options = + GoogleSignInOptions + .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(webClientId) + .requestEmail() + .build() + GoogleSignIn.getClient(context, options) + } + + val googleLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) { + viewModel.clearError() + return@rememberLauncherForActivityResult + } + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + runCatching { + val account = task.getResult(ApiException::class.java) + val token = account.idToken + if (token.isNullOrBlank()) { + viewModel.reportError("Google sign-in did not return a token.") + } else { + viewModel.signInWithGoogle(token) + } + }.onFailure { error -> + if (error is ApiException && error.statusCode == 12501) { + viewModel.clearError() + } else { + viewModel.reportError(error.message ?: "Google sign-in failed.") + } + } + } + + Scaffold { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.auth_title), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.auth_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = email, + onValueChange = { + email = it + viewModel.clearError() + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.auth_email_label)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + enabled = !state.isLoading, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { + password = it + viewModel.clearError() + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.auth_password_label)) }, + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Rounded.VisibilityOff + } else { + Icons.Rounded.Visibility + }, + contentDescription = + if (passwordVisible) { + stringResource(R.string.auth_hide_password) + } else { + stringResource(R.string.auth_show_password) + }, + ) + } + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + enabled = !state.isLoading, + ) + + state.errorMessage?.let { message -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (state.isSignUpMode) { + viewModel.signUpWithEmail(email, password) + } else { + viewModel.signInWithEmail(email, password) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading, + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text( + text = + if (state.isSignUpMode) { + stringResource(R.string.auth_create_account) + } else { + stringResource(R.string.auth_sign_in) + }, + ) + } + } + + TextButton( + onClick = { viewModel.toggleSignUpMode() }, + enabled = !state.isLoading, + ) { + Text( + text = + if (state.isSignUpMode) { + stringResource(R.string.auth_already_have_account) + } else { + stringResource(R.string.auth_need_account) + }, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = { + googleLauncher.launch(googleSignInClient.signInIntent) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading && googleSignInEnabled, + ) { + Text(stringResource(R.string.auth_google)) + } + + if (!googleSignInEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.auth_google_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton( + onClick = { viewModel.continueOffline() }, + enabled = !state.isLoading, + ) { + Text(stringResource(R.string.auth_continue_offline)) + } + } + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt new file mode 100644 index 00000000..bdf11f4a --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt @@ -0,0 +1,144 @@ +package com.itlab.notes.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseAuthWeakPasswordException +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +data class AuthUiState( + val isSignedIn: Boolean = false, + val continueOffline: Boolean = false, + val isLoading: Boolean = false, + val isSignUpMode: Boolean = false, + val errorMessage: String? = null, +) + +class AuthViewModel( + private val firebaseAuth: FirebaseAuth, +) : ViewModel() { + private val _uiState = MutableStateFlow(AuthUiState(isSignedIn = firebaseAuth.currentUser != null)) + val uiState: StateFlow = _uiState.asStateFlow() + + private val authStateListener = + FirebaseAuth.AuthStateListener { auth -> + _uiState.update { + it.copy( + isSignedIn = auth.currentUser != null, + isLoading = false, + ) + } + } + + init { + firebaseAuth.addAuthStateListener(authStateListener) + } + + override fun onCleared() { + firebaseAuth.removeAuthStateListener(authStateListener) + super.onCleared() + } + + fun toggleSignUpMode() { + _uiState.update { it.copy(isSignUpMode = !it.isSignUpMode, errorMessage = null) } + } + + fun continueOffline() { + _uiState.update { it.copy(continueOffline = true, errorMessage = null) } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun reportError(message: String) { + _uiState.update { it.copy(isLoading = false, errorMessage = message) } + } + + fun signInWithEmail( + email: String, + password: String, + ) { + val trimmedEmail = email.trim() + if (trimmedEmail.isEmpty() || password.isEmpty()) { + _uiState.update { it.copy(errorMessage = "Email and password are required.") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + firebaseAuth.signInWithEmailAndPassword(trimmedEmail, password).await() + }.onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = mapAuthError(error), + ) + } + } + } + } + + fun signUpWithEmail( + email: String, + password: String, + ) { + val trimmedEmail = email.trim() + if (trimmedEmail.isEmpty() || password.isEmpty()) { + _uiState.update { it.copy(errorMessage = "Email and password are required.") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + firebaseAuth.createUserWithEmailAndPassword(trimmedEmail, password).await() + }.onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = mapAuthError(error), + ) + } + } + } + } + + fun signInWithGoogle(idToken: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + val credential = GoogleAuthProvider.getCredential(idToken, null) + firebaseAuth.signInWithCredential(credential).await() + }.onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = mapAuthError(error), + ) + } + } + } + } + + private fun mapAuthError(error: Throwable): String = + when (error) { + is FirebaseAuthInvalidUserException -> + "No account found for this email. Try creating an account." + is FirebaseAuthInvalidCredentialsException -> + "Invalid email or password." + is FirebaseAuthWeakPasswordException -> + "Password must be at least 6 characters." + is FirebaseAuthUserCollisionException -> + "An account with this email already exists. Sign in instead." + else -> error.message ?: "Authentication failed. Please try again." + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97483cfe..43ae401b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,17 @@ Notes - \ No newline at end of file + YOUR_WEB_CLIENT_ID + Notes + Sign in to sync your notes across devices + Email + Password + Sign in + Create account + Need an account? Create one + Already have an account? Sign in + Continue with Google + Google sign-in requires a Web Client ID in Firebase (see default_web_client_id). + Continue without signing in + Show password + Hide password + From 96bac8a35112e0e06f8d7b897e2701c7368d1c34 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 10:55:19 +0300 Subject: [PATCH 25/35] fix: auth --- .../com/itlab/notes/ui/auth/AuthScreen.kt | 39 ++++++++++++++----- app/src/main/res/values/strings.xml | 3 +- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt index 0c6e3007..e4bd6e1a 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.common.api.ApiException import com.itlab.notes.R @@ -60,7 +61,7 @@ fun authScreen( var passwordVisible by rememberSaveable { mutableStateOf(false) } val webClientId = stringResource(R.string.default_web_client_id) - val googleSignInEnabled = webClientId.isNotBlank() && webClientId != "YOUR_WEB_CLIENT_ID" + val googleSignInEnabled = webClientId.isNotBlank() val googleSignInClient = remember(context, webClientId) { @@ -75,24 +76,34 @@ fun authScreen( val googleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode != Activity.RESULT_OK) { - viewModel.clearError() + // Google Sign-In may return RESULT_CANCELED even after a successful account pick. + val data = result.data + if (data == null) { + if (result.resultCode == Activity.RESULT_CANCELED) { + viewModel.clearError() + } else { + viewModel.reportError("Google sign-in returned no data.") + } return@rememberLauncherForActivityResult } - val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) - runCatching { - val account = task.getResult(ApiException::class.java) + try { + val account = + GoogleSignIn + .getSignedInAccountFromIntent(data) + .getResult(ApiException::class.java) val token = account.idToken if (token.isNullOrBlank()) { - viewModel.reportError("Google sign-in did not return a token.") + viewModel.reportError( + "Google sign-in did not return a token. Check Web Client ID in Firebase.", + ) } else { viewModel.signInWithGoogle(token) } - }.onFailure { error -> - if (error is ApiException && error.statusCode == 12501) { + } catch (error: ApiException) { + if (error.statusCode == GoogleSignInStatusCodes.SIGN_IN_CANCELLED) { viewModel.clearError() } else { - viewModel.reportError(error.message ?: "Google sign-in failed.") + viewModel.reportError(mapGoogleSignInError(error)) } } } @@ -238,6 +249,7 @@ fun authScreen( OutlinedButton( onClick = { + viewModel.clearError() googleLauncher.launch(googleSignInClient.signInIntent) }, modifier = Modifier.fillMaxWidth(), @@ -267,3 +279,10 @@ fun authScreen( } } } + +private fun mapGoogleSignInError(error: ApiException): String = + when (error.statusCode) { + GoogleSignInStatusCodes.DEVELOPER_ERROR -> + "Google Sign-In configuration error. Add SHA-1 fingerprint in Firebase Console." + else -> error.message ?: "Google sign-in failed (code ${error.statusCode})." + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43ae401b..1625acd6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ Notes - YOUR_WEB_CLIENT_ID Notes Sign in to sync your notes across devices Email @@ -14,4 +13,4 @@ Continue without signing in Show password Hide password - + \ No newline at end of file From f8f93659a35f389ce4d451a2260aa2cb0d6c791f Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 11:16:48 +0300 Subject: [PATCH 26/35] fix: add logout --- app/src/main/java/com/itlab/AppModule.kt | 9 +++- .../main/java/com/itlab/notes/ui/NotesApp.kt | 7 +++- .../com/itlab/notes/ui/auth/AuthViewModel.kt | 41 +++++++++++++++++++ .../itlab/notes/ui/notes/DirectoriesScreen.kt | 22 +++++++++- app/src/main/res/values/strings.xml | 1 + .../java/com/itlab/data/cloud/AuthManager.kt | 6 +-- .../data/repository/AuthRepositoryImpl.kt | 5 +++ .../com/itlab/data/cloud/AuthManagerTest.kt | 17 ++------ .../data/repository/AuthRepositoryImplTest.kt | 15 +++++++ .../itlab/domain/repository/AuthRepository.kt | 2 + 10 files changed, 104 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/itlab/AppModule.kt b/app/src/main/java/com/itlab/AppModule.kt index 060064f3..c30960c0 100644 --- a/app/src/main/java/com/itlab/AppModule.kt +++ b/app/src/main/java/com/itlab/AppModule.kt @@ -19,6 +19,8 @@ import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import com.itlab.notes.ui.NotesUseCases import com.itlab.notes.ui.NotesViewModel import com.itlab.notes.ui.auth.AuthViewModel +import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -63,5 +65,10 @@ val appModule = } viewModelOf(::NotesViewModel) - viewModelOf(::AuthViewModel) + viewModel { + AuthViewModel( + firebaseAuth = get(), + app = androidApplication(), + ) + } } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 124fe927..a3ed961d 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -20,12 +20,13 @@ fun notesApp() { authScreen(authViewModel) return } - notesMain() + notesMain(authViewModel) } @Composable -private fun notesMain() { +private fun notesMain(authViewModel: AuthViewModel) { val viewModel: NotesViewModel = koinViewModel() + val authState by authViewModel.uiState.collectAsState() val state = viewModel.uiState when (val screen = state.screen) { @@ -52,6 +53,8 @@ private fun notesMain() { onDirectoryClick = { directory -> viewModel.onEvent(NotesUiEvent.OpenDirectory(directory)) }, + showSignOut = authState.isSignedIn, + onSignOut = { authViewModel.signOut() }, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt index bdf11f4a..b7551387 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt @@ -1,8 +1,12 @@ package com.itlab.notes.ui.auth +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.firebase.auth.FirebaseAuth +import com.itlab.notes.R import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthInvalidUserException import com.google.firebase.auth.FirebaseAuthUserCollisionException @@ -25,6 +29,7 @@ data class AuthUiState( class AuthViewModel( private val firebaseAuth: FirebaseAuth, + private val app: Application, ) : ViewModel() { private val _uiState = MutableStateFlow(AuthUiState(isSignedIn = firebaseAuth.currentUser != null)) val uiState: StateFlow = _uiState.asStateFlow() @@ -129,6 +134,42 @@ class AuthViewModel( } } + fun signOut() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + firebaseAuth.signOut() + signOutGoogle() + }.onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = error.message ?: "Sign out failed.", + ) + } + } + _uiState.update { + it.copy( + continueOffline = false, + isLoading = false, + isSignedIn = firebaseAuth.currentUser != null, + ) + } + } + } + + private suspend fun signOutGoogle() { + val webClientId = app.getString(R.string.default_web_client_id) + if (webClientId.isBlank()) return + val options = + GoogleSignInOptions + .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(webClientId) + .requestEmail() + .build() + GoogleSignIn.getClient(app, options).signOut().await() + } + private fun mapAuthError(error: Throwable): String = when (error) { is FirebaseAuthInvalidUserException -> diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 0468e81b..8f584811 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.FolderCopy import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button @@ -82,8 +83,10 @@ import com.itlab.notes.ui.toSingleLineText import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties +import com.itlab.notes.R private const val DIRECTORY_NAME_TAKEN_ERROR = "A directory with this name already exists" @@ -97,6 +100,8 @@ fun directoriesScreen( onDeleteDirectory: (DirectoryItemUi) -> Unit, onRenameDirectory: (DirectoryItemUi, String) -> Unit, onDirectoryClick: (DirectoryItemUi) -> Unit, + showSignOut: Boolean = false, + onSignOut: () -> Unit = {}, ) { val colors = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current @@ -119,6 +124,8 @@ fun directoriesScreen( containerColor = colors.background, topBar = { directoriesTopBar( + showSignOut = showSignOut, + onSignOut = onSignOut, onAddDirectoryClick = { showCreateDialog = true }, ) }, @@ -294,11 +301,24 @@ internal fun universalBasicAlertDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun directoriesTopBar(onAddDirectoryClick: () -> Unit) { +private fun directoriesTopBar( + showSignOut: Boolean, + onSignOut: () -> Unit, + onAddDirectoryClick: () -> Unit, +) { val colors = MaterialTheme.colorScheme CenterAlignedTopAppBar( title = { Text("Directories", color = colors.onSurface) }, actions = { + if (showSignOut) { + IconButton(onClick = onSignOut) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Logout, + contentDescription = stringResource(R.string.sign_out), + tint = colors.onSurface, + ) + } + } IconButton(onClick = onAddDirectoryClick) { Icon( Icons.Rounded.Add, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1625acd6..3a7ff547 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,4 +13,5 @@ Continue without signing in Show password Hide password + Sign out \ No newline at end of file diff --git a/data/src/main/java/com/itlab/data/cloud/AuthManager.kt b/data/src/main/java/com/itlab/data/cloud/AuthManager.kt index 29a0a96c..0fb4c6a0 100644 --- a/data/src/main/java/com/itlab/data/cloud/AuthManager.kt +++ b/data/src/main/java/com/itlab/data/cloud/AuthManager.kt @@ -1,10 +1,8 @@ package com.itlab.data.cloud -import android.content.Context import android.content.Intent import com.firebase.ui.auth.AuthUI import com.google.firebase.auth.FirebaseAuth -import kotlinx.coroutines.tasks.await class AuthManager( private val auth: FirebaseAuth, @@ -23,7 +21,7 @@ class AuthManager( fun getCurrentUserId(): String? = auth.currentUser?.uid - suspend fun signOut(context: Context) { - AuthUI.getInstance().signOut(context).await() + fun signOut() { + auth.signOut() } } diff --git a/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt index 36f9de35..80465528 100644 --- a/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt @@ -7,4 +7,9 @@ class AuthRepositoryImpl( private val authManager: AuthManager, ) : AuthRepository { override fun getCurrentUserId(): String? = authManager.getCurrentUserId() + + override suspend fun signOut(): Result = + runCatching { + authManager.signOut() + } } diff --git a/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt b/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt index ab5e0a93..c2be5b88 100644 --- a/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt +++ b/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt @@ -82,19 +82,10 @@ class AuthManagerTest { } @Test - fun `signOut should call AuthUI signOut`() = - runBlocking { - val authUI = mockk() - - val mockTask = Tasks.forResult(null) - - every { AuthUI.getInstance() } returns authUI - every { authUI.signOut(any()) } returns mockTask - - authManager.signOut(context) - - verify { authUI.signOut(context) } - } + fun `signOut should call FirebaseAuth signOut`() { + authManager.signOut() + verify { auth.signOut() } + } @Test fun `getSignInIntent should return intent from builder`() { diff --git a/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt b/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt index 4ceb06b2..ecb40174 100644 --- a/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt +++ b/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt @@ -4,9 +4,13 @@ import com.itlab.data.cloud.AuthManager import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -42,4 +46,15 @@ class AuthRepositoryImplTest { assertNull(result) verify(exactly = 1) { authManager.getCurrentUserId() } } + + @Test + fun `signOut should delegate to authManager`() = + runTest { + every { authManager.signOut() } returns Unit + + val result = authRepository.signOut() + + assertTrue(result.isSuccess) + verify(exactly = 1) { authManager.signOut() } + } } diff --git a/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt b/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt index ef5cf772..db299e30 100644 --- a/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt @@ -2,4 +2,6 @@ package com.itlab.domain.repository interface AuthRepository { fun getCurrentUserId(): String? + + suspend fun signOut(): Result } From 5f4ebabb5e0c42cbe17d78a9485d5d7d32186fb1 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 12:23:50 +0300 Subject: [PATCH 27/35] fix: some improvments --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/java/com/itlab/AppModule.kt | 10 + .../notes/auth/ClearLocalDataOnSignOut.kt | 24 ++ .../main/java/com/itlab/notes/ui/NotesApp.kt | 10 +- .../com/itlab/notes/ui/auth/AuthScreen.kt | 315 ++++++++++++++---- .../com/itlab/notes/ui/auth/AuthViewModel.kt | 101 +++++- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 3 +- app/src/main/res/drawable/ic_google.xml | 18 + app/src/main/res/values/strings.xml | 17 - .../main/java/com/itlab/data/dao/NoteDao.kt | 3 + .../java/com/itlab/data/di/DataModuleTest.kt | 3 + 11 files changed, 400 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/auth/ClearLocalDataOnSignOut.kt create mode 100644 app/src/main/res/drawable/ic_google.xml delete mode 100644 app/src/main/res/values/strings.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d81259a4..bd0cab25 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" + android:label="Notes" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Notes"> diff --git a/app/src/main/java/com/itlab/AppModule.kt b/app/src/main/java/com/itlab/AppModule.kt index c30960c0..4c6f402b 100644 --- a/app/src/main/java/com/itlab/AppModule.kt +++ b/app/src/main/java/com/itlab/AppModule.kt @@ -16,6 +16,7 @@ import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase +import com.itlab.notes.auth.ClearLocalDataOnSignOut import com.itlab.notes.ui.NotesUseCases import com.itlab.notes.ui.NotesViewModel import com.itlab.notes.ui.auth.AuthViewModel @@ -44,6 +45,14 @@ val appModule = factory { GetNoteUseCase(get()) } factory { UpdateFolderUseCase(get()) } factory { GetFolderUseCase(get()) } + factory { + ClearLocalDataOnSignOut( + observeNotesUseCase = get(), + deleteNoteUseCase = get(), + observeFoldersUseCase = get(), + deleteFolderUseCase = get(), + ) + } factory { NotesUseCases( createFolderUseCase = get(), @@ -69,6 +78,7 @@ val appModule = AuthViewModel( firebaseAuth = get(), app = androidApplication(), + clearLocalDataOnSignOut = get(), ) } } diff --git a/app/src/main/java/com/itlab/notes/auth/ClearLocalDataOnSignOut.kt b/app/src/main/java/com/itlab/notes/auth/ClearLocalDataOnSignOut.kt new file mode 100644 index 00000000..f5c40a59 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/auth/ClearLocalDataOnSignOut.kt @@ -0,0 +1,24 @@ +package com.itlab.notes.auth + +import com.itlab.domain.usecase.folderusecase.DeleteFolderUseCase +import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase +import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase +import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase +import kotlinx.coroutines.flow.first + +/** Removes all local notes and folders when the user signs out (app-layer only). */ +class ClearLocalDataOnSignOut( + private val observeNotesUseCase: ObserveNotesUseCase, + private val deleteNoteUseCase: DeleteNoteUseCase, + private val observeFoldersUseCase: ObserveFoldersUseCase, + private val deleteFolderUseCase: DeleteFolderUseCase, +) { + suspend operator fun invoke() { + observeNotesUseCase().first().forEach { note -> + deleteNoteUseCase(note.id) + } + observeFoldersUseCase().first().forEach { folder -> + deleteFolderUseCase(folder.id) + } + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index a3ed961d..0b9e1634 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -3,6 +3,7 @@ package com.itlab.notes.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import com.itlab.notes.ui.auth.AuthViewModel import com.itlab.notes.ui.auth.authScreen import com.itlab.notes.ui.editor.editorScreen @@ -16,11 +17,14 @@ import org.koin.androidx.compose.koinViewModel fun notesApp() { val authViewModel: AuthViewModel = koinViewModel() val authState by authViewModel.uiState.collectAsState() - if (!authState.isSignedIn && !authState.continueOffline) { + if (!authState.isSessionActive && !authState.continueOffline) { authScreen(authViewModel) return } - notesMain(authViewModel) + val sessionKey = authViewModel.sessionKey ?: return + key(sessionKey) { + notesMain(authViewModel) + } } @Composable @@ -53,7 +57,7 @@ private fun notesMain(authViewModel: AuthViewModel) { onDirectoryClick = { directory -> viewModel.onEvent(NotesUiEvent.OpenDirectory(directory)) }, - showSignOut = authState.isSignedIn, + showSignOut = authState.isSessionActive, onSignOut = { authViewModel.signOut() }, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt index e4bd6e1a..20501afc 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt @@ -1,8 +1,13 @@ package com.itlab.notes.ui.auth import android.app.Activity +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,14 +15,18 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Email import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -26,6 +35,8 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -35,7 +46,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -56,9 +69,6 @@ fun authScreen( ) { val state by viewModel.uiState.collectAsState() val context = LocalContext.current - var email by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var passwordVisible by rememberSaveable { mutableStateOf(false) } val webClientId = stringResource(R.string.default_web_client_id) val googleSignInEnabled = webClientId.isNotBlank() @@ -76,13 +86,12 @@ fun authScreen( val googleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - // Google Sign-In may return RESULT_CANCELED even after a successful account pick. val data = result.data if (data == null) { - if (result.resultCode == Activity.RESULT_CANCELED) { - viewModel.clearError() - } else { + if (result.resultCode != Activity.RESULT_CANCELED) { viewModel.reportError("Google sign-in returned no data.") + } else { + viewModel.clearError() } return@rememberLauncherForActivityResult } @@ -108,38 +117,204 @@ fun authScreen( } } + val googleUnavailableMessage = + "Google sign-in requires a Web Client ID in Firebase (see default_web_client_id)." + val launchGoogleSignIn = { + if (!googleSignInEnabled) { + viewModel.reportError(googleUnavailableMessage) + } else { + viewModel.clearError() + viewModel.clearSuccess() + googleLauncher.launch(googleSignInClient.signInIntent) + } + } + Scaffold { padding -> - Column( + AnimatedContent( + targetState = state.step, modifier = Modifier .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .padding(padding), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "auth_step", + ) { step -> + when (step) { + AuthScreenStep.ChooseMethod -> + authMethodChoiceContent( + isLoading = state.isLoading, + googleSignInEnabled = googleSignInEnabled, + onGoogleClick = launchGoogleSignIn, + onEmailClick = { viewModel.openEmailStep() }, + onContinueOffline = { viewModel.continueOffline() }, + errorMessage = state.errorMessage, + ) + AuthScreenStep.Email -> + authEmailContent( + state = state, + onBack = { viewModel.backToMethodChoice() }, + onSignIn = viewModel::signInWithEmail, + onSignUp = viewModel::signUpWithEmail, + onToggleSignUpMode = { viewModel.toggleSignUpMode() }, + onClearError = { viewModel.clearError() }, + onClearSuccess = { viewModel.clearSuccess() }, + ) + } + } + } +} + +@Composable +private fun authMethodChoiceContent( + isLoading: Boolean, + googleSignInEnabled: Boolean, + onGoogleClick: () -> Unit, + onEmailClick: () -> Unit, + onContinueOffline: () -> Unit, + errorMessage: String?, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Notes", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Choose how you want to sign in", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(40.dp)) + + authMessageBlock(errorMessage = errorMessage, successMessage = null) + + OutlinedButton( + onClick = onGoogleClick, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading && googleSignInEnabled, ) { - Text( - text = stringResource(R.string.auth_title), - style = MaterialTheme.typography.headlineMedium, + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_google), + contentDescription = null, + modifier = authMethodIconModifier(), + tint = Color.Unspecified, + ) + Text("Continue with Google") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = onEmailClick, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + ) { + Icon( + imageVector = Icons.Rounded.Email, + contentDescription = null, + modifier = authMethodIconModifier(), ) - Spacer(modifier = Modifier.height(8.dp)) + Text("Sign in with Email") + } + + if (!googleSignInEnabled) { + Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.auth_subtitle), - style = MaterialTheme.typography.bodyMedium, + text = "Google sign-in requires a Web Client ID in Firebase (see default_web_client_id).", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(32.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + TextButton( + onClick = onContinueOffline, + enabled = !isLoading, + ) { + Text("Continue without signing in") + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun authEmailContent( + state: AuthUiState, + onBack: () -> Unit, + onSignIn: (String, String) -> Unit, + onSignUp: (String, String) -> Unit, + onToggleSignUpMode: () -> Unit, + onClearError: () -> Unit, + onClearSuccess: () -> Unit, +) { + BackHandler(onBack = onBack) + + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + Text( + text = + if (state.isSignUpMode) { + "Create account" + } else { + "Sign in" + }, + ) + }, + navigationIcon = { + IconButton(onClick = onBack, enabled = !state.isLoading) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { OutlinedTextField( value = email, onValueChange = { email = it - viewModel.clearError() + onClearError() + onClearSuccess() }, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.auth_email_label)) }, + label = { Text("Email") }, singleLine = true, keyboardOptions = KeyboardOptions( @@ -153,10 +328,11 @@ fun authScreen( value = password, onValueChange = { password = it - viewModel.clearError() + onClearError() + onClearSuccess() }, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.auth_password_label)) }, + label = { Text("Password") }, singleLine = true, visualTransformation = if (passwordVisible) { @@ -175,9 +351,9 @@ fun authScreen( }, contentDescription = if (passwordVisible) { - stringResource(R.string.auth_hide_password) + "Hide password" } else { - stringResource(R.string.auth_show_password) + "Show password" }, ) } @@ -190,25 +366,20 @@ fun authScreen( enabled = !state.isLoading, ) - state.errorMessage?.let { message -> - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } + Spacer(modifier = Modifier.height(12.dp)) + authMessageBlock( + errorMessage = state.errorMessage, + successMessage = state.successMessage, + ) Spacer(modifier = Modifier.height(24.dp)) Button( onClick = { if (state.isSignUpMode) { - viewModel.signUpWithEmail(email, password) + onSignUp(email, password) } else { - viewModel.signInWithEmail(email, password) + onSignIn(email, password) } }, modifier = Modifier.fillMaxWidth(), @@ -223,60 +394,58 @@ fun authScreen( Text( text = if (state.isSignUpMode) { - stringResource(R.string.auth_create_account) + "Create account" } else { - stringResource(R.string.auth_sign_in) + "Sign in" }, ) } } TextButton( - onClick = { viewModel.toggleSignUpMode() }, + onClick = onToggleSignUpMode, enabled = !state.isLoading, ) { Text( text = if (state.isSignUpMode) { - stringResource(R.string.auth_already_have_account) + "Already have an account? Sign in" } else { - stringResource(R.string.auth_need_account) + "Need an account? Create one" }, ) } + } + } +} - Spacer(modifier = Modifier.height(8.dp)) +private fun authMethodIconModifier(): Modifier = + Modifier + .size(30.dp) + .padding(end = 12.dp) - OutlinedButton( - onClick = { - viewModel.clearError() - googleLauncher.launch(googleSignInClient.signInIntent) - }, +@Composable +private fun authMessageBlock( + errorMessage: String?, + successMessage: String?, +) { + when { + !errorMessage.isNullOrBlank() -> + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading && googleSignInEnabled, - ) { - Text(stringResource(R.string.auth_google)) - } - - if (!googleSignInEnabled) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.auth_google_unavailable), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - TextButton( - onClick = { viewModel.continueOffline() }, - enabled = !state.isLoading, - ) { - Text(stringResource(R.string.auth_continue_offline)) - } - } + ) + !successMessage.isNullOrBlank() -> + Text( + text = successMessage, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } } diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt index b7551387..4dc13962 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt @@ -6,12 +6,13 @@ import androidx.lifecycle.viewModelScope import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.firebase.auth.FirebaseAuth -import com.itlab.notes.R import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthInvalidUserException import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseAuthWeakPasswordException import com.google.firebase.auth.GoogleAuthProvider +import com.itlab.notes.R +import com.itlab.notes.auth.ClearLocalDataOnSignOut import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,26 +20,51 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +enum class AuthScreenStep { + ChooseMethod, + Email, +} + data class AuthUiState( - val isSignedIn: Boolean = false, + val step: AuthScreenStep = AuthScreenStep.ChooseMethod, + /** User may enter the notes app (explicit sign-in or restored Firebase session). */ + val isSessionActive: Boolean = false, val continueOffline: Boolean = false, val isLoading: Boolean = false, val isSignUpMode: Boolean = false, val errorMessage: String? = null, + val successMessage: String? = null, ) class AuthViewModel( private val firebaseAuth: FirebaseAuth, private val app: Application, + private val clearLocalDataOnSignOut: ClearLocalDataOnSignOut, ) : ViewModel() { - private val _uiState = MutableStateFlow(AuthUiState(isSignedIn = firebaseAuth.currentUser != null)) + private var shouldActivateSession = firebaseAuth.currentUser != null + + private val _uiState = + MutableStateFlow( + AuthUiState( + isSessionActive = firebaseAuth.currentUser != null && shouldActivateSession, + ), + ) val uiState: StateFlow = _uiState.asStateFlow() + val sessionKey: String? + get() = + when { + _uiState.value.continueOffline -> OFFLINE_SESSION_KEY + _uiState.value.isSessionActive -> firebaseAuth.currentUser?.uid + else -> null + } + private val authStateListener = FirebaseAuth.AuthStateListener { auth -> + val signedIn = auth.currentUser != null _uiState.update { it.copy( - isSignedIn = auth.currentUser != null, + isSessionActive = signedIn && shouldActivateSession, isLoading = false, ) } @@ -53,8 +79,35 @@ class AuthViewModel( super.onCleared() } + fun openEmailStep() { + _uiState.update { + it.copy( + step = AuthScreenStep.Email, + errorMessage = null, + successMessage = null, + ) + } + } + + fun backToMethodChoice() { + _uiState.update { + it.copy( + step = AuthScreenStep.ChooseMethod, + isSignUpMode = false, + errorMessage = null, + successMessage = null, + ) + } + } + fun toggleSignUpMode() { - _uiState.update { it.copy(isSignUpMode = !it.isSignUpMode, errorMessage = null) } + _uiState.update { + it.copy( + isSignUpMode = !it.isSignUpMode, + errorMessage = null, + successMessage = null, + ) + } } fun continueOffline() { @@ -65,6 +118,10 @@ class AuthViewModel( _uiState.update { it.copy(errorMessage = null) } } + fun clearSuccess() { + _uiState.update { it.copy(successMessage = null) } + } + fun reportError(message: String) { _uiState.update { it.copy(isLoading = false, errorMessage = message) } } @@ -79,10 +136,12 @@ class AuthViewModel( return } viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) } + shouldActivateSession = true runCatching { firebaseAuth.signInWithEmailAndPassword(trimmedEmail, password).await() }.onFailure { error -> + shouldActivateSession = false _uiState.update { it.copy( isLoading = false, @@ -103,9 +162,22 @@ class AuthViewModel( return } viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) } + shouldActivateSession = false runCatching { firebaseAuth.createUserWithEmailAndPassword(trimmedEmail, password).await() + firebaseAuth.signOut() + }.onSuccess { + _uiState.update { + it.copy( + step = AuthScreenStep.Email, + isSignUpMode = false, + isLoading = false, + isSessionActive = false, + successMessage = "Account created. Sign in with your email and password.", + errorMessage = null, + ) + } }.onFailure { error -> _uiState.update { it.copy( @@ -119,11 +191,13 @@ class AuthViewModel( fun signInWithGoogle(idToken: String) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) } + shouldActivateSession = true runCatching { val credential = GoogleAuthProvider.getCredential(idToken, null) firebaseAuth.signInWithCredential(credential).await() }.onFailure { error -> + shouldActivateSession = false _uiState.update { it.copy( isLoading = false, @@ -136,8 +210,10 @@ class AuthViewModel( fun signOut() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) } + shouldActivateSession = false runCatching { + clearLocalDataOnSignOut() firebaseAuth.signOut() signOutGoogle() }.onFailure { error -> @@ -150,9 +226,10 @@ class AuthViewModel( } _uiState.update { it.copy( + step = AuthScreenStep.ChooseMethod, continueOffline = false, isLoading = false, - isSignedIn = firebaseAuth.currentUser != null, + isSessionActive = false, ) } } @@ -182,4 +259,8 @@ class AuthViewModel( "An account with this email already exists. Sign in instead." else -> error.message ?: "Authentication failed. Please try again." } + + companion object { + const val OFFLINE_SESSION_KEY = "offline" + } } diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 8f584811..973b8c30 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -83,7 +83,6 @@ import com.itlab.notes.ui.toSingleLineText import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import com.itlab.notes.R @@ -314,7 +313,7 @@ private fun directoriesTopBar( IconButton(onClick = onSignOut) { Icon( imageVector = Icons.AutoMirrored.Rounded.Logout, - contentDescription = stringResource(R.string.sign_out), + contentDescription = "Sign out", tint = colors.onSurface, ) } diff --git a/app/src/main/res/drawable/ic_google.xml b/app/src/main/res/drawable/ic_google.xml new file mode 100644 index 00000000..2db8d7e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_google.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index 3a7ff547..00000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,17 +0,0 @@ - - Notes - Notes - Sign in to sync your notes across devices - Email - Password - Sign in - Create account - Need an account? Create one - Already have an account? Sign in - Continue with Google - Google sign-in requires a Web Client ID in Firebase (see default_web_client_id). - Continue without signing in - Show password - Hide password - Sign out - \ No newline at end of file diff --git a/data/src/main/java/com/itlab/data/dao/NoteDao.kt b/data/src/main/java/com/itlab/data/dao/NoteDao.kt index 3da3c37c..7ca6517f 100644 --- a/data/src/main/java/com/itlab/data/dao/NoteDao.kt +++ b/data/src/main/java/com/itlab/data/dao/NoteDao.kt @@ -29,6 +29,9 @@ interface NoteDao { @Query("DELETE FROM notes WHERE id = :id") suspend fun hardDeleteById(id: String) + @Query("DELETE FROM notes") + suspend fun deleteAll() + @Insert suspend fun insert(note: NoteEntity) diff --git a/data/src/test/java/com/itlab/data/di/DataModuleTest.kt b/data/src/test/java/com/itlab/data/di/DataModuleTest.kt index e24e3fc8..bd29aa45 100644 --- a/data/src/test/java/com/itlab/data/di/DataModuleTest.kt +++ b/data/src/test/java/com/itlab/data/di/DataModuleTest.kt @@ -2,6 +2,7 @@ package com.itlab.data.di import android.content.Context import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.storage.FirebaseStorage import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -28,6 +29,8 @@ class DataModuleTest : KoinTest { fun `verify dataModule dependencies`() { mockkStatic(FirebaseAuth::class) every { FirebaseAuth.getInstance() } returns mockk(relaxed = true) + mockkStatic(FirebaseStorage::class) + every { FirebaseStorage.getInstance() } returns mockk(relaxed = true) koinApplication { androidContext(mockk(relaxed = true)) From 5e1029bb850a00c0d54f102707d6fd4f193efe4d Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 15:03:28 +0300 Subject: [PATCH 28/35] style: some improvements --- .gitignore | Bin 232 -> 292 bytes app/build.gradle.kts | 11 + app/detekt.yml | 14 ++ app/gradle.lockfile | 20 +- app/src/main/java/com/itlab/AppModule.kt | 10 +- .../java/com/itlab/notes/NotesApplication.kt | 2 +- .../itlab/notes/auth/AppSessionPreferences.kt | 33 +++ .../com/itlab/notes/media/NoteMediaImport.kt | 15 +- .../notes/onboarding/CoachMarkOverlay.kt | 225 ++++++++++++++++++ .../notes/onboarding/OnboardingModifiers.kt | 25 ++ .../notes/onboarding/OnboardingPreferences.kt | 45 ++++ .../notes/onboarding/OnboardingTourSteps.kt | 108 +++++++++ .../notes/onboarding/OnboardingViewModel.kt | 182 ++++++++++++++ .../onboarding/WelcomeOnboardingScreen.kt | 213 +++++++++++++++++ .../main/java/com/itlab/notes/ui/NotesApp.kt | 78 +++++- .../java/com/itlab/notes/ui/NotesUseCases.kt | 2 +- .../java/com/itlab/notes/ui/NotesViewModel.kt | 16 +- .../com/itlab/notes/ui/auth/AuthScreen.kt | 110 +++++---- .../com/itlab/notes/ui/auth/AuthViewModel.kt | 73 +++++- .../com/itlab/notes/ui/editor/EditorScreen.kt | 26 +- .../com/itlab/notes/ui/notes/AppEmptyState.kt | 4 +- .../itlab/notes/ui/notes/DirectoriesScreen.kt | 64 ++++- .../com/itlab/notes/ui/notes/NotesScreen.kt | 21 +- .../main/java/com/itlab/data/dao/NoteDao.kt | 1 + .../noteusecase/ResolveUniqueNoteTitle.kt | 1 + gradle/libs.versions.toml | 2 + 26 files changed, 1197 insertions(+), 104 deletions(-) create mode 100644 app/detekt.yml create mode 100644 app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt create mode 100644 app/src/main/java/com/itlab/notes/onboarding/CoachMarkOverlay.kt create mode 100644 app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt create mode 100644 app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt create mode 100644 app/src/main/java/com/itlab/notes/onboarding/OnboardingTourSteps.kt create mode 100644 app/src/main/java/com/itlab/notes/onboarding/OnboardingViewModel.kt create mode 100644 app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt diff --git a/.gitignore b/.gitignore index 5c915a534520097fa3c5a225be11833c5e6a6c0f..6c2ee243484af8128b6aa2a2cafe0f7616d6304d 100644 GIT binary patch literal 292 zcmZWkK?=e^49t09KOz|aAbR!EgGY(Gp+;l3WVf~Wdbij^MFNv#n1q=P%2U0=)+j1_ zx6>3zvA5K-72#Ydl*qIT6mF|k4}Pry#ER?Op|y2VZK?sYa!KY YL4jclxuU@c0-;VB5bhNdbl}0k2ipN!e*gdg literal 232 zcmZXPu@1s83`F;Q3WNI(2v(L3jL1!_V&Nu{h=W-%2)RL$ zM}e# { + config.setFrom( + files( + rootProject.file("detekt.yml"), + layout.projectDirectory.file("detekt.yml"), + ), + ) +} + dependencies { implementation(project(":domain")) implementation(project(":data")) @@ -86,6 +96,7 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.koin.android) implementation(libs.koin.androidx.compose) + implementation(libs.androidx.datastore.preferences) implementation(libs.coil.compose) testImplementation(libs.junit) diff --git a/app/detekt.yml b/app/detekt.yml new file mode 100644 index 00000000..2e5aa217 --- /dev/null +++ b/app/detekt.yml @@ -0,0 +1,14 @@ +# UI (app) module: Jetpack Compose entry points often exceed default complexity limits. +complexity: + LongParameterList: + active: false + LongMethod: + active: false + TooManyFunctions: + active: false + CyclomaticComplexMethod: + active: false + +style: + ReturnCount: + active: false diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 6691f85c..c7357aaa 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -128,22 +128,22 @@ androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debu androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.customview:customview:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.datastore:datastore-android:1.1.7=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-core-android:1.1.7=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath androidx.datastore:datastore-core-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.datastore:datastore-core-okio-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-core-okio:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-core:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core-okio-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core-okio:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.datastore:datastore-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.datastore:datastore-preferences-android:1.1.7=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-preferences-core-android:1.1.7=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences-core-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath androidx.datastore:datastore-preferences-core-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.datastore:datastore-preferences-core:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences-core:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.datastore:datastore-preferences-external-protobuf:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.datastore:datastore-preferences-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.datastore:datastore-preferences-proto:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-preferences:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.documentfile:documentfile:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.drawerlayout:drawerlayout:1.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath diff --git a/app/src/main/java/com/itlab/AppModule.kt b/app/src/main/java/com/itlab/AppModule.kt index 4c6f402b..e38bab55 100644 --- a/app/src/main/java/com/itlab/AppModule.kt +++ b/app/src/main/java/com/itlab/AppModule.kt @@ -9,14 +9,18 @@ import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase import com.itlab.domain.usecase.noteusecase.GetNoteUseCase +import com.itlab.domain.usecase.noteusecase.GetUserIdUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase -import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase +import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase +import com.itlab.notes.auth.AppSessionPreferences import com.itlab.notes.auth.ClearLocalDataOnSignOut +import com.itlab.notes.onboarding.OnboardingPreferences +import com.itlab.notes.onboarding.OnboardingViewModel import com.itlab.notes.ui.NotesUseCases import com.itlab.notes.ui.NotesViewModel import com.itlab.notes.ui.auth.AuthViewModel @@ -27,6 +31,8 @@ import org.koin.dsl.module val appModule = module { + single { OnboardingPreferences(androidApplication()) } + single { AppSessionPreferences(androidApplication()) } factory { ValidateDuplicateNoteTitleUseCase(get()) } factory { CreateNoteUseCase(get(), get()) } factory { CreateFolderUseCase(get()) } @@ -74,10 +80,12 @@ val appModule = } viewModelOf(::NotesViewModel) + viewModelOf(::OnboardingViewModel) viewModel { AuthViewModel( firebaseAuth = get(), app = androidApplication(), + appSessionPreferences = get(), clearLocalDataOnSignOut = get(), ) } diff --git a/app/src/main/java/com/itlab/notes/NotesApplication.kt b/app/src/main/java/com/itlab/notes/NotesApplication.kt index 90a23692..38c71e09 100644 --- a/app/src/main/java/com/itlab/notes/NotesApplication.kt +++ b/app/src/main/java/com/itlab/notes/NotesApplication.kt @@ -1,8 +1,8 @@ package com.itlab.notes import android.app.Application -import com.itlab.data.di.dataModule import com.itlab.appModule +import com.itlab.data.di.dataModule import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin diff --git a/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt b/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt new file mode 100644 index 00000000..ceb12871 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt @@ -0,0 +1,33 @@ +package com.itlab.notes.auth + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.appSessionDataStore: DataStore by preferencesDataStore( + name = "app_session_preferences", +) + +class AppSessionPreferences( + private val context: Context, +) { + val continueOffline: Flow = + context.appSessionDataStore.data.map { prefs -> + prefs[CONTINUE_OFFLINE] ?: false + } + + suspend fun setContinueOffline(enabled: Boolean) { + context.appSessionDataStore.edit { prefs -> + prefs[CONTINUE_OFFLINE] = enabled + } + } + + private companion object { + val CONTINUE_OFFLINE = booleanPreferencesKey("continue_offline") + } +} diff --git a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt index 610fe604..a51a7b07 100644 --- a/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt +++ b/app/src/main/java/com/itlab/notes/media/NoteMediaImport.kt @@ -11,12 +11,18 @@ import java.util.UUID object NoteMediaImport { private const val SUBDIR = "note_attachments" - fun importImagesFromUris(context: Context, uris: List): List = + fun importImagesFromUris( + context: Context, + uris: List, + ): List = uris.mapNotNull { uri -> runCatching { importImageFromUri(context, uri) }.getOrNull() } - fun importImageFromUri(context: Context, uri: Uri): ContentItem.Image { + fun importImageFromUri( + context: Context, + uri: Uri, + ): ContentItem.Image { val appContext = context.applicationContext val resolver = appContext.contentResolver val mime = resolver.getType(uri) ?: "image/jpeg" @@ -33,7 +39,10 @@ object NoteMediaImport { ) } - fun deleteImportedFileIfOwned(context: Context, localPath: String?) { + fun deleteImportedFileIfOwned( + context: Context, + localPath: String?, + ) { val path = localPath ?: return val file = File(path) if (!file.exists()) return diff --git a/app/src/main/java/com/itlab/notes/onboarding/CoachMarkOverlay.kt b/app/src/main/java/com/itlab/notes/onboarding/CoachMarkOverlay.kt new file mode 100644 index 00000000..c30b24db --- /dev/null +++ b/app/src/main/java/com/itlab/notes/onboarding/CoachMarkOverlay.kt @@ -0,0 +1,225 @@ +package com.itlab.notes.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +@Composable +fun coachMarkOverlay( + step: OnboardingTourStep, + stepIndex: Int, + stepCount: Int, + targetBounds: Rect?, + screenMatchesStep: Boolean, + onSkip: () -> Unit, + onBack: () -> Unit, + onNext: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val scrim = Color.Black.copy(alpha = 0.62f) + val highlightPadding = 10.dp + val density = LocalDensity.current + val paddedHole = + remember(targetBounds, density) { + targetBounds?.let { bounds -> + val pad = with(density) { highlightPadding.toPx() } + Rect( + left = bounds.left - pad, + top = bounds.top - pad, + right = bounds.right + pad, + bottom = bounds.bottom + pad, + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (screenMatchesStep && paddedHole != null) { + scrimWithHole( + hole = paddedHole, + scrimColor = scrim, + ) + } else { + Box( + modifier = + Modifier + .fillMaxSize() + .background(scrim) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) + } + + Surface( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 20.dp, vertical = 28.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = colors.surfaceContainerHigh, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = "Step ${stepIndex + 1} of $stepCount", + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + Text( + text = step.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = step.description, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + if (!screenMatchesStep && step.requiredScreen != null) { + Text( + text = "Follow the hint on screen to continue.", + style = MaterialTheme.typography.bodySmall, + color = colors.primary, + modifier = Modifier.padding(top = 8.dp), + ) + } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onSkip) { + Text("Skip tour") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (stepIndex > 0) { + TextButton(onClick = onBack) { + Text("Back") + } + } + Button(onClick = onNext) { + Text(if (stepIndex >= stepCount - 1) "Done" else "Next") + } + } + } + } + } + } +} + +@Composable +private fun scrimWithHole( + hole: Rect, + scrimColor: Color, +) { + val interaction = remember { MutableInteractionSource() } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val widthPx = constraints.maxWidth.toFloat() + val heightPx = constraints.maxHeight.toFloat() + val holeLeft = hole.left.coerceIn(0f, widthPx) + val holeTop = hole.top.coerceIn(0f, heightPx) + val holeRight = hole.right.coerceIn(holeLeft, widthPx) + val holeBottom = hole.bottom.coerceIn(holeTop, heightPx) + val density = LocalDensity.current + + scrimPanel( + x = 0f, + y = 0f, + width = widthPx, + height = holeTop, + scrimColor = scrimColor, + interaction = interaction, + density = density, + ) + scrimPanel( + x = 0f, + y = holeBottom, + width = widthPx, + height = heightPx - holeBottom, + scrimColor = scrimColor, + interaction = interaction, + density = density, + ) + scrimPanel( + x = 0f, + y = holeTop, + width = holeLeft, + height = holeBottom - holeTop, + scrimColor = scrimColor, + interaction = interaction, + density = density, + ) + scrimPanel( + x = holeRight, + y = holeTop, + width = widthPx - holeRight, + height = holeBottom - holeTop, + scrimColor = scrimColor, + interaction = interaction, + density = density, + ) + } +} + +@Composable +private fun scrimPanel( + x: Float, + y: Float, + width: Float, + height: Float, + scrimColor: Color, + interaction: MutableInteractionSource, + density: androidx.compose.ui.unit.Density, +) { + if (width <= 0f || height <= 0f) return + Box( + modifier = + Modifier + .offset { IntOffset(x.roundToInt(), y.roundToInt()) } + .width(with(density) { width.toDp() }) + .height(with(density) { height.toDp() }) + .background(scrimColor) + .clickable( + interactionSource = interaction, + indication = null, + onClick = {}, + ), + ) +} diff --git a/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt b/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt new file mode 100644 index 00000000..08f93dae --- /dev/null +++ b/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt @@ -0,0 +1,25 @@ +package com.itlab.notes.onboarding + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned + +val LocalOnboardingRegistrar = + staticCompositionLocalOf<((String, Rect?) -> Unit)?> { + null + } + +@Composable +fun onboardingTargetModifier(key: String): Modifier { + val registrar = LocalOnboardingRegistrar.current ?: return Modifier + return Modifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + registrar( + key, + if (bounds.width > 0f && bounds.height > 0f) bounds else null, + ) + } +} diff --git a/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt b/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt new file mode 100644 index 00000000..7bb34307 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt @@ -0,0 +1,45 @@ +package com.itlab.notes.onboarding + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.onboardingDataStore: DataStore by preferencesDataStore( + name = "onboarding_preferences", +) + +class OnboardingPreferences( + private val context: Context, +) { + val welcomeCompleted: Flow = + context.onboardingDataStore.data.map { prefs -> + prefs[WELCOME_COMPLETED] ?: false + } + + val tourCompleted: Flow = + context.onboardingDataStore.data.map { prefs -> + prefs[TOUR_COMPLETED] ?: false + } + + suspend fun setWelcomeCompleted() { + context.onboardingDataStore.edit { prefs -> + prefs[WELCOME_COMPLETED] = true + } + } + + suspend fun setTourCompleted() { + context.onboardingDataStore.edit { prefs -> + prefs[TOUR_COMPLETED] = true + } + } + + private companion object { + val WELCOME_COMPLETED = booleanPreferencesKey("welcome_completed") + val TOUR_COMPLETED = booleanPreferencesKey("tour_completed") + } +} diff --git a/app/src/main/java/com/itlab/notes/onboarding/OnboardingTourSteps.kt b/app/src/main/java/com/itlab/notes/onboarding/OnboardingTourSteps.kt new file mode 100644 index 00000000..bc880649 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/onboarding/OnboardingTourSteps.kt @@ -0,0 +1,108 @@ +package com.itlab.notes.onboarding + +import com.itlab.notes.ui.NotesUiScreen + +enum class OnboardingScreenKind { + Directories, + DirectoryNotes, +} + +data class OnboardingTourStep( + val targetKey: String?, + val title: String, + val description: String, + val requiredScreen: OnboardingScreenKind? = null, + val requiresSignIn: Boolean = false, +) + +fun NotesUiScreen.onboardingScreenKind(): OnboardingScreenKind? = + when (this) { + NotesUiScreen.Directories -> OnboardingScreenKind.Directories + is NotesUiScreen.DirectoryNotes -> OnboardingScreenKind.DirectoryNotes + is NotesUiScreen.NoteEditor -> null + } + +object OnboardingTargets { + const val DIRECTORIES_SEARCH = "directories_search" + const val DIRECTORIES_ADD = "directories_add" + const val DIRECTORIES_FOLDER_ROW = "directories_folder_row" + const val DIRECTORIES_SIGN_OUT = "directories_sign_out" + const val NOTES_FAB = "notes_fab" + const val NOTES_SEARCH = "notes_search" + const val NOTES_NOTE_ROW = "notes_note_row" +} + +fun buildOnboardingTourSteps(showSignOut: Boolean): List = + buildList { + add( + OnboardingTourStep( + targetKey = OnboardingTargets.DIRECTORIES_SEARCH, + title = "Search", + description = "Find directories quickly. Inside a folder you can search notes too.", + requiredScreen = OnboardingScreenKind.Directories, + ), + ) + add( + OnboardingTourStep( + targetKey = OnboardingTargets.DIRECTORIES_ADD, + title = "New directory", + description = "Tap + to create a folder for your notes.", + requiredScreen = OnboardingScreenKind.Directories, + ), + ) + add( + OnboardingTourStep( + targetKey = OnboardingTargets.DIRECTORIES_FOLDER_ROW, + title = "Folders", + description = "Tap to open. Long-press a custom folder to rename or delete it in a dialog.", + requiredScreen = OnboardingScreenKind.Directories, + ), + ) + if (showSignOut) { + add( + OnboardingTourStep( + targetKey = OnboardingTargets.DIRECTORIES_SIGN_OUT, + title = "Sign out", + description = + "Use this when switching accounts. Local notes from the " + + "session are cleared on sign out.", + requiredScreen = OnboardingScreenKind.Directories, + requiresSignIn = true, + ), + ) + } + add( + OnboardingTourStep( + targetKey = null, + title = "Open a folder", + description = "Tap any directory to continue the tour and see note actions.", + requiredScreen = OnboardingScreenKind.DirectoryNotes, + ), + ) + add( + OnboardingTourStep( + targetKey = OnboardingTargets.NOTES_FAB, + title = "New note", + description = "Tap + to create a note in this folder.", + requiredScreen = OnboardingScreenKind.DirectoryNotes, + ), + ) + add( + OnboardingTourStep( + targetKey = OnboardingTargets.NOTES_SEARCH, + title = "Search notes", + description = "Filter notes by title or content in the current folder.", + requiredScreen = OnboardingScreenKind.DirectoryNotes, + ), + ) + add( + OnboardingTourStep( + targetKey = OnboardingTargets.NOTES_NOTE_ROW, + title = "Notes list", + description = + "Tap a note to edit. Long-press to select several notes, then move or delete" + + " them from the top bar.", + requiredScreen = OnboardingScreenKind.DirectoryNotes, + ), + ) + } diff --git a/app/src/main/java/com/itlab/notes/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/itlab/notes/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..47ee7c6f --- /dev/null +++ b/app/src/main/java/com/itlab/notes/onboarding/OnboardingViewModel.kt @@ -0,0 +1,182 @@ +package com.itlab.notes.onboarding + +import androidx.compose.ui.geometry.Rect +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.itlab.notes.ui.NotesUiScreen +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private data class TourCoreState( + val isReady: Boolean, + val welcomeDone: Boolean, + val tourDone: Boolean, + val tourOn: Boolean, + val step: Int, +) + +data class OnboardingUiState( + val isReady: Boolean = false, + val showWelcome: Boolean = false, + val showTour: Boolean = false, + val tourStepIndex: Int = 0, + val showSignOutStep: Boolean = false, + val currentScreenKind: OnboardingScreenKind? = OnboardingScreenKind.Directories, +) + +class OnboardingViewModel( + private val preferences: OnboardingPreferences, +) : ViewModel() { + private val welcomeCompleted = MutableStateFlow(false) + private val tourCompleted = MutableStateFlow(false) + private val tourActive = MutableStateFlow(false) + private val tourStepIndex = MutableStateFlow(0) + private val showSignOutStep = MutableStateFlow(false) + private val currentScreenKind = MutableStateFlow(OnboardingScreenKind.Directories) + private val targetBounds = MutableStateFlow>(emptyMap()) + val targetBoundsState: StateFlow> = targetBounds.asStateFlow() + private val preferencesLoaded = MutableStateFlow(false) + private val pendingTourStart = MutableStateFlow(false) + + val uiState: StateFlow = + combine( + combine( + preferencesLoaded, + welcomeCompleted, + tourCompleted, + tourActive, + tourStepIndex, + ) { loaded, welcomeDone, tourDone, tourOn, step -> + TourCoreState( + isReady = loaded, + welcomeDone = welcomeDone, + tourDone = tourDone, + tourOn = tourOn, + step = step, + ) + }, + showSignOutStep, + currentScreenKind, + ) { core, signOut, screen -> + OnboardingUiState( + isReady = core.isReady, + showWelcome = core.isReady && !core.welcomeDone, + showTour = core.isReady && core.welcomeDone && !core.tourDone && core.tourOn, + tourStepIndex = core.step, + showSignOutStep = signOut, + currentScreenKind = screen, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = OnboardingUiState(isReady = false), + ) + + val tourSteps: StateFlow> = + showSignOutStep + .combine(tourStepIndex) { signOut, _ -> buildOnboardingTourSteps(signOut) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = buildOnboardingTourSteps(false), + ) + + init { + viewModelScope.launch { + preferences.welcomeCompleted.collect { welcomeCompleted.value = it } + } + viewModelScope.launch { + preferences.tourCompleted.collect { tourCompleted.value = it } + } + viewModelScope.launch { + preferences.welcomeCompleted.first() + preferences.tourCompleted.first() + preferencesLoaded.value = true + } + } + + fun startTourIfNeeded() { + if (!welcomeCompleted.value || tourCompleted.value) return + if (tourActive.value) { + pendingTourStart.value = false + return + } + pendingTourStart.value = true + } + + /** Call when the main notes UI is visible (after auth / offline). */ + fun activateTourIfPending() { + if (!pendingTourStart.value || tourCompleted.value || !welcomeCompleted.value) return + pendingTourStart.value = false + tourActive.value = true + tourStepIndex.value = 0 + } + + fun updateShowSignOutStep(show: Boolean) { + showSignOutStep.value = show + } + + fun updateCurrentScreen(screen: NotesUiScreen) { + currentScreenKind.value = screen.onboardingScreenKind() + } + + fun registerTarget( + key: String, + bounds: Rect?, + ) { + targetBounds.update { current -> + if (bounds == null) { + current - key + } else { + current + (key to bounds) + } + } + } + + fun completeWelcome() { + viewModelScope.launch { + preferences.setWelcomeCompleted() + welcomeCompleted.value = true + pendingTourStart.value = true + } + } + + fun skipWelcome() { + completeWelcome() + } + + fun skipTour() { + finishTour() + } + + fun nextTourStep() { + val steps = buildOnboardingTourSteps(showSignOutStep.value) + val clampedIndex = tourStepIndex.value.coerceIn(0, steps.lastIndex) + if (clampedIndex >= steps.lastIndex) { + finishTour() + } else { + tourStepIndex.value = clampedIndex + 1 + } + } + + fun previousTourStep() { + if (tourStepIndex.value > 0) { + tourStepIndex.value -= 1 + } + } + + private fun finishTour() { + viewModelScope.launch { + preferences.setTourCompleted() + tourCompleted.value = true + tourActive.value = false + } + } +} diff --git a/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt b/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt new file mode 100644 index 00000000..620a9551 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt @@ -0,0 +1,213 @@ +package com.itlab.notes.onboarding + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cloud +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Note +import androidx.compose.material.icons.rounded.WavingHand +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +private data class WelcomeSlide( + val icon: ImageVector, + val title: String, + val body: String, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun welcomeOnboardingScreen( + onFinished: () -> Unit, + onSkip: () -> Unit, +) { + val slides = + listOf( + WelcomeSlide( + icon = Icons.Rounded.WavingHand, + title = "Welcome to Notes", + body = "A simple workspace for folders, notes, and attachments on your device.", + ), + WelcomeSlide( + icon = Icons.Rounded.Folder, + title = "Organize with directories", + body = "Group notes into folders. Use All Notes and Favorites for quick access.", + ), + WelcomeSlide( + icon = Icons.Rounded.Note, + title = "Write and attach", + body = "Open a note to edit text, add images, and mark favorites.", + ), + WelcomeSlide( + icon = Icons.Rounded.Cloud, + title = "Ready to explore", + body = "Next we will walk you through the main controls in the app.", + ), + ) + val pagerState = rememberPagerState(pageCount = { slides.size }) + val scope = rememberCoroutineScope() + val colors = MaterialTheme.colorScheme + val isLast = pagerState.currentPage == slides.lastIndex + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = colors.background, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onSkip) { + Text("Skip") + } + } + HorizontalPager( + state = pagerState, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) { page -> + welcomeSlideContent(slide = slides[page]) + } + welcomePagerIndicator( + pagerState = pagerState, + pageCount = slides.size, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 20.dp), + ) + Button( + onClick = { + if (isLast) { + onFinished() + } else { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + ) { + Text(if (isLast) "Get started" else "Next") + } + Spacer(Modifier.height(8.dp)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun welcomePagerIndicator( + pagerState: PagerState, + pageCount: Int, + modifier: Modifier = Modifier, + activeWidth: Dp = 28.dp, + dotSize: Dp = 8.dp, + dotSpacing: Dp = 10.dp, +) { + val colors = MaterialTheme.colorScheme + val activeColor = colors.primary + val inactiveColor = colors.onSurfaceVariant.copy(alpha = 0.35f) + val scrollPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(dotSpacing, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pageCount) { index -> + val distance = (scrollPosition - index).absoluteValue.coerceIn(0f, 1f) + val width = lerp(activeWidth, dotSize, distance) + val color = lerp(activeColor, inactiveColor, distance) + Box( + modifier = + Modifier + .height(dotSize) + .width(width) + .clip(CircleShape) + .background(color), + ) + } + } +} + +@Composable +private fun welcomeSlideContent(slide: WelcomeSlide) { + val colors = MaterialTheme.colorScheme + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = slide.icon, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(72.dp), + ) + Spacer(Modifier.height(28.dp)) + Text( + text = slide.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = slide.body, + style = MaterialTheme.typography.bodyLarge, + color = colors.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 0b9e1634..81bfbf10 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -1,9 +1,18 @@ package com.itlab.notes.ui +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import com.itlab.notes.onboarding.LocalOnboardingRegistrar +import com.itlab.notes.onboarding.OnboardingViewModel +import com.itlab.notes.onboarding.coachMarkOverlay +import com.itlab.notes.onboarding.welcomeOnboardingScreen import com.itlab.notes.ui.auth.AuthViewModel import com.itlab.notes.ui.auth.authScreen import com.itlab.notes.ui.editor.editorScreen @@ -15,24 +24,87 @@ import org.koin.androidx.compose.koinViewModel @Composable fun notesApp() { + val onboardingViewModel: OnboardingViewModel = koinViewModel() + val onboardingState by onboardingViewModel.uiState.collectAsState() + val tourSteps by onboardingViewModel.tourSteps.collectAsState() + val tourTargetBounds by onboardingViewModel.targetBoundsState.collectAsState() + + if (!onboardingState.isReady) { + return + } + val authViewModel: AuthViewModel = koinViewModel() val authState by authViewModel.uiState.collectAsState() + if (!authState.isSessionReady) { + return + } if (!authState.isSessionActive && !authState.continueOffline) { authScreen(authViewModel) return } + + if (onboardingState.showWelcome) { + welcomeOnboardingScreen( + onFinished = onboardingViewModel::completeWelcome, + onSkip = onboardingViewModel::skipWelcome, + ) + return + } + val sessionKey = authViewModel.sessionKey ?: return key(sessionKey) { - notesMain(authViewModel) + LaunchedEffect(sessionKey, onboardingState.showWelcome) { + if (!onboardingState.showWelcome) { + onboardingViewModel.startTourIfNeeded() + onboardingViewModel.activateTourIfPending() + } + } + CompositionLocalProvider( + LocalOnboardingRegistrar provides { targetKey, bounds -> + onboardingViewModel.registerTarget(targetKey, bounds) + }, + ) { + Box(modifier = Modifier.fillMaxSize()) { + notesMain( + authViewModel = authViewModel, + onboardingViewModel = onboardingViewModel, + ) + if (onboardingState.showTour && tourSteps.isNotEmpty()) { + val stepIndex = onboardingState.tourStepIndex.coerceIn(0, tourSteps.lastIndex) + val step = tourSteps[stepIndex] + val screenMatches = + step.requiredScreen == null || + step.requiredScreen == onboardingState.currentScreenKind + coachMarkOverlay( + step = step, + stepIndex = stepIndex, + stepCount = tourSteps.size, + targetBounds = step.targetKey?.let { tourTargetBounds[it] }, + screenMatchesStep = screenMatches, + onSkip = onboardingViewModel::skipTour, + onBack = onboardingViewModel::previousTourStep, + onNext = onboardingViewModel::nextTourStep, + ) + } + } + } } } @Composable -private fun notesMain(authViewModel: AuthViewModel) { +private fun notesMain( + authViewModel: AuthViewModel, + onboardingViewModel: OnboardingViewModel, +) { val viewModel: NotesViewModel = koinViewModel() val authState by authViewModel.uiState.collectAsState() val state = viewModel.uiState + LaunchedEffect(state.screen, authState.isSessionActive) { + onboardingViewModel.updateCurrentScreen(state.screen) + onboardingViewModel.updateShowSignOutStep(authState.isSessionActive) + } + when (val screen = state.screen) { NotesUiScreen.Directories -> { directoriesScreen( @@ -59,6 +131,8 @@ private fun notesMain(authViewModel: AuthViewModel) { }, showSignOut = authState.isSessionActive, onSignOut = { authViewModel.signOut() }, + showReturnToSignIn = authState.continueOffline && !authState.isSessionActive, + onReturnToSignIn = { authViewModel.exitOfflineToSignIn() }, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt index 24e9bb0a..0e7a681f 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt @@ -10,10 +10,10 @@ import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase import com.itlab.domain.usecase.noteusecase.GetNoteUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase -import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase +import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase data class NotesUseCases( diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index af143ff5..93850534 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -10,14 +10,14 @@ import com.itlab.domain.model.Note import com.itlab.domain.model.NoteFolder import com.itlab.notes.media.withoutTextItems import com.itlab.notes.ui.notes.ALL_DIRECTORY_ID -import com.itlab.notes.ui.notes.canCreateNotesInDirectory import com.itlab.notes.ui.notes.DirectoryItemUi import com.itlab.notes.ui.notes.FAVORITES_DIRECTORY_ID import com.itlab.notes.ui.notes.NoteItemUi import com.itlab.notes.ui.notes.RECENT_DIRECTORY_ID +import com.itlab.notes.ui.notes.canCreateNotesInDirectory import com.itlab.notes.ui.notes.coerceDirectoryNameLength -import com.itlab.notes.ui.toSingleLineText import com.itlab.notes.ui.notes.isVirtualDirectory +import com.itlab.notes.ui.toSingleLineText import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -58,7 +58,11 @@ class NotesViewModel( is NotesUiEvent.OpenNote -> openNote(event.note) NotesUiEvent.CreateNote -> createNote() is NotesUiEvent.CreateDirectory -> { - val normalized = event.name.toSingleLineText().trim().coerceDirectoryNameLength() + val normalized = + event.name + .toSingleLineText() + .trim() + .coerceDirectoryNameLength() if (normalized.isNotBlank()) { viewModelScope.launch { useCases.createFolderUseCase(NoteFolder(name = normalized)) @@ -100,7 +104,11 @@ class NotesViewModel( } private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { - val normalized = event.newName.toSingleLineText().trim().coerceDirectoryNameLength() + val normalized = + event.newName + .toSingleLineText() + .trim() + .coerceDirectoryNameLength() if (normalized.isBlank() || isVirtualDirectory(event.directoryId)) return viewModelScope.launch { val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt index 20501afc..d5819c27 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt @@ -4,11 +4,8 @@ import android.app.Activity import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -29,6 +26,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -40,6 +38,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -57,16 +56,14 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes import com.google.android.gms.common.api.ApiException import com.itlab.notes.R import org.koin.androidx.compose.koinViewModel @Composable -fun authScreen( - viewModel: AuthViewModel = koinViewModel(), -) { +fun authScreen(viewModel: AuthViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsState() val context = LocalContext.current @@ -130,35 +127,35 @@ fun authScreen( } Scaffold { padding -> - AnimatedContent( - targetState = state.step, + Box( modifier = Modifier .fillMaxSize() .padding(padding), - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "auth_step", - ) { step -> - when (step) { - AuthScreenStep.ChooseMethod -> - authMethodChoiceContent( - isLoading = state.isLoading, - googleSignInEnabled = googleSignInEnabled, - onGoogleClick = launchGoogleSignIn, - onEmailClick = { viewModel.openEmailStep() }, - onContinueOffline = { viewModel.continueOffline() }, - errorMessage = state.errorMessage, - ) - AuthScreenStep.Email -> - authEmailContent( - state = state, - onBack = { viewModel.backToMethodChoice() }, - onSignIn = viewModel::signInWithEmail, - onSignUp = viewModel::signUpWithEmail, - onToggleSignUpMode = { viewModel.toggleSignUpMode() }, - onClearError = { viewModel.clearError() }, - onClearSuccess = { viewModel.clearSuccess() }, - ) + ) { + key(state.step) { + when (state.step) { + AuthScreenStep.ChooseMethod -> + authMethodChoiceContent( + isLoading = state.isLoading, + googleSignInEnabled = googleSignInEnabled, + onGoogleClick = launchGoogleSignIn, + onEmailClick = { viewModel.openEmailStep() }, + onContinueOffline = { viewModel.continueOffline() }, + errorMessage = state.errorMessage, + ) + AuthScreenStep.Email -> + authEmailContent( + state = state, + onBackFromEmail = { viewModel.backFromEmailStep() }, + onSignIn = viewModel::signInWithEmail, + onSignUp = viewModel::signUpWithEmail, + onSwitchToSignUp = { viewModel.switchToSignUpMode() }, + onSwitchToSignIn = { viewModel.switchToSignInMode() }, + onClearError = { viewModel.clearError() }, + onClearSuccess = { viewModel.clearSuccess() }, + ) + } } } } @@ -199,13 +196,15 @@ private fun authMethodChoiceContent( OutlinedButton( onClick = onGoogleClick, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), enabled = !isLoading && googleSignInEnabled, ) { if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, + authButtonLoadingIndicator( + color = MaterialTheme.colorScheme.primary, ) } else { Icon( @@ -222,7 +221,10 @@ private fun authMethodChoiceContent( OutlinedButton( onClick = onEmailClick, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), enabled = !isLoading, ) { Icon( @@ -258,14 +260,15 @@ private fun authMethodChoiceContent( @Composable private fun authEmailContent( state: AuthUiState, - onBack: () -> Unit, + onBackFromEmail: () -> Unit, onSignIn: (String, String) -> Unit, onSignUp: (String, String) -> Unit, - onToggleSignUpMode: () -> Unit, + onSwitchToSignUp: () -> Unit, + onSwitchToSignIn: () -> Unit, onClearError: () -> Unit, onClearSuccess: () -> Unit, ) { - BackHandler(onBack = onBack) + BackHandler(onBack = onBackFromEmail) var email by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } @@ -284,7 +287,7 @@ private fun authEmailContent( ) }, navigationIcon = { - IconButton(onClick = onBack, enabled = !state.isLoading) { + IconButton(onClick = onBackFromEmail, enabled = !state.isLoading) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back", @@ -313,6 +316,7 @@ private fun authEmailContent( onClearError() onClearSuccess() }, + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), label = { Text("Email") }, singleLine = true, @@ -331,6 +335,7 @@ private fun authEmailContent( onClearError() onClearSuccess() }, + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), label = { Text("Password") }, singleLine = true, @@ -382,13 +387,15 @@ private fun authEmailContent( onSignIn(email, password) } }, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), enabled = !state.isLoading, ) { if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - strokeWidth = 2.dp, + authButtonLoadingIndicator( + color = MaterialTheme.colorScheme.onPrimary, ) } else { Text( @@ -403,7 +410,7 @@ private fun authEmailContent( } TextButton( - onClick = onToggleSignUpMode, + onClick = if (state.isSignUpMode) onSwitchToSignIn else onSwitchToSignUp, enabled = !state.isLoading, ) { Text( @@ -424,6 +431,15 @@ private fun authMethodIconModifier(): Modifier = .size(30.dp) .padding(end = 12.dp) +@Composable +private fun authButtonLoadingIndicator(color: Color = LocalContentColor.current) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = color, + ) +} + @Composable private fun authMessageBlock( errorMessage: String?, diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt index 4dc13962..bca4d61d 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt @@ -12,6 +12,7 @@ import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseAuthWeakPasswordException import com.google.firebase.auth.GoogleAuthProvider import com.itlab.notes.R +import com.itlab.notes.auth.AppSessionPreferences import com.itlab.notes.auth.ClearLocalDataOnSignOut import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,6 +28,7 @@ enum class AuthScreenStep { data class AuthUiState( val step: AuthScreenStep = AuthScreenStep.ChooseMethod, + val isSessionReady: Boolean = false, /** User may enter the notes app (explicit sign-in or restored Firebase session). */ val isSessionActive: Boolean = false, val continueOffline: Boolean = false, @@ -39,6 +41,7 @@ data class AuthUiState( class AuthViewModel( private val firebaseAuth: FirebaseAuth, private val app: Application, + private val appSessionPreferences: AppSessionPreferences, private val clearLocalDataOnSignOut: ClearLocalDataOnSignOut, ) : ViewModel() { private var shouldActivateSession = firebaseAuth.currentUser != null @@ -62,16 +65,32 @@ class AuthViewModel( private val authStateListener = FirebaseAuth.AuthStateListener { auth -> val signedIn = auth.currentUser != null + val sessionActive = signedIn && shouldActivateSession _uiState.update { it.copy( - isSessionActive = signedIn && shouldActivateSession, + isSessionActive = sessionActive, + continueOffline = if (sessionActive) false else it.continueOffline, isLoading = false, ) } + if (sessionActive) { + viewModelScope.launch { appSessionPreferences.setContinueOffline(false) } + } } init { firebaseAuth.addAuthStateListener(authStateListener) + viewModelScope.launch { + appSessionPreferences.continueOffline.collect { offline -> + _uiState.update { state -> + val sessionActive = firebaseAuth.currentUser != null && shouldActivateSession + state.copy( + continueOffline = if (sessionActive) false else offline, + isSessionReady = true, + ) + } + } + } } override fun onCleared() { @@ -83,6 +102,7 @@ class AuthViewModel( _uiState.update { it.copy( step = AuthScreenStep.Email, + isSignUpMode = false, errorMessage = null, successMessage = null, ) @@ -96,22 +116,63 @@ class AuthViewModel( isSignUpMode = false, errorMessage = null, successMessage = null, + isLoading = false, ) } } - fun toggleSignUpMode() { + fun switchToSignUpMode() { _uiState.update { it.copy( - isSignUpMode = !it.isSignUpMode, + isSignUpMode = true, errorMessage = null, successMessage = null, ) } } + fun switchToSignInMode() { + _uiState.update { + it.copy( + isSignUpMode = false, + errorMessage = null, + successMessage = null, + ) + } + } + + fun backFromEmailStep() { + if (_uiState.value.isSignUpMode) { + switchToSignInMode() + } else { + backToMethodChoice() + } + } + fun continueOffline() { _uiState.update { it.copy(continueOffline = true, errorMessage = null) } + viewModelScope.launch { appSessionPreferences.setContinueOffline(true) } + } + + private suspend fun clearOfflineSession() { + appSessionPreferences.setContinueOffline(false) + } + + /** Leaves offline mode and returns to the sign-in choice screen. Local notes are kept. */ + fun exitOfflineToSignIn() { + viewModelScope.launch { + clearOfflineSession() + _uiState.update { + it.copy( + step = AuthScreenStep.ChooseMethod, + continueOffline = false, + isSessionActive = false, + isLoading = false, + errorMessage = null, + successMessage = null, + ) + } + } } fun clearError() { @@ -170,11 +231,10 @@ class AuthViewModel( }.onSuccess { _uiState.update { it.copy( - step = AuthScreenStep.Email, - isSignUpMode = false, isLoading = false, isSessionActive = false, - successMessage = "Account created. Sign in with your email and password.", + successMessage = + "Account created. Switch to Sign in below when you are ready.", errorMessage = null, ) } @@ -214,6 +274,7 @@ class AuthViewModel( shouldActivateSession = false runCatching { clearLocalDataOnSignOut() + clearOfflineSession() firebaseAuth.signOut() signOutGoogle() }.onFailure { error -> diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index 5894580c..7df79b62 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -11,11 +11,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -35,7 +34,8 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -88,7 +88,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation -import kotlin.math.roundToInt import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -103,10 +102,11 @@ import com.itlab.notes.media.NoteMediaImport import com.itlab.notes.media.imageAttachments import com.itlab.notes.media.toCoilModel import com.itlab.notes.ui.asDomainFolderId -import com.itlab.notes.ui.toSingleLineText import com.itlab.notes.ui.notes.NoteItemUi +import com.itlab.notes.ui.toSingleLineText import kotlinx.coroutines.delay import org.koin.compose.koinInject +import kotlin.math.roundToInt private const val EDITOR_TOP_BAR_TITLE_MAX_LENGTH = 35 @@ -115,7 +115,7 @@ private val EditorHorizontalContentPadding = 15.dp private val EditorContentScrollBottomInset = 120.dp private val EditorContentScrollTopInset = 16.dp private val EditorContentFieldMinHeight = 160.dp -private const val EditorAutosaveDebounceMs = 600L +private const val EDITOR_AUTOSAVE_DEBOUNCE_MS = 600L private data class EditorAttachmentsViewerState( val images: List, @@ -126,11 +126,11 @@ private fun String.truncateForEditorTopBar(): String = take(EDITOR_TOP_BAR_TITLE private const val EDITOR_AI_UI_PREVIEW = true -private val editorAiPreviewSummary = +private const val EDITOR_AI_PREVIEW_SUMMARY = "This note is about planning the product launch: goals for the week, " + "open questions for the team, and a short list of next steps." private val editorAiPreviewTags = - listOf("Work", "Planning", "Product", "Follow-up", "Study", "Study","Study",) + listOf("Work", "Planning", "Product", "Follow-up", "Study", "Study", "Study") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -163,7 +163,7 @@ fun editorScreen( LaunchedEffect(editorVm.title, editorVm.content, editorVm.attachments, titleHasDuplicate) { if (editorVm.buildUpdatedNote() == initialNote) return@LaunchedEffect - delay(EditorAutosaveDebounceMs) + delay(EDITOR_AUTOSAVE_DEBOUNCE_MS) if (!titleHasDuplicate) { persistDraftIfNeeded() } @@ -247,7 +247,7 @@ fun editorScreen( titleHasDuplicate = titleHasDuplicate, content = editorVm.content, attachments = editorVm.attachments, - aiSummary = if (EDITOR_AI_UI_PREVIEW) editorAiPreviewSummary else null, + aiSummary = if (EDITOR_AI_UI_PREVIEW) EDITOR_AI_PREVIEW_SUMMARY else null, onTitleChange = editorVm::onTitleChange, onContentChange = editorVm::onContentChange, onAttachmentClick = { item -> @@ -538,7 +538,8 @@ private fun editorImageThumbnail( if (model != null) { AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(model) .crossfade(true) .allowHardware(false) @@ -646,7 +647,8 @@ private fun editorFullScreenAttachmentsViewer( val absorbImageTap = remember(item.id) { MutableInteractionSource() } AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(model) .crossfade(false) .allowHardware(false) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt b/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt index f556a811..929d7486 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/AppEmptyState.kt @@ -86,9 +86,7 @@ fun directoriesSearchEmptyState(modifier: Modifier = Modifier) { } @Composable -fun directoriesEmptyState( - modifier: Modifier = Modifier, -) { +fun directoriesEmptyState(modifier: Modifier = Modifier) { appEmptyState( icon = Icons.Rounded.Folder, title = "No directories yet", diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 973b8c30..97b21b70 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -30,6 +30,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Login +import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.AllInbox import androidx.compose.material.icons.rounded.ChevronRight @@ -38,7 +40,6 @@ import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.FolderCopy import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Star -import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button @@ -79,13 +80,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.VisualTransformation -import com.itlab.notes.ui.toSingleLineText import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties -import com.itlab.notes.R +import com.itlab.notes.onboarding.OnboardingTargets +import com.itlab.notes.onboarding.onboardingTargetModifier +import com.itlab.notes.ui.toSingleLineText private const val DIRECTORY_NAME_TAKEN_ERROR = "A directory with this name already exists" @@ -101,6 +102,8 @@ fun directoriesScreen( onDirectoryClick: (DirectoryItemUi) -> Unit, showSignOut: Boolean = false, onSignOut: () -> Unit = {}, + showReturnToSignIn: Boolean = false, + onReturnToSignIn: () -> Unit = {}, ) { val colors = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current @@ -125,6 +128,8 @@ fun directoriesScreen( directoriesTopBar( showSignOut = showSignOut, onSignOut = onSignOut, + showReturnToSignIn = showReturnToSignIn, + onReturnToSignIn = onReturnToSignIn, onAddDirectoryClick = { showCreateDialog = true }, ) }, @@ -303,22 +308,41 @@ internal fun universalBasicAlertDialog( private fun directoriesTopBar( showSignOut: Boolean, onSignOut: () -> Unit, + showReturnToSignIn: Boolean, + onReturnToSignIn: () -> Unit, onAddDirectoryClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme + val showAccountAction = showSignOut || showReturnToSignIn CenterAlignedTopAppBar( title = { Text("Directories", color = colors.onSurface) }, actions = { - if (showSignOut) { - IconButton(onClick = onSignOut) { + if (showAccountAction) { + IconButton( + onClick = if (showSignOut) onSignOut else onReturnToSignIn, + modifier = + if (showSignOut) { + onboardingTargetModifier(OnboardingTargets.DIRECTORIES_SIGN_OUT) + } else { + Modifier + }, + ) { Icon( - imageVector = Icons.AutoMirrored.Rounded.Logout, - contentDescription = "Sign out", + imageVector = + if (showSignOut) { + Icons.AutoMirrored.Rounded.Logout + } else { + Icons.AutoMirrored.Rounded.Login + }, + contentDescription = if (showSignOut) "Sign out" else "Sign in", tint = colors.onSurface, ) } } - IconButton(onClick = onAddDirectoryClick) { + IconButton( + onClick = onAddDirectoryClick, + modifier = onboardingTargetModifier(OnboardingTargets.DIRECTORIES_ADD), + ) { Icon( Icons.Rounded.Add, contentDescription = null, @@ -386,6 +410,7 @@ private fun directoriesList( directorySearchBar( query = searchQuery, onQueryChange = onSearchQueryChange, + modifier = onboardingTargetModifier(OnboardingTargets.DIRECTORIES_SEARCH), ) LazyColumn( modifier = Modifier.weight(1f), @@ -394,6 +419,7 @@ private fun directoriesList( fun LazyListScope.addSection( title: String, dirs: List, + tourHighlightDirectoryId: String? = null, ) { if (dirs.isEmpty()) return item { sectionTitle(title = title) } @@ -402,6 +428,7 @@ private fun directoriesList( directories = dirs, onDirectoryClick = onDirectoryClick, onDirectoryLongClick = { pendingDelete = it }, + tourHighlightDirectoryId = tourHighlightDirectoryId, ) } } @@ -421,7 +448,12 @@ private fun directoriesList( if (!isSearchActive) { addSection("Continue working", listOf(recentDirectory)) } - addSection("Regular directories", regularDirectories) + addSection( + title = "Regular directories", + dirs = regularDirectories, + tourHighlightDirectoryId = + regularDirectories.firstOrNull()?.id ?: allNotesDirectory?.id, + ) when { isSearchActive && directories.isEmpty() -> { @@ -539,6 +571,7 @@ private fun directoriesBlock( directories: List, onDirectoryClick: (DirectoryItemUi) -> Unit, onDirectoryLongClick: (DirectoryItemUi) -> Unit, + tourHighlightDirectoryId: String? = null, ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, @@ -558,7 +591,16 @@ private fun directoriesBlock( onDirectoryLongClick(dir) } }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + modifier = + Modifier + .padding(horizontal = 12.dp, vertical = 0.dp) + .then( + if (dir.id == tourHighlightDirectoryId) { + onboardingTargetModifier(OnboardingTargets.DIRECTORIES_FOLDER_ROW) + } else { + Modifier + }, + ), ) if (index < directories.lastIndex) { directoriesListDivider() diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index f5839e4a..3d2ee597 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material.icons.rounded.FolderCopy import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -58,6 +57,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.itlab.notes.onboarding.OnboardingTargets +import com.itlab.notes.onboarding.onboardingTargetModifier @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -425,6 +426,7 @@ private fun notesFab(onAddNoteClick: () -> Unit) { val colors = MaterialTheme.colorScheme FloatingActionButton( onClick = onAddNoteClick, + modifier = onboardingTargetModifier(OnboardingTargets.NOTES_FAB), containerColor = colors.primary, ) { Icon( @@ -476,6 +478,7 @@ private fun notesListContent( } } } + val tourNoteId = notes.firstOrNull()?.id items( items = notes, key = { note -> note.id }, @@ -483,6 +486,12 @@ private fun notesListContent( notesListItem( note = note, isSelected = note.id in selectedNoteIds, + modifier = + if (note.id == tourNoteId) { + onboardingTargetModifier(OnboardingTargets.NOTES_NOTE_ROW) + } else { + Modifier + }, onClick = { if (isSelectionMode) { if (note.id in selectedNoteIds) { @@ -509,12 +518,14 @@ private fun notesListContent( private fun notesListItem( note: NoteItemUi, isSelected: Boolean, + modifier: Modifier = Modifier, onClick: () -> Unit, onLongClick: () -> Unit, ) { noteCard( note = note, isSelected = isSelected, + modifier = modifier, onClick = onClick, onLongClick = onLongClick, ) @@ -525,6 +536,7 @@ private fun notesListItem( private fun noteCard( note: NoteItemUi, isSelected: Boolean, + modifier: Modifier = Modifier, onClick: () -> Unit, onLongClick: () -> Unit, ) { @@ -542,7 +554,7 @@ private fun noteCard( ), shape = MaterialTheme.shapes.large, modifier = - Modifier + modifier .fillMaxWidth() .clip(MaterialTheme.shapes.large) .combinedClickable( @@ -602,7 +614,10 @@ private fun searchField( appSearchField( value = query, onValueChange = onQueryChange, - modifier = Modifier.padding(vertical = 16.dp), + modifier = + Modifier + .padding(vertical = 16.dp) + .then(onboardingTargetModifier(OnboardingTargets.NOTES_SEARCH)), placeholderText = "Search notes", ) } diff --git a/data/src/main/java/com/itlab/data/dao/NoteDao.kt b/data/src/main/java/com/itlab/data/dao/NoteDao.kt index 7ca6517f..6461d316 100644 --- a/data/src/main/java/com/itlab/data/dao/NoteDao.kt +++ b/data/src/main/java/com/itlab/data/dao/NoteDao.kt @@ -9,6 +9,7 @@ import androidx.room.Update import com.itlab.data.entity.NoteEntity import kotlinx.coroutines.flow.Flow +@Suppress("TooManyFunctions") @Dao interface NoteDao { @Query("SELECT * FROM notes WHERE isDeleted = 0 ORDER BY updatedAt DESC") diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt index 06685bd4..8fb8ec0f 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt @@ -4,6 +4,7 @@ package com.itlab.domain.usecase.noteusecase * Picks a title that does not collide with [existingTitles] in the same folder (case-insensitive). * If [desiredTitle] is taken, returns `"$base (1)"`, `"$base (2)"`, … */ +@Suppress("ReturnCount") fun resolveUniqueNoteTitle( desiredTitle: String, existingTitles: Iterable, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b4059cbc..29db351a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ androidx-work-testing = "2.9.0" lifecycleViewmodelKtx = "2.10.0" koin = "4.2.1" coilCompose = "2.7.0" +datastore = "1.1.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,6 +64,7 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } mockk = { group = "io.mockk", name = "mockk", version = "1.14.9" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-test-core = { group = "androidx.test", name = "core", version = "1.7.0" } From 0bebabb9066d1709bb06d365dddb75dbee0ea3ca Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 15:52:13 +0300 Subject: [PATCH 29/35] fix ci --- data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt | 2 -- .../java/com/itlab/data/repository/AuthRepositoryImplTest.kt | 2 -- .../itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt | 2 +- .../itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt b/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt index c2be5b88..8e5ca22f 100644 --- a/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt +++ b/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthUI -import com.google.android.gms.tasks.Tasks import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth @@ -18,7 +17,6 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.unmockkConstructor import io.mockk.verify -import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt b/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt index ecb40174..57f60f14 100644 --- a/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt +++ b/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt @@ -4,8 +4,6 @@ import com.itlab.data.cloud.AuthManager import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt index 9ec189aa..3158bf84 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt @@ -2,9 +2,9 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.ContentItem import com.itlab.domain.repository.NotesRepository +import kotlinx.coroutines.flow.first import java.util.UUID import kotlin.time.Clock -import kotlinx.coroutines.flow.first class DuplicateNoteUseCase( private val repo: NotesRepository, diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt index 14461917..f2ca381f 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt @@ -3,8 +3,8 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.repository.NoteFolderRepository import com.itlab.domain.repository.NotesRepository import com.itlab.domain.usecase.requireNotBlank -import kotlin.time.Clock import kotlinx.coroutines.flow.first +import kotlin.time.Clock class MoveNoteToFolderUseCase( private val notesRepo: NotesRepository, From f063de3533a27eb53f66138e8a79ac4284c118f7 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 16:00:04 +0300 Subject: [PATCH 30/35] fix: auth local files --- app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt index bca4d61d..6e68b8b5 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthViewModel.kt @@ -158,9 +158,10 @@ class AuthViewModel( appSessionPreferences.setContinueOffline(false) } - /** Leaves offline mode and returns to the sign-in choice screen. Local notes are kept. */ + /** Leaves offline mode and returns to the sign-in choice screen. */ fun exitOfflineToSignIn() { viewModelScope.launch { + runCatching { clearLocalDataOnSignOut() } clearOfflineSession() _uiState.update { it.copy( @@ -201,6 +202,7 @@ class AuthViewModel( shouldActivateSession = true runCatching { firebaseAuth.signInWithEmailAndPassword(trimmedEmail, password).await() + clearLocalDataOnSignOut() }.onFailure { error -> shouldActivateSession = false _uiState.update { @@ -256,6 +258,7 @@ class AuthViewModel( runCatching { val credential = GoogleAuthProvider.getCredential(idToken, null) firebaseAuth.signInWithCredential(credential).await() + clearLocalDataOnSignOut() }.onFailure { error -> shouldActivateSession = false _uiState.update { From 224d773a3e254b0bbb7d24ee433fc67dc9a72aab Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 16:22:59 +0300 Subject: [PATCH 31/35] fix: google-services.json --- app/google-services.json | 54 +++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/app/google-services.json b/app/google-services.json index 0942b00a..8eeed345 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -1,23 +1,37 @@ -{ - "project_info": { - "project_number": "123456789", - "project_id": "mock-id" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:123456789:android:mock", - "android_client_info": { - "package_name": "com.itlab.notes" - } - }, - "api_key": [ +{ + "project_info": { + "project_number": "123456789", + "project_id": "mock-id" + }, + "client": [ { - "current_key": "mock-key" + "client_info": { + "mobilesdk_app_id": "1:123456789:android:mock", + "android_client_info": { + "package_name": "com.itlab.notes" + } + }, + "oauth_client": [ + { + "client_id": "mock-id", + "client_type": 1, + "android_info": { + "package_name": "com.itlab.notes", + "certificate_hash": "mock-hash" + } + }, + { + "client_id": "mock-id", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "mock-key" + } + ], + "services": {} } - ], - "services": {} - } - ], - "configuration_version": "1" + ], + "configuration_version": "1" } From 93fccd387d428e9e91796eda700c1c934d6978e4 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 17:14:52 +0300 Subject: [PATCH 32/35] fix: delete debugging files --- .tmp_docx.zip | Bin 20864 -> 0 bytes .tmp_docx/[Content_Types].xml | 2 - .tmp_docx/_rels/.rels | 2 - .tmp_docx/docProps/app.xml | 2 - .tmp_docx/docProps/core.xml | 2 - .tmp_docx/word/_rels/document.xml.rels | 2 - .tmp_docx/word/document.xml | 2 - .tmp_docx/word/fontTable.xml | 2 - .tmp_docx/word/settings.xml | 2 - .tmp_docx/word/styles.xml | 2 - .tmp_docx/word/theme/theme1.xml | 2 - .tmp_docx/word/webSettings.xml | 2 - app/src/main/java/com/itlab/AppModule.kt | 6 +- .../java/com/itlab/notes/ui/NotesUseCases.kt | 2 + .../java/com/itlab/data/cloud/AuthManager.kt | 6 +- .../main/java/com/itlab/data/dao/NoteDao.kt | 4 -- .../data/repository/AuthRepositoryImpl.kt | 5 -- .../com/itlab/data/cloud/AuthManagerTest.kt | 19 ++++-- .../java/com/itlab/data/di/DataModuleTest.kt | 3 - .../data/repository/AuthRepositoryImplTest.kt | 13 ---- .../itlab/domain/repository/AuthRepository.kt | 2 - .../usecase/noteusecase/CreateNoteUseCase.kt | 20 +++--- .../noteusecase/DuplicateNoteUseCase.kt | 13 +--- .../noteusecase/MoveNoteToFolderUseCase.kt | 15 +---- .../noteusecase/ResolveUniqueNoteTitle.kt | 28 -------- .../usecase/noteusecase/SearchNotesUseCase.kt | 13 +--- .../usecase/noteusecase/UpdateNoteUseCase.kt | 12 ++-- .../ValidateDuplicateNoteTitleUseCase.kt | 10 ++- .../java/com/itlab/domain/NoteUseCasesTest.kt | 63 ++---------------- .../domain/ResolveUniqueNoteTitleTest.kt | 47 ------------- .../itlab/domain/SearchNotesUseCaseTest.kt | 31 +++++++++ 31 files changed, 88 insertions(+), 246 deletions(-) delete mode 100644 .tmp_docx.zip delete mode 100644 .tmp_docx/[Content_Types].xml delete mode 100644 .tmp_docx/_rels/.rels delete mode 100644 .tmp_docx/docProps/app.xml delete mode 100644 .tmp_docx/docProps/core.xml delete mode 100644 .tmp_docx/word/_rels/document.xml.rels delete mode 100644 .tmp_docx/word/document.xml delete mode 100644 .tmp_docx/word/fontTable.xml delete mode 100644 .tmp_docx/word/settings.xml delete mode 100644 .tmp_docx/word/styles.xml delete mode 100644 .tmp_docx/word/theme/theme1.xml delete mode 100644 .tmp_docx/word/webSettings.xml delete mode 100644 domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt delete mode 100644 domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt diff --git a/.tmp_docx.zip b/.tmp_docx.zip deleted file mode 100644 index 650fe421979654f8c54d6d5cd597f44527e1d53c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20864 zcmeFZV~{4nwl3VZF>TwnZBE;^t!dk~ZQGi*-Tk(vZJT%Y-e3o#F)>EnpxZLU14;R3Io2Z4rAr7gIYIeHBj! zQ)gXz4_h0;LNE}@d?1j&`v33xA6x^?$&+@2j7Vb7DQ}1gZK_5mg_YDGQT(ZN%BPT+ z-XLn9iKE?Ly{I5cs=x^_HpFBs&)Y0&BSG^UnKsbKE$(FJ7<{P#NqUwXH0!GebbcmS zuv*?sQW z22I;0bD&aafy6=3%SVAtYFuUeWhY6DX|nFTs9~NW8*I>w7IEftx|EmflcpH&1PF4* zyNSid6i8Q8kA=K&A#h<;q8S?@E_FAoh^q5Z(edo4FQ%0T;O;)CmXI~SZP1A_04L0B zW){|H6l~>wQ$m);0C)3>?E~--sScnp+yHSe^HzU%q+j+&Ux1bX& zKnpydW{=i3vLlOU93Ox!i}nRZw(D$aW-NDsm`)e{bh9K^%x57VZSw{3#*P5*wlAPS zK;Pe>K#Ko|OX4SBw_X2jXYzkN4)(80>N}a*I5W`yBmIA__&?bH{^QfD69=q@7~ur3 zgFb`idz3Z?FpK0FjOPHD>yS{|QnILkHLKOHPu{gP5ZzNliOGe)QZV#Z8knR#Dj}?bKxU8cmv(E_QSW9B6*(GDrrlMR&)z#;8UzYp~rsWkYNo{Y)NW;!TZU&oP0 zSOVstQZSl!`+Z2=dqbv=p zBX;CtglhnsAD)_E@ut?a0Bo!^{kad=SF#Fc`NtkAj7hnqy*0$tB@=aZ^_+8e`p2d` zzjaFtyN)2ks@2bf=+44ys`jmEy{6@@w}Yvcs+o|xj6sK`6Ly?Sy}$A)Ja>n0j?%K?8j9#^{^~}T^WSCG8%@E5L4jT?4Q7SAWF&91NUQs=f+o4EU0dgLF(MGIGZ z>8bE7^yssT(UFP|8J?}lgAE&|_wc9@Vn{W^ai4Fd1@ zxbC*DYdv%hU8)J}-o##5TnyRz#cNaa4A;m5zPB%XsmJJ(hLhxEun;JjfAxOluup#+ zAghWQNEn3gA|y}^=bg?L50x|e)GsZF{!m9pBm4r=vH~(*L`Qr1!ZhU{IWrq8V3e1M zXZvupP&dd{Yp<`&$CZ&MXYQ}(va(ZPi#)13r``l$vbEw2`Og`Ox_3&Y1pc{lA+}!H z@e&Y!ahU_0h84AJ+c(E3R&POo8b>y8iilu)jKa$mRtn@nZ6nqlc)vt9Sk^1oNNuh_$3<3v+Nk%=v<#+(AT zPdrRO)n1!%uq^;saM5f>vXdEM;$uNQ!NKZwR#sdEaI2+`BFJSn>$+i z3)DtmHLriH+OciqWm}$@tNl6i=kJd7)2V}BpH=mBWovhh=Spzj^!2uFzWUq--0OJ> zczN%KZ3Dl1p5UR4bZz5Ydwihvaur@L4lT&2RW3~_H=N1#nRr&qn*g7Yv|aZYeiKAr$GDFw!0!8nw3+yAnqGgJeWZVWjqy->gn?^-K0VE@{jy(kH$WeG zUMn%bFnST=zXRU9Plb01_xlo*;qdKLzWLhygxcYL(yI^KHYLD|y+(IH(>Mycy#nt( zVKUUwY#qGi91C*kl}ARxoe?%me44_Tkj%B@}2wJ%|9W590bg1inSalzz3 zu%m&A!yoKwavfUt+s@pD(RWJC2yy#8eGBXziYEr(=l22s&VmVPCiMV{TF?>}Mz)ei z8E{P_dtmSpY)jVVo(2zB^?-ehroG{R-aUj4+)BeOt=b@6Ca-MT7o%C3Je{cZNj1K8 zo$z$^ZZb7y2vcGG!?s14c5s2Va7k?7CYzJ!Ad>3!8?%^>@lpmUaJYR~WBfhAyvSyv zw#N+jH5fXM?**UkGT<2mjAq|*mpz%W&M{fS8bqcxqf<@!@y>{rEB&YSkgsu)rod+a zj|*hmCd)YnOaq4DGn`is&Lgnm>4|*aFZVTfz$FdI5vwzC8N6uU&y#JhHmn{NZ~Tz& zuJ@XJ!5&C&_&d%*w=bdLD@C{(Bh|-}&Da3IHN1Aw_OAT4cVZ*x+Cq^qq_1~GCb~x& zQ+S(SE+@)bC@oQQu`q;DDYB10@7qS3nBLqK^T5*&*3I*=5ibd19P3KN9mA4WYo9{N;pjl0qs@QtSiRKlmkkjSpwy($B zgSXgN-Mf=t`r zqzOOBD{{%J`)~JUF%Q8OK}Q572tLld^VgAl!iVVApZLQ!%7|O}0XSP!DVHTG_BCeP znV0-wal(R^=o&u=hx$7?O-iACWk*bHp1Va_muDRThR+D|WKRv;xOc99Hd^4dhbgJl z;6`gxNJ)pN)>@RTmu3K@12G;5HUTS;d&&(x$eOMq9MY{NG%_7$9A1~DJZocF#_|RT z!7};|;VfW|`T=A>8q`>)2SvcwxCv{b;Tc%agovU044`VhAJ!@nP-DAh4vh4QS(4JS z*ww(4pe7@%48u@!9G;sOw2Yd-|`+BppYg*=s*m8F)y4>#un0GJ(e*2xqc zc+%cYpJrE0LRhVF3zRT7A(26nu>Jr-Y+*<09Y)Y!*Ca>`uY?q9zLL#xoLb>yH{uvh z+u|PIOEO7sgDd&-QVd(u+vQtlwb5ERRBZM~FA*iW7=KWEjM7t0>B@+>t!xhIZZXM# zo55ic`fH6*%nS|;EKpTZEVa8My;xDCV=QTiq|}ruO{X>FrB<-HW3+pOX#V&fU%lK$0T{fL4fiQ?YR!CJ_;Kgd$xt;j$} zchTKpi_b>^E8F))M5qk~>{ zS;5kJyseh2*U=}n;Ck?(g@k>`K|SDfDm)6+TXB9LytiqJyp*GewOr^AQk41V+9o5)Av_}38!yagLK@?uKpU@EJu{Qj zojItrGuGlf%>jV6V()Y13W98K7=MNYa%EaBQ>SnphfP`h5!h=)by!cXAQz^#YEf3U zHRrm2;JzwPXiAs?Wk$0K;f7eMjvoqvaKya5mtoi@h8TFHBw{*NYD#RtC4CiR#73Fa zVG}SG#z7(My{)$ctsY%Vlh*HR^h_1$K_zNlW% z3Wt{!>0XDeuF5pSE(tkuyynRTlS&a<@E@47ZQjDYEQM7J6wHS(*WGn3H}whz>7O+v zM#W^5p&Tj{^%S7u=Ml`3To8N#GiqXp|J--fi#g>ZPZK9E zk3pxI{(IqTFOMIR2~;UVU#I6~oy%#7rT;)w+J_VsDGYV*SJ@e6DBm+R9aihw32Up< zGaSo;dv}oLO@DnG%i5IlTQN8msHFUu+0%PUH-&4elv6pjFI=@^MX=|eAT|5 zss5OAECvVt3d7u;+}I9CH9i?^GU^8qJu!@_LFQ|s2s zid(Ny94-4CrF%mnZaQS-tNVO_H@B~Yyr>ElTUVj={iv3SXv*S*02UX-St8Zs>v>Uo zV)hb<6xnIT0bE)xI1A1uy}b5jX(r*fo>Y?hxq8P8H?_0k9&gZT5D-QRd#7cksqs~O zXLR#x^>R-}Q*#6t`96hwf>TS z^hCp`qWe+|#-T8-s@g!*NJ z*gPs3N!7+{kI9mJK#gf7+{Hkg_yN=oMMPUElq60`&kn(n)dSF_`c0(bW}POtOfBQ_ zz*0ybo)t+)7{=62!eW;d~6ut8R1u^8lHQX}3_F$Yz(S&f?3l&@7}W!Cau;M()~wmY<2%5ohlFZgY5>iuaE z`PSmkrvT#VCDx((oWjmIIoloqQyY@7o(4IvxK1NzXegn+W6Y%(UM~rWg{h}=Qjhvi z?$|K!YC@x-)(oka*bWlLX%%ZXMmv0eT%-NFQnmMm?Mf5jyUv|-&9O8lm!7?)NN0K4 z$y{wE8lK;kcMW5gK~i*-PB?n0D7x#~eih#l zZL~spDNk-8Xb32f0=s>2-6lBi_0E{G-uNx|GIzW@|7HrZn{CasocTD4cf8rx4k}Ag z1?5Pd%Wd0?w9I6;L0#)e21iWTxFN~qbx7NgTO9;Mggj1h`xH3KJRd2U0J~{wc$D)Q zyQS((@4;Myeut950iE7OR#?bB(zW1ZDP9Ts z7OO0qC{Pdw2APu^1Z7Y8 zD^-s+Ks_o)_8e%>u>4>rW@6Qo!q6m-_5ri+ z5)!C{k#d5}zK$DYgPt_8CHxHToLsKyETWuTUrdLY6FdC+b2LvXE9FWMB*m z6r)t4I!Bja_&YP%asgc7QaWPMM!|KQ9+M-(%UknTLm?H_G8xgT{vgHV1E%{5!r(!t zK%`(0aPo}fE6W|Wm6j`n^N{PRNnio^uRiNXSJfysa&x%(b{QpJXqZ8K%6R1`hqMc;OToU zxX5Cme({3RWyk#pZn%<>Pv8tXP=YUI;sG)jzhPnF!b?+MPz-QWRT8TDdKg~p4|&^0 zl;S5|ux7%JomAt0H%k4;2aV$vyuAVxq+|;n2y4`;wy3mN(3P-~h#Da*$TnL3NHc+6 z6)-m6fd_&D@5J4jvzlIlMrX<@~g4u4X2@ zm7FnkbKgS`xKyij6Jy|Uh8Su(F3k{^n?FM#Dk9_Q;f4qAFA$0k#O6!!rKehpSxFY9 zdnZg-vTjmgQ+3@g0rep&t$i3@Gn29e1GM%{;+~eDHGgKE~jM3XbO*cnKACmCIO(Tq!o@z*s6+?b>3YedV7X?%rs+dxD3 z$9}98k^D%w-@U=Pph1rH#Kg5taI~3W>4u7NbZ6L&&MOKbFK1?JDV^p)X7RSu5_7le zgu8LnQYFW&MiE@qTt4y^_Hfg!T9(c56)EdKD``R`>nfAV>aGu0Mtmsv!Df6Vp5?uk%x}BF)Fc^~cJNqm;62lGlcGKj`)= z<*~t;Ln+NHxiEjl7h0EJc@grpPpQ8!-s%#>bk-vY!dpY^g}V;beVJn+Sq0{XBMQNf zm@hLbcU_!2-t;H(wkwHLWqPaxyQalTf8;B2B{bxP$6g#Nw4;OKCq^DAbrx#==W>&? zriS*N6V?b6b{~W zh@Q=m-uQcwi9`^%85jP}U{pbkIE=RF8f(=21C&!}&ap@P1)yJqvLn-qAs+H&Z=taL z4!}`43cv@Yqo=vo-qP;La=Ow_$API!_171q>p2KxC}0gUp1+A=)P>VPOQepi*+3&E zEa~JMvjk8VjQ5!+iD_(|)SYFpPpak#A548+R%y+MG%(Jv*!veU2boqGpH+fhU&wckvJ73o86MkGbYZQ`)PTB8phemo;S=aDSuNO_UzX0v`M^{et{bR`E9?OXt9= z0~`f14tGq<>I+mE?z6PiSgGN9-mJJ#rc%I%!@O5{)w@&3Bqj+`BIfYL@v1)0iA!aY zUv^<~OCj7@tyi1T%H{4ay~Ya{$za~Bt9eMr<@}||8lcz2z#q^A=)Vb=d6F>5C1n=a zHLAQq#^fXsA)RUU&~}J|qAGu^RBB7MN!SQ7kim)%yz2bK7QFl zi|SppZ(|pz-vehMkcS#_Yjc$Uc!m=bj~0rwzI)<@@d+4iErd8ghmFsn_qjOnyyM$7 zDG;T*SAVcE3;MdO6Rt`zyn1+BWalr&$*?}n`-6onQm*<1`ibu}-VXa)ckv;D1uBLm z0YcD~n=PQb~DqefD!f5Mv z8>Qfn27VX|J7LQ_B)R1ar;gfO>`88EF5l_LXiCbBHfqW9NVfJp7#8zcgr-;i+qbS5X)mxWw;C`314D7k7~tL$TCErF8|~4CZVEK5tCFU zR3i7gc^cJGrR9!QfB6V1#tQeA1~&5qiB?3E`DdNV;DAGmX0DAsCv56`$SId^)tAZX~vc9ert7 z=m_wwhBNWQrSK4%HtnKmTR%-RadefoGZI;T+ZP^~A1To5_em_^Lx{)GHNC-1JXl8oe7?B{jH6P!7Bomh9c&1uDe>$EZN*|KCdd=PzMopE0&K}rQY=U&49fd|194eD zE4Ac1lylyNY6p#qcIUy3!(v9wWUfp<`hUh|n)fpiN4A;tXC@3rI%MGE7_hy>3{Z{ zuspf^1kx;>jgX)6E_Fh)&fH5j@fw<8NwF%O%*&8XVQ`kukxl_N4!^I1!m}{zC|r&z zBQJ^N?ahEWQ6X1lhyS1uheaS^+afhqB6X5stb$B)NtF_z7tH*ujJE(a*BjY!`#=kS z2NPh^CYI_&nxHj+f`VpFoN**cVpLQkg`C6o3HbFy_d+^nREK%@ajEHe1fqh6O?Plf(^r8SYb~r-`u?_% z-FqtmfJc{$qpB?JrknMR{AeV6RQ$l}RBzQhheOhKAJAPbsz(cx6J4!g2MAyUVWD=K z3eGEikNn7c@z8)$I1BOG{^M@}-@m@>AP}hLFNgSn&r`3KW}*M+`HSnaq&H? zwUu=&N}8irmGRr_Hy0iEs1>_;@BVr~@j( zzt)@V)2ie#UUXl;_8yvcWFK@=2t!9rM4kTu+Hl@SK=&`A1*%k5^6ZPB`_D{y-cQ9z zZr$L&e|P=5tq%T7>dp13AIr@|7n^_dOnZsErh0W~S_Q5+(UrzF5x)%#cgW1UE^0z~ zgSkGkHnFJp(Vu^249BfmKGt*~_3b6MMU}HM7Wp+D`s+xDI~jWD6aVvuFx>FXA z<0}05tk;FWg@SY#oMZ$CW$x7T=YvE><^>>7|DJ0WmZj7}9o3uK2zPCkFHH{nVI;lj z?S1-TguRS$9%yJ(qtdF{L=Vg|E$br9GIX&3$;=6SntBXtlA>s){Aq;xKI}v{p)Qzr z9~|n>;zQzM?(9PrnI7*cpA%KQc(lP#RsCh>yp+exS!fc`u zolBtz0mZ$9LI@^w7KDYJZ4nK@dW>zQn~oHzVSG!0)HIbkz>7U6Ft^7z=#S|rnfi3Y z?jvM>F*Lkmq)WVbCqTIm6;m-%Pyu8P`kFAXq(MV!~JdxSEcND z<{BZ6Amb)U)d~r&B(Vm9M03p%K`&Izqy4cI5MiRsiqV}__ndZ9av=8J|CyGv0%V0; zc`5#E7JG5}>;aqO#EJ3BpBY2w&L~Z}#aI6u?KmGW_VX*rBQM_J3LxNb3F-tDM)anX z-j5=loaJ>Wgvx72g)ACbN{d)(MAB)|#!K!|8HQx%M&4Xvwztp$s@?PZgyNh58WEpL#c9*KC`QvUJ zh(pV+>$T$`BLLNmhAU-ehEah<&FRP1Hi+!r8;H%Qt0q?aR~yGM5fZXTEA+}i#~gmn zi!c&ZvGrn$$d0=3BD@B^3Dg99buX|cq0ts*rX~%|J%X#*Y>i1;b*AnoG+ue{6|Aia znteg=ggfqyB3Y!RD4|sV9(zt?t;(dc{gx}r{jJ`mBYfIIjnXFsr$RF)pG#(<20dvU zDyit!C{z4yk%F&Jz*`MPJjn%shUy1>mq@G2YXn7U%s0*|2+pGNsHy0B;gvr5lUOTM z_sRLQcSg#enWIvkk9u=%M&*rhr3qtiUIG{s2;f2u&+6$I@v0t{_$swjU7Y7%vXo}s zAm;wnuM-OP;Iy!Ec2F&*n##o3B^Fk#wp_65z#SS3QSxHsTJBzK6YdG5CrIAXT2Be| zxb^oyYP~lFl-po9Bv0PE?%}w_B`cBQ>lzP5)t<3Zq<*}3%7R2hp275di{I>$?)XeL0~~s#6YDRR6Bm`5My+=Y{1j_6}{x6WRk(E$5k0$fyFGlTp6IFCy*EF+=v8HPqbk!a+q;F}oQ zgc9W#qXwNC`cOPC<(UKUK*99K>7nTH;!mgwMW4kwu86g=h&M2MBbg{f-8>*UMzNPZ zJyp0Ny)jf5m*14jId{b1=JtLTeMqO2srL$v3MSU7G8|SL*}A@Y_!OMMJ1B0M)JD5b zS%Fr=u0rML8q7%6R4!;WSdET}Q4%P~!?=Ppg!iPLcWFIJT`6TwY1tCJIZ_$duYL8* zq!{}YeL6$+q`2{S#XwAoIzm^{2GmfPdBq|iuqc<+Dz5~~-#d?>r3pQ9moyBWAr`>8 z2|DD%J3EXbFRQ`B$DJm9QDF9D$E#7zlGFBf$M@;6I}L0ST50Ay{7iZ#DaXl9qQQ5E z{n<$T6^(4hm3k=Sf<(I!I6^m7#pgO8G%Zv@FPE+%BmD8j1WY?W8ZjUj*mD5@iG*UM-6tTtEF+q& z*OqL2=6PpW#v=_~fW4I}QK>>d-XSK{8IWDd#$EwSG^DdhOGioY?PP=aC~DxYmP;Lb zV}kE0#fbSm1AQx}ZUsiTC9ShPCC%7vlfG?;DmZn}2)I6^7%w)&O-B?WgJmVsAgpo? ziCNm1V%Y(8L}t7m&w7sA6SrO+O%}Rwh{_D+TN6aBIsFBLjL&rHiZ~0mKL-4ZD>4Sc zPIT8NApnU-n%518O*L{?2b5r6f;Tif(Yx?ixyV4awa=ek`nn1JwT73AY8>5UFt05u zyi#KvtBd+~f=!^a;8haH$|88(_^vf(ADH^h-Exr~c2qqa( zQ4R>xOjJy98TMX!5D$G`Sgfd$vy*5~DAh%zE-Z~$E8H(ohRuaTBFz9V>G@AnTkS{W zFPE%W6e}@c4P#pzh1Qix7k9sNBT$Zl)v)W6UF&LiEp5OB*CEOQOO5VzjChayizT3k zDG(nBrgFfxZ6KK18x*{{tp$gVd~r~q%}xBJOjrSM(Gqq+y`%hsplA2QE`ZmZ$>>&f zt`~?ZlrCqnD_8Y}vsbt3J<5{$*AD&%UY?C;!st{xVrZzJk(!(eeCJ6h5$#CIYN{0*T=Zl+!{_ zKm6&dlaxB2Gw8f=s!eyE^=Ajn4n(>~FL{^ck2xWoMAvz*QhQY3*IfxAS*u&0<56QK z7-;M1+2qby$;&J~28b4ND$g;6wIrKxbXWar5w96%VR0# zGXAbrLvnv)3%7!MA!xPF+};i1um|Pl|4Ag^@EtvM$|hB#3&;ge=MdZ6FEl^ zWz1ZrNL$%o^t++ zRG`h{N!F1{^ble_TSxzU`tS&3$Wd(a*1?|Y#Isl3n&HtJDCN9euSflSIrWVd2p*#T zJ~^tQNe{Z4iycxr>&MoXSDP054cF%~n-4&p0JKiXa#4He`CZ6t2$$0K&Dx`H@xn#! z6FZsR+WN(-V(ak?LNAC|I`I~}^I)}dz^F}TEPNhT&13QT?QsG(w;-~^Lgo)B&W_I2zF;HV6gc7>Gh{(5^!DD5LsP$DeCwiCDCVd04zy z5TJph{DI4~YeKAjMK*VKL^zwr>*@A=+w?9V;C-{Z{z-6TT{o#KD3zY}ehyIh_H~(k zy&tXn@_Na@W4L?4*ztSVOWXO9>tVI{<@>UC`{$m4|7*W1?1x8IQ4g<2%LXyYy#9gzw=j1FZJDdT!5N017~I!209z*rQ*&w>aP8|Sw; zpxHM+@0xyrF2wT*E=?r^V!_OgJhSJK2iXs8@o}hHrz6<7xfkc4F6pAFxCKR`b8s+h zJ=mFSaua&dgw#6xZCg8nr!-VQ!C)^=j7Ik~h@<1T28aOt=H9J|9%hsg$UDWa6Kil8 z_&Tl)YgQ3P(ogA(*P>C64To()Lq=wp5U zMuWJr?FP`lr%2Y5W!28J||>Z@J%(9=x{kVoyOR2rNLH2=qzkH1e9wp<_c_YljRww z9R3PY&FoZn)^$!eXWv4x_a!oB#j>m-SNXV-|)auuRI zlikwxMxJ@H6FG1%@XAr9VpV-XA5yCKFwHOn;p-fucQ0l+EI#zrxC7`Z-UbO*{CS4a zTc$zB_U+QLc=v7Dyq_$wD|5n#CN65fD6@Y4Gb)fXt^as_+p_w3Tlm1wX5g4`+V&zw zSFQh?Ml*8$^+4CxRCS~q=5NYht@<`?)%25wwd&G$;M@&6#7yxVt+OS6`_dh>Z~kJ( zHRl)n)5dat)gyL@78ewZUQO`{M`lQ;dPQ@OSJ(XghWl}AXbwX!#?f;3%-4T=AVWBP z;1L@P2xt-(=zkN=olRX_EbYvl|1nTDtJ&HE*pa^Y4Za8Fj=8)V=qR*u3S;-l#JUk2 zBY%~OP7n)6f=R7AhJ8NcQO!GL);ee%M9FPUI*;xT@$`Cs53d`@B$pQEg5PSQ!Aa^u z$(~QszI@c<=0bwnD#jqAvSomK7Y042^?lvhrCA!mrJn)|E^+VwrY(<7(8N3|B*r~u zA(DchP^OH9^Qn{@OYpKes7KNv7IB^^Uk{T)D-nVuF%v2}s}90+(xNO7v#gdOpGf>s z{Cx*2T$f*u_(DGq9ih zP#vUFNT@Xg?0AG)>`LJA0dj)$4rk=9?V`AgFJb4J{d(XEG!Ed^13wB#Q2!WHum% z7OaMFfp)k-2nv;v8qBon03(hBwrPqJ!r2VN;K-kzTyA&-d}vKVifM~{VU3;Bc2+11 zgC1JmA_|_b8?iw-(2e)Q55^hXR$`#$gjWakO#QsIAV4C9^Ws4>Nef!m#w@0=j3C0* zVSazyajXs$+M++w=+1zqo84PHHU1;hmx#dXqhUT2q|jPbAYtGcg|H>&1Df-;KFvfb z-ZEd-9&IXTI8mviP#W4*+cJ%-#bh)HLB z9(3-`kvI)mV8nIgZ|C)aqruyj@fM$X!*x!f;iyYpg<_^LVJ4T3T5NX-G{c zOMOshLBu&yBItnOd<;E=JK}tjTsntO6V;CS6%E}XR<;V$$n@MyIPMyi7;wDT+tcK_ zZ5Ae#8Dwgz1HTILGXy{yJ{Klr2GZA%eHPF4!#9XeV5Ax?y!qy2ngeTJ&j;8^6c{C; zN+I-06OCeb(UB#jthRWpL!SznX8)eU!k#>aayxQU=khtap!`y$cY{DjJ89p(?YA(rzkf=M zAA4MMcw@w$iC8AZ-K$qTZowq?eQ8(V7oz_K`QOdDmj^(ku;74zB3yug5dS^vc6RZ! zG5u#Z(w#Hy2Ixc{eda5>?k`g9axi%nk@u-}j=LOgQMerDXv|_075-5xC44Ah^UT+I zTKgPvCkZYn9mosz8@OznG`0K{c$nauD71K3-}jgN-Y(><6VYPQz{%DX;jSemNdkX1 z+|@O`+csigw}IubQN~@;0PHMepYO+gp06kOOjKI>aR5m1t0PJ-+>uxVCqBZx!whO_ zUlMF?WeiIOS+ycVQ<&jku>V(Mv3=?_-rCz9aSJ%E!%y_5bfepFvmsF_f;Nsvg zA`3oEq3nH!#(~VnPmGPEsX{Swk=$devZVGsGTCC9sS_Z76l62zw5GkBjf_qSh>}5x zuA@3qJx~Nke?B10un(+J4}F$U>V>0ls8OssPU1#R-<$>@PZd#TG*f%7S`oZS7!rlG6ePZG|@OE6nuTYVrJt-jfCX002F#ggU`ARzt6naB!R5wM|wDZY6el}k74ub z=qR1ODZ8dO)6aF?v?s6@uV5^%u*6H7Zwtr|D$mw)6ENXiqh9-#MhIy6u`Vv@cDPpg);t)@PKWM#syAQV?4J)z`WJKFBYdqZ+bKX( zFJ$MsfG1ItRn+*n(G5x9%fYn#F)`Rsw%^v@G7~nVzRZT>PiJ4wLd^}AK^Eg} z;ZL6t8}6I+^vb)p`24cRAa`A|4{nrQ-g%cWW`W(~efz+@3y~MKvYu!r*LM-D7SN4! z-6#LZlUXZQE2Q$B$(R`#IAI%fAqZJpMDA#5o1VH`?#)stz~1ny3PsqiEEsCDviMMg z$LmoQx!$QH3VQWpCtZ*v^S$?6gUh$u=iCfG;+zUS;_MDS;;arl;>`9x;w0zJ+)p31 zh&9e6<_v+;ZJNuxc#mD*p4sw^zS*}EAboLcBLMzr2cY&6xO@9P@7pOH`)*#Y1LC^) zH1|7}m1)|Q*=3@(>9eFXiyL=^2J+q)H7YeqMN9si2I0?2x+MbrG;Mto+|2sHeDC!R z$3*@pkc-i|0DC~29uda8?|B`2{{R_x7zjxli57>Hep@(&OR~pO#=^gkr63~D)o_~M zi2}F4oPjbm4em{7e9KCV-%FN2N>xqXT$y*gK^kbFOhG$k@?9$>o=Q+Fw(VEVO3F#I zpN`tlJ15IDbf%AkDf_+9Z}6rL0kPP}SkGF7U6?7(s>~K+(Qk!4->!O}WVQK^n`PEQ z$12Ou(Mx%>2C3VpO5>%^%`H#bN3oFY&-C!H88)R~P zEL3G;MV9atNtP6}^i|-ter*kf-*DPEyTT@18St46U@R|DdNWpBRLnb_X`ei;W{+*l z=hdPcJ=junlNzCMtd=i#rUNXhseDi5x?`NF3cQ6R@{zJ?&eN?Qxe3KdhXNE)T$3Rc zDeOpFh>w#1Xw5w6 zk4bm_PZ~K$1y$tiUZl%Qs`*nQP#z30$c#u*65>QHvq+VAToL%|>km_E%^&k~b}V{Z zz$y;Jd%XJfr- z{W$t4(mczw;MVyz9fYX;Mj0rtKtS~RLbTkiJ6YvxIxou=61snXSTpmc5j zy;pZMu3^^1T#D0A7H9H{|5mF|M3e&9&3n%lg!-1Z&Fm0JTyRw z%FsZC851)F0z#Zx@SDelR^uD+kHp)R8gm|{KTv6!WnoBQUKJl!oNX2^+A0?dbD5Wg zvC^~kn`*ZyLknyX&xn?EIlC#|1v0aFcU;(v)|*J!H}(~jgi}as-j0Q9Q6E0R(iN2h zSUImK78(P99xHut?IXRyHeGmuF@UA?V^kR>M_Ze~LH6LN#~Ls=#lhv}{)gR+C~uN; zFT*%*v!PNiS4Dr)W9!#qjC>hi-S44pDDwN|bzdBo?JevM+BJ^BR*p5+qy58Y&CEeN z57G&fb)^^6$XnlM1C}C=K*UzaI&~DBgfKi3!>s+wgmcw9@dKq%$+e^R*o2WXoCI6n zm*K|IkqCz+c0V=AR`9qDv-wcfz(vL-b=Z9FE#CduYcqUz z-Vrf7m=Sq9X>$5kIJ3CZnWsi`!>c(DBdH#GOBnK5CMeJ>O$s<+u8lTuXk) zTh0WD&-gyWs^e&m@WtjV#^B6zUAH6WpvL3OQ=xJVDN&8fon~ikhR;Y%#0Z+xc!>6! z(qP6-sxfX;XbVLuw4OD%tD>WIO7^$O2 zvCv1}|62I`r;J)OKL!T*AF31OgqxG)@qz6B&QjB7d*Jy0Z*xDU$0&Jo>pEhalBV;vRn1> zSgq4=RK2?+&lLN>VfT5o+n>eQAu+{-u$GnMr7hdmc94O%{<%N*eCP17@tP&jt?Yv} ztjz#<5n7dWkUz|~od6`{kMUS!ow|`~xZ*dP>hqxBLLhD=*4`f8m7=+Ff(+PNj=6i`5B7d{L4=%_(q~IC9ltq%;mXHo9*m#o-OG3e_m*V|XI*)$c%Qogjf3_P?(1rhLHjsL(~CU4=2Y9wNlHl)FA zgK7eCgfyW+O3SVhjI$b#e5y{R4K!~7YtgcbtxC0--wduj>wwlZbwO41;%4$N+@fqA zwEGL*ZdD;=HqSN#?IkX7T@q`2WQJFO+er|Kk;QD&d4L(B)Et zjl}uyJ?%27v#=}QqtMrj(Z|eR14aIB>UB!qTs|8fA1P*8=Jyq|7*XDWD z{xtzdLF$piQ>fK06oGG=29H5ivij#VEvm-k5UnUPsw{?|O{%qOZbB@v`o(P1wdNal zi;rtHkfm>%BlXQse@Q`IHDM&9f-1QVkLMz`ceZ#xx6((JN#>Ub)GdpF6dw2cD-pzJ ztA#5rk9lM5H6ATH9Q;_1nhH0#H`oNEZmiy$-(303easf>Q5iQcWFH%SX|s&et9Af& z*N6`Ak9tFjSvab1v5j5&IBHOnI`C1B@FxKN6Tqy1@7MdWpYPZ6F~MU#^YWUyt!`5X z{zS{qrTQb`b8GhNE8jJO9Nne+)khWH^9+yobI1c*e$?wzjdV+vMFD`9Ex&iOHsaLv zpGH2i*Y1^CyXsq}V2U7~gd5(M!Ms3wW<&Cz3<`L5J80(@x010prJzSS1e`c*)@w}# zxawuiuN1$JzZ*XPP3-;}hDu)k?;eit-(UsWzgH3Nrba*iBO;N#sBiuEUmZX<&jY<8 z<6Wf_XjE#3aHD!71^~$L)8$aIj>M>{tHtY$3g;K2=fv;leR`t%!fs3r%7c+*Q>QvAOe-15#WW6) zp&CKQ(}u><%s%7*8Ri8T>0k zbp?#w7)d2^c}h=?WffwnX0pG@oFNpEqbk#w`M0ZVj8#=q3eR+ceWm2&H~-h}Z7qLN z@@>mUi7U4@9a%r`!(mm~JKJLFZ2=1OpG~4|zZ-ZRC%oOXNo@A? z=kD`P*zZsK$crufmxV;E`v468D&YKy5G?%DfXC*9Bqrsgg2sdHOg`8*+d!c0{nTH4 zC%*Hxwd8b7TXUsEIdoOkif#N4B$&fC_S*P)+}oGzoUtTh>C$$&QwMMSudqw2o4x$` z=B&k&Hz@S#d2CvsA~b7u(5qbv_VM5OrG*-kccrYoxJW^q@k!jSg8KieEjOF1&5^7O=~Hs`7yo%q!Dx%}uIh6N?5#@&Jjz_ep%#xAjv(Do2Trv;gNZzcsoX-!?3l{JM8ZiR<(_m5ujLNT)egc-+`=o44rb zlpOtV(JD>@7OR&P#aI2$c*TDeV2VtBz`-6hVY=wjm#6Fe*;E~kTvNaPs0<0x$*QpH zJyjI6BD~^3OR7;p#RH$}53@f$I51aueGq5IzHYl;i6WQtEhb$uwVJq4{@%=|I_^0S z&J`W`UjO{WW3}JveLdyMztmnI+gs`UXYUXH{1X<9Z}YL{&of)Dz0L-vkB7kXTLghK zvcPHBfTH|@V*TX&B5?8mp5ih(4GRpqo=JiJ%?2W^_kI8HFL@u3Fj<00Cvda+^cfb5 zZ${_7-)0pxYn$|^_qql%j@bSv%Rj$Ue){FK?)<c2x3wr8 zo_;Cx*^i&BSH2xjew?+c@rIZ9>`Sf}S0479{grXnu1jZj?DTw|!^3kmCbGQQwzTNM z9aY1!r|U{iH`{KKkoj{DF289trN3;NT3X>?<+K@#$1YwCb0^b#jDHH9q`U-*qoX##C?2QPV z*GTMB-@j})|AvQHTK?$v|9Wt;T)R|tzKc_xxzDWDqLu7s3j=nm_^hpd zB6={GnSasI6XzRxS#5SNP}0ACROm*;;dR1}CtL(SZCY{7<&40N5BD-Og^r%i-^!!6 zOD*;L{CS%l8E^dIxFAT64?ezfD2}Vy z`c#AYAs3c!%m>bWjf)UU33hGL|9J8GyP}TA*-y+ZqQ68g&wJmh!k-$trf#jx;{<;V zty_skhKvQi^=dO7vd!A6yXw7heW&7)Zgc@!RLE^y0J2w;#3#hCF1&0wa?y zu!V#Ds5)>2ArV*(t_$!+H30QsEhN1RlYvbsq+_JlA< zW(J0X+EDE{7VMy#guZM8Vb*tD9E&*6jXuo2`DWwbp5DxGeS25L$D_U14d1at{t`DMArV* c8%aB~7!B}d1tt&BA#U6Z2Z2THN - \ No newline at end of file diff --git a/.tmp_docx/_rels/.rels b/.tmp_docx/_rels/.rels deleted file mode 100644 index 32548d42..00000000 --- a/.tmp_docx/_rels/.rels +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.tmp_docx/docProps/app.xml b/.tmp_docx/docProps/app.xml deleted file mode 100644 index 94823e96..00000000 --- a/.tmp_docx/docProps/app.xml +++ /dev/null @@ -1,2 +0,0 @@ - -27180410284Microsoft Office Word08524falsefalse12064falsefalse16.0000 \ No newline at end of file diff --git a/.tmp_docx/docProps/core.xml b/.tmp_docx/docProps/core.xml deleted file mode 100644 index 0bc997b6..00000000 --- a/.tmp_docx/docProps/core.xml +++ /dev/null @@ -1,2 +0,0 @@ - -ZlataZlata22026-05-16T12:44:00Z2026-05-16T12:46:00Z \ No newline at end of file diff --git a/.tmp_docx/word/_rels/document.xml.rels b/.tmp_docx/word/_rels/document.xml.rels deleted file mode 100644 index 3b2b7f8c..00000000 --- a/.tmp_docx/word/_rels/document.xml.rels +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.tmp_docx/word/document.xml b/.tmp_docx/word/document.xml deleted file mode 100644 index 94363c72..00000000 --- a/.tmp_docx/word/document.xml +++ /dev/null @@ -1,2 +0,0 @@ - -Назначение слояDomain-слой является центральным и наиболее стабильным слоем приложения. Он содержит бизнес-логику, не зависящую от деталей реализации пользовательского интерфейса, источников данных и внешних фреймворков. Слой спроектирован в соответствии с принципами чистой архитектуры: все зависимости направлены внутрь, а доменные сущности и сценарии использования не имеют ссылок на внешние слои.Структура слояDomain-слой организован в соответствии с принципом разделения ответственности и включает следующие пакеты:- model — доменные сущности и типы данных;- repository — интерфейсы репозиториев, представляющие абстракции доступа к данным;- ai — интерфейс сервиса искусственного интеллекта;- usecase — сценарии использования, реализующие бизнес-операции.Модели данных (пакет model)NoteЦентральная сущность приложения, представляющая заметку. Содержит следующие поля:- id — уникальный идентификатор заметки, генерируется как UUID;- title — заголовок заметки;- folderId — идентификатор родительской директории, может быть null для заметок без папки;- contentItems — список элементов содержимого: текст, изображения, файлы и ссылки;- createdAt и updatedAt — временные метки создания и последнего изменения, представлены типом Instant из kotlin.time;- tags — множество тегов для категоризации заметок;- isFavorite — флаг избранного;- summary — краткое содержание, сгенерированное ИИ, может быть null, если саммари ещё не создавалось.Все поля, кроме id, title и contentItems, имеют значения по умолчанию, что упрощает создание новых заметок. Класс является иммутабельным (data class), любые изменения выполняются через метод copy. NoteFolderМодель директории для организации заметок. Директории образуют плоскую структуру без вложенности. Поля:- id — уникальный идентификатор директории (UUID);- name — название директории;- createdAt и updatedAt — временные метки создания и изменения;- metadata — словарь дополнительных параметров, зарезервированный для будущих расширений.ContentItemИерархия sealed-классов, описывающая различные типы содержимого заметки. Каждый элемент имеет уникальный идентификатор id, генерируемый как UUID.Типы элементов содержимого:ContentItem.Text — текстовый блок заметки. Содержит поля: text (строка текста) и format (формат текста: PLAIN, MARKDOWN или HTML). По умолчанию используется PLAIN.ContentItem.Image — изображение. Содержит поля: source типа DataSource (путь к файлу), mimeType (MIME-тип изображения), а также опциональные width и height (размеры в пикселях).ContentItem.File — вложенный файл. Содержит поля: source типа DataSource, mimeType, name (имя файла) и опциональный size (размер в байтах).ContentItem.Link — ссылка. Содержит поля: url (адрес ссылки) и опциональный title (заголовок ссылки).DataSourceВспомогательная модель для описания источника данных вложения. Содержит поля:- localPath — путь к файлу на устройстве, может быть null;- remoteUrl — URL удалённого ресурса, может быть null.Вычисляемое свойство displayPath возвращает первый доступный путь: localPath, если он задан, иначе remoteUrl. Это упрощает отображение в пользовательском интерфейсе. TextFormatПеречисление форматов текста со следующими значениями:- PLAIN — обычный текст;- MARKDOWN — текст с разметкой Markdown;- HTML — текст с разметкой HTML.Перечисление закладывает основу для будущей поддержки форматированного текста.Интерфейсы репозиториев (пакет repository)Репозитории определяют контракты доступа к данным, не зависящие от конкретного источника. Реализации находятся в data-слое. NotesRepositoryИнтерфейс репозитория заметок. Предоставляет следующие методы:- observeNotes() — возвращает Flow со списком всех заметок, реактивно обновляется при изменениях;- observeNotesByFolder(folderId) — возвращает Flow со списком заметок, отфильтрованных по идентификатору директории;- getNoteById(id) — suspend-функция, возвращает заметку по идентификатору или null;- createNote(note) — suspend-функция, создаёт новую заметку и возвращает её идентификатор;- updateNote(note) — suspend-функция, обновляет существующую заметку;- deleteNote(id) — suspend-функция, удаляет заметку по идентификатору.Операции observeNotes и observeNotesByFolder возвращают реактивные потоки (Flow), что обеспечивает автоматическое обновление UI при изменении данных. NoteFolderRepositoryИнтерфейс репозитория директорий. Предоставляет следующие методы:- observeFolders() — возвращает Flow со списком всех директорий;- createFolder(folder) — создаёт новую директорию, возвращает её идентификатор;- renameFolder(id, name) — переименовывает директорию;- deleteFolder(id) — удаляет директорию по идентификатору;- getFolderById(id) — возвращает директорию по идентификатору или null;- updateFolder(folder) — обновляет данные директории.Интерфейс ИИ-сервиса (пакет ai)Интерфейс NoteAiService абстрагирует работу с нейросетевыми моделями. Реализация в data-слое использует OpenVINO для локального выполнения моделей. Такое разделение позволяет заменять модели без изменения бизнес-логики.Методы интерфейса:- summarize(text) — принимает текст заметки, возвращает строку с кратким содержанием;- tagTXT(text) — принимает текст заметки, возвращает множество тегов, извлечённых из текста;- tagIMGs(img) — принимает список путей к изображениям (локальных или URL), возвращает множество тегов, извлечённых из изображений с помощью компьютерного зрения.Все методы являются suspend-функциями, поскольку выполнение нейросетевых моделей может занимать продолжительное время.Сценарии использования (пакет usecase)Каждый сценарий использования (Use Case) реализует ровно одну бизнес-операцию. Классы используют паттерн «функциональный объект» с методом invoke, что позволяет вызывать их подобно функциям. Сценарии использования являются единственной точкой входа в доменную логику для внешних слоёв.Операции с заметками (пакет noteusecase)CreateNoteUseCase — создание заметки. Проверяет уникальность заголовка в рамках директории, нормализует название (удаляет лишние пробелы), генерирует UUID и временные метки.GetNoteUseCase — получение заметки по идентификатору. Делегирует вызов репозиторию.UpdateNoteUseCase — обновление заметки. Проверяет уникальность заголовка (исключая саму обновляемую заметку), обновляет временную метку.DeleteNoteUseCase — удаление заметки по идентификатору.DuplicateNoteUseCase — создание копии заметки. Генерирует новые идентификаторы для заметки и всех её элементов содержимого, добавляет суффикс «Copy» к заголовку. Если исходный заголовок пуст, используется заголовок «Copy». Сохраняет теги, флаг избранного и краткое содержание.ObserveNotesUseCase — реактивное наблюдение за всеми заметками. Возвращает Flow из репозитория.ObserveNotesByFolderUseCase — фильтрация заметок по директории. Если folderId равен null, возвращаются все заметки.SearchNotesUseCase — поиск заметок по запросу. Проверяет совпадение в заголовке и текстовом содержимом без учёта регистра. Если запрос пуст, возвращаются все заметки.GetNotesByTagUseCase — фильтрация заметок по тегу. Сравнение выполняется без учёта регистра. Если тег пуст, возвращаются все заметки.GetAllFavoritesUseCase — получение избранных заметок. Фильтрует заметки по флагу isFavorite.SwitchFavoriteUseCase — переключение флага избранного. Инвертирует текущее значение isFavorite.MoveNoteToFolderUseCase — перемещение заметки в другую директорию. Проверяет существование целевой директории и заметки.AddTagUseCase — добавление тега к заметке. Нормализует тег (удаляет пробелы по краям). Если тег уже существует, он не дублируется, поскольку используется Set.DeleteTagUseCase — удаление тега из заметки. Нормализует тег перед удалением.ApplySummaryUseCase — сохранение сгенерированного краткого содержания в заметку. Обновляет временную метку.ApplyTagsUseCase — сохранение набора тегов в заметку. Нормализует все теги (тримминг, фильтрация пустых). Обновляет временную метку.Операции с директориями (пакет folderusecase)CreateFolderUseCase — создание директории. Проверяет уникальность имени (без учёта регистра), нормализует название, генерирует UUID и временные метки.GetFolderUseCase — получение директории по идентификатору.UpdateFolderUseCase — обновление директории. Проверяет уникальность имени (исключая саму обновляемую директорию), нормализует название.DeleteFolderUseCase — удаление директории. Защищает системную директорию «all» от удаления. Перед удалением директории каскадно удаляет все заметки, привязанные к ней.ObserveFoldersUseCase — реактивное наблюдение за списком директорий.Операции с содержимым (пакет contentusecase)CreateContentItemUseCase — создание элемента содержимого. Присваивает новый UUID элементу в зависимости от его типа (Text, Image, File, Link).AddContentItemUseCase — добавление элемента в заметку. Проверяет, что заметка существует и что элемент с таким идентификатором ещё не добавлен (защита от дубликатов).GetContentItemUseCase — получение элемента содержимого по идентификатору заметки и идентификатору элемента.DeleteContentItemUseCase — удаление элемента из заметки. Если элемент с указанным идентификатором не найден, операция завершается без ошибок.Интеллектуальные операции (пакет aiusecase)SuggestSummaryUseCase — генерация краткого содержания заметки. Извлекает весь текст из элементов ContentItem.Text, объединяя их через перевод строки, и отправляет в AI-сервис. Если заметка не найдена, возвращает ошибку.SuggestTagsUseCase — генерация тегов для заметки. Извлекает текст из элементов ContentItem.Text и пути к изображениям из элементов ContentItem.Image (используя localPath или remoteUrl). Отправляет текст и изображения в соответствующие методы AI-сервиса. Результаты тегов от текстовой модели и модели компьютерного зрения объединяются оператором union множеств. Вспомогательные функцииФункция requireNotBlank является внутренней (internal) и используется для валидации строковых параметров во всех сценариях использования. Принимает значение и название поля. Выбрасывает IllegalArgumentException с сообщением вида «fieldName must not be blank», если значение пустое или состоит из одних пробелов.Принципы обработки ошибокВсе сценарии использования, выполняющие операции с возможными отказами, возвращают Result. Успешный результат возвращается как Result.success(value), ошибка — как Result.failure(exception) с информативным сообщением.Исключения выбрасываются в следующих случаях:- сущность не найдена — IllegalArgumentException с указанием идентификатора;- нарушение уникальности — дубликат названия заметки или папки;- некорректные входные данные — пустые строки после нормализации;- попытка удаления или переименования системной директории «all».Принципы реактивностиДля наблюдения за изменениями данных используются реактивные потоки Kotlin Flow. Это позволяет UI-слою автоматически получать обновления при изменении данных без явных колбэков. Методы observeNotes, observeNotesByFolder и observeFolders возвращают Flow, который эмитирует новое значение при каждом изменении в репозитории. Доменные модели являются иммутабельными, что гарантирует потокобезопасность и предсказуемость при работе с реактивными потоками.ТестированиеВсе сценарии использования покрыты модульными тестами с использованием fake-реализаций репозиториев и AI-сервиса. Тесты проверяют:- успешные сценарии: создание, обновление, удаление сущностей;- обработку граничных случаев: пустые строки, дубликаты идентификаторов и названий;- корректность передачи данных между слоями;- иммутабельность доменных моделей: оригинальные объекты не изменяются при операциях;- каскадное удаление: при удалении директории удаляются все связанные заметки;- нормализацию данных: теги и названия обрезаются от пробелов.Тестовые fake-объекты (FakeNotesRepo, FakeFolderRepo, FakeNoteAiService) эмулируют поведение реальных репозиториев в памяти, что позволяет тестировать бизнес-логику изолированно от внешних зависимостей. Fake-репозитории хранят данные в HashMap и обновляют MutableStateFlow при каждом изменении, полностью имитируя реактивное поведение реальных реализаций. \ No newline at end of file diff --git a/.tmp_docx/word/fontTable.xml b/.tmp_docx/word/fontTable.xml deleted file mode 100644 index 65e83160..00000000 --- a/.tmp_docx/word/fontTable.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.tmp_docx/word/settings.xml b/.tmp_docx/word/settings.xml deleted file mode 100644 index 0075fbdc..00000000 --- a/.tmp_docx/word/settings.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.tmp_docx/word/styles.xml b/.tmp_docx/word/styles.xml deleted file mode 100644 index 2f8b3a4e..00000000 --- a/.tmp_docx/word/styles.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.tmp_docx/word/theme/theme1.xml b/.tmp_docx/word/theme/theme1.xml deleted file mode 100644 index 88a9084c..00000000 --- a/.tmp_docx/word/theme/theme1.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/.tmp_docx/word/webSettings.xml b/.tmp_docx/word/webSettings.xml deleted file mode 100644 index 67b79831..00000000 --- a/.tmp_docx/word/webSettings.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/java/com/itlab/AppModule.kt b/app/src/main/java/com/itlab/AppModule.kt index e38bab55..223d6442 100644 --- a/app/src/main/java/com/itlab/AppModule.kt +++ b/app/src/main/java/com/itlab/AppModule.kt @@ -34,17 +34,18 @@ val appModule = single { OnboardingPreferences(androidApplication()) } single { AppSessionPreferences(androidApplication()) } factory { ValidateDuplicateNoteTitleUseCase(get()) } - factory { CreateNoteUseCase(get(), get()) } + factory { CreateNoteUseCase(get()) } factory { CreateFolderUseCase(get()) } factory { DeleteFolderUseCase(get(), get()) } factory { DeleteNoteUseCase(get()) } - factory { UpdateNoteUseCase(get(), get()) } + factory { UpdateNoteUseCase(get()) } factory { UpdateFolderUseCase(get()) } factory { GetFolderUseCase(get()) } factory { ObserveNotesByFolderUseCase(get()) } factory { ObserveFoldersUseCase(get()) } factory { MoveNoteToFolderUseCase(get(), get()) } factory { ObserveNotesUseCase(get()) } + factory { GetUserIdUseCase(get()) } factory { SearchNotesUseCase(get()) } factory { SwitchFavoriteUseCase(get()) } factory { GetAllFavoritesUseCase(get()) } @@ -72,6 +73,7 @@ val appModule = getFolderUseCase = get(), moveNoteToFolderUseCase = get(), observeNotesUseCase = get(), + getUserIdUseCase = get(), searchNotesUseCase = get(), switchFavoriteUseCase = get(), getAllFavoritesUseCase = get(), diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt index 0e7a681f..c4bd321b 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt @@ -9,6 +9,7 @@ import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.GetAllFavoritesUseCase import com.itlab.domain.usecase.noteusecase.GetNoteUseCase +import com.itlab.domain.usecase.noteusecase.GetUserIdUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesByFolderUseCase import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase @@ -28,6 +29,7 @@ data class NotesUseCases( val getFolderUseCase: GetFolderUseCase, val moveNoteToFolderUseCase: MoveNoteToFolderUseCase, val observeNotesUseCase: ObserveNotesUseCase, + val getUserIdUseCase: GetUserIdUseCase, val searchNotesUseCase: SearchNotesUseCase, val switchFavoriteUseCase: SwitchFavoriteUseCase, val getAllFavoritesUseCase: GetAllFavoritesUseCase, diff --git a/data/src/main/java/com/itlab/data/cloud/AuthManager.kt b/data/src/main/java/com/itlab/data/cloud/AuthManager.kt index 0fb4c6a0..29a0a96c 100644 --- a/data/src/main/java/com/itlab/data/cloud/AuthManager.kt +++ b/data/src/main/java/com/itlab/data/cloud/AuthManager.kt @@ -1,8 +1,10 @@ package com.itlab.data.cloud +import android.content.Context import android.content.Intent import com.firebase.ui.auth.AuthUI import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.tasks.await class AuthManager( private val auth: FirebaseAuth, @@ -21,7 +23,7 @@ class AuthManager( fun getCurrentUserId(): String? = auth.currentUser?.uid - fun signOut() { - auth.signOut() + suspend fun signOut(context: Context) { + AuthUI.getInstance().signOut(context).await() } } diff --git a/data/src/main/java/com/itlab/data/dao/NoteDao.kt b/data/src/main/java/com/itlab/data/dao/NoteDao.kt index 6461d316..3da3c37c 100644 --- a/data/src/main/java/com/itlab/data/dao/NoteDao.kt +++ b/data/src/main/java/com/itlab/data/dao/NoteDao.kt @@ -9,7 +9,6 @@ import androidx.room.Update import com.itlab.data.entity.NoteEntity import kotlinx.coroutines.flow.Flow -@Suppress("TooManyFunctions") @Dao interface NoteDao { @Query("SELECT * FROM notes WHERE isDeleted = 0 ORDER BY updatedAt DESC") @@ -30,9 +29,6 @@ interface NoteDao { @Query("DELETE FROM notes WHERE id = :id") suspend fun hardDeleteById(id: String) - @Query("DELETE FROM notes") - suspend fun deleteAll() - @Insert suspend fun insert(note: NoteEntity) diff --git a/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt index 80465528..36f9de35 100644 --- a/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/itlab/data/repository/AuthRepositoryImpl.kt @@ -7,9 +7,4 @@ class AuthRepositoryImpl( private val authManager: AuthManager, ) : AuthRepository { override fun getCurrentUserId(): String? = authManager.getCurrentUserId() - - override suspend fun signOut(): Result = - runCatching { - authManager.signOut() - } } diff --git a/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt b/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt index 8e5ca22f..ab5e0a93 100644 --- a/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt +++ b/data/src/test/java/com/itlab/data/cloud/AuthManagerTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthUI +import com.google.android.gms.tasks.Tasks import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth @@ -17,6 +18,7 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.unmockkConstructor import io.mockk.verify +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -80,10 +82,19 @@ class AuthManagerTest { } @Test - fun `signOut should call FirebaseAuth signOut`() { - authManager.signOut() - verify { auth.signOut() } - } + fun `signOut should call AuthUI signOut`() = + runBlocking { + val authUI = mockk() + + val mockTask = Tasks.forResult(null) + + every { AuthUI.getInstance() } returns authUI + every { authUI.signOut(any()) } returns mockTask + + authManager.signOut(context) + + verify { authUI.signOut(context) } + } @Test fun `getSignInIntent should return intent from builder`() { diff --git a/data/src/test/java/com/itlab/data/di/DataModuleTest.kt b/data/src/test/java/com/itlab/data/di/DataModuleTest.kt index bd29aa45..e24e3fc8 100644 --- a/data/src/test/java/com/itlab/data/di/DataModuleTest.kt +++ b/data/src/test/java/com/itlab/data/di/DataModuleTest.kt @@ -2,7 +2,6 @@ package com.itlab.data.di import android.content.Context import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.storage.FirebaseStorage import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -29,8 +28,6 @@ class DataModuleTest : KoinTest { fun `verify dataModule dependencies`() { mockkStatic(FirebaseAuth::class) every { FirebaseAuth.getInstance() } returns mockk(relaxed = true) - mockkStatic(FirebaseStorage::class) - every { FirebaseStorage.getInstance() } returns mockk(relaxed = true) koinApplication { androidContext(mockk(relaxed = true)) diff --git a/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt b/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt index 57f60f14..4ceb06b2 100644 --- a/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt +++ b/data/src/test/java/com/itlab/data/repository/AuthRepositoryImplTest.kt @@ -5,10 +5,8 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify -import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -44,15 +42,4 @@ class AuthRepositoryImplTest { assertNull(result) verify(exactly = 1) { authManager.getCurrentUserId() } } - - @Test - fun `signOut should delegate to authManager`() = - runTest { - every { authManager.signOut() } returns Unit - - val result = authRepository.signOut() - - assertTrue(result.isSuccess) - verify(exactly = 1) { authManager.signOut() } - } } diff --git a/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt b/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt index db299e30..ef5cf772 100644 --- a/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/itlab/domain/repository/AuthRepository.kt @@ -2,6 +2,4 @@ package com.itlab.domain.repository interface AuthRepository { fun getCurrentUserId(): String? - - suspend fun signOut(): Result } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt index 8d62d669..1fa9779e 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/CreateNoteUseCase.kt @@ -2,28 +2,30 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.Note import com.itlab.domain.repository.NotesRepository +import kotlinx.coroutines.flow.first +import java.util.UUID import kotlin.time.Clock class CreateNoteUseCase( private val repo: NotesRepository, - private val validateDuplicateNoteTitle: ValidateDuplicateNoteTitleUseCase, ) { suspend operator fun invoke(note: Note): Result = runCatching { val normalizedTitle = note.title.trim() val hasDuplicateTitle = - validateDuplicateNoteTitle( - title = normalizedTitle, - folderId = note.folderId, - ) + repo.observeNotes().first().any { existing -> + existing.folderId == note.folderId && + existing.title.trim().equals(normalizedTitle, ignoreCase = true) + } require(!hasDuplicateTitle) { "Note with title '$normalizedTitle' already exists in this folder" } val now = Clock.System.now() - val noteToPersist = + + val note = note.copy( - title = normalizedTitle, + id = UUID.randomUUID().toString(), + createdAt = now, updatedAt = now, ) - repo.createNote(noteToPersist) - noteToPersist.id + repo.createNote(note) } } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt index 3158bf84..fd3795b8 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/DuplicateNoteUseCase.kt @@ -2,7 +2,6 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.ContentItem import com.itlab.domain.repository.NotesRepository -import kotlinx.coroutines.flow.first import java.util.UUID import kotlin.time.Clock @@ -15,21 +14,11 @@ class DuplicateNoteUseCase( repo.getNoteById(noteId) ?: throw IllegalArgumentException("Note not found: $noteId") - val folderId = note.folderId - val existingTitles = - if (folderId != null) { - repo.observeNotesByFolder(folderId).first().map { it.title } - } else { - repo.observeNotes().first().map { it.title } - } - val baseTitle = note.title.trim().ifBlank { "Copy" } - val uniqueTitle = resolveUniqueNoteTitle(baseTitle, existingTitles) - val now = Clock.System.now() val duplicated = note.copy( id = UUID.randomUUID().toString(), - title = uniqueTitle, + title = if (note.title.isBlank()) "Copy" else "${note.title} Copy", createdAt = now, updatedAt = now, contentItems = diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt index f2ca381f..9c264586 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/MoveNoteToFolderUseCase.kt @@ -3,7 +3,6 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.repository.NoteFolderRepository import com.itlab.domain.repository.NotesRepository import com.itlab.domain.usecase.requireNotBlank -import kotlinx.coroutines.flow.first import kotlin.time.Clock class MoveNoteToFolderUseCase( @@ -19,19 +18,7 @@ class MoveNoteToFolderUseCase( requireNotBlank(folderId, "Folder id") requireNotNull(folderRepo.getFolderById(folderId)) { "Folder not found: $folderId" } val note = notesRepo.getNoteById(noteId) ?: throw IllegalArgumentException("Note not found: $noteId") - val titlesInTargetFolder = - notesRepo - .observeNotesByFolder(folderId) - .first() - .filter { it.id != noteId } - .map { it.title } - val uniqueTitle = resolveUniqueNoteTitle(note.title, titlesInTargetFolder) - val updated = - note.copy( - folderId = folderId, - title = uniqueTitle, - updatedAt = Clock.System.now(), - ) + val updated = note.copy(folderId = folderId, updatedAt = Clock.System.now()) notesRepo.updateNote(updated) } } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt deleted file mode 100644 index 8fb8ec0f..00000000 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ResolveUniqueNoteTitle.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.itlab.domain.usecase.noteusecase - -/** - * Picks a title that does not collide with [existingTitles] in the same folder (case-insensitive). - * If [desiredTitle] is taken, returns `"$base (1)"`, `"$base (2)"`, … - */ -@Suppress("ReturnCount") -fun resolveUniqueNoteTitle( - desiredTitle: String, - existingTitles: Iterable, -): String { - val base = desiredTitle.trim().ifBlank { return resolveUniqueNoteTitle("Untitled", existingTitles) } - val taken = - existingTitles - .map { it.trim() } - .filter { it.isNotEmpty() } - .toSet() - - fun isTaken(title: String): Boolean = taken.any { it.equals(title, ignoreCase = true) } - - if (!isTaken(base)) return base - - var index = 1 - while (isTaken("$base ($index)")) { - index++ - } - return "$base ($index)" -} diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt index 1d08d0a9..8d487671 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/SearchNotesUseCase.kt @@ -9,9 +9,6 @@ import kotlinx.coroutines.flow.map class SearchNotesUseCase( private val repo: NotesRepository, ) { - /** - * @param folderId when set, limits results to notes in that folder (for search inside a directory). - */ operator fun invoke( query: String, folderId: String? = null, @@ -20,13 +17,9 @@ class SearchNotesUseCase( if (normalizedQuery.isBlank()) return repo.observeNotes() return repo.observeNotes().map { notes -> - val scoped = - if (folderId == null) { - notes - } else { - notes.filter { it.folderId == folderId } - } - scoped.filter { note -> note.matches(normalizedQuery) } + notes + .filter { note -> folderId == null || note.folderId == folderId } + .filter { note -> note.matches(normalizedQuery) } } } diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt index 1cf1808b..508ef2fd 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/UpdateNoteUseCase.kt @@ -2,21 +2,21 @@ package com.itlab.domain.usecase.noteusecase import com.itlab.domain.model.Note import com.itlab.domain.repository.NotesRepository +import kotlinx.coroutines.flow.first import kotlin.time.Clock class UpdateNoteUseCase( private val repo: NotesRepository, - private val validateDuplicateNoteTitle: ValidateDuplicateNoteTitleUseCase, ) { suspend operator fun invoke(note: Note): Result = runCatching { val normalizedTitle = note.title.trim() val hasDuplicateTitle = - validateDuplicateNoteTitle( - title = normalizedTitle, - folderId = note.folderId, - excludeNoteId = note.id, - ) + repo.observeNotes().first().any { existing -> + existing.id != note.id && + existing.folderId == note.folderId && + existing.title.trim().equals(normalizedTitle, ignoreCase = true) + } require(!hasDuplicateTitle) { "Note with title '$normalizedTitle' already exists in this folder" } val note = note.copy(updatedAt = Clock.System.now()) repo.updateNote(note) diff --git a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt index b1abeb9f..8b3d5766 100644 --- a/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt +++ b/domain/src/main/java/com/itlab/domain/usecase/noteusecase/ValidateDuplicateNoteTitleUseCase.kt @@ -6,18 +6,16 @@ import kotlinx.coroutines.flow.first class ValidateDuplicateNoteTitleUseCase( private val repo: NotesRepository, ) { - /** - * @return `true` if another note in the same folder already has this title. - */ + /** @return true when another note in the same folder already has this title. */ suspend operator fun invoke( title: String, folderId: String?, - excludeNoteId: String? = null, + excludeNoteId: String, ): Boolean { val normalizedTitle = title.trim() - if (normalizedTitle.isBlank()) return false + if (normalizedTitle.isEmpty()) return false return repo.observeNotes().first().any { existing -> - (excludeNoteId == null || existing.id != excludeNoteId) && + existing.id != excludeNoteId && existing.folderId == folderId && existing.title.trim().equals(normalizedTitle, ignoreCase = true) } diff --git a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt index 20144e99..cda4095c 100644 --- a/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt +++ b/domain/src/test/java/com/itlab/domain/NoteUseCasesTest.kt @@ -18,10 +18,8 @@ import com.itlab.domain.usecase.noteusecase.ObserveNotesUseCase import com.itlab.domain.usecase.noteusecase.SearchNotesUseCase import com.itlab.domain.usecase.noteusecase.SwitchFavoriteUseCase import com.itlab.domain.usecase.noteusecase.UpdateNoteUseCase -import com.itlab.domain.usecase.noteusecase.ValidateDuplicateNoteTitleUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -36,8 +34,7 @@ class NoteUseCasesTest { override fun observeNotes() = flow - override fun observeNotesByFolder(folderId: String) = - flow.map { notes -> notes.filter { it.folderId == folderId } } + override fun observeNotesByFolder(folderId: String) = flow override suspend fun getNoteById(id: String): Note? = store[id] @@ -85,9 +82,8 @@ class NoteUseCasesTest { runBlocking { val repo = FakeNotesRepo() - val validateTitle = ValidateDuplicateNoteTitleUseCase(repo) - val create = CreateNoteUseCase(repo, validateTitle) - val update = UpdateNoteUseCase(repo, validateTitle) + val create = CreateNoteUseCase(repo) + val update = UpdateNoteUseCase(repo) val delete = DeleteNoteUseCase(repo) val get = GetNoteUseCase(repo) @@ -116,7 +112,7 @@ class NoteUseCasesTest { val folderRepo = FakeFolderRepo() val move = MoveNoteToFolderUseCase(notesRepo, folderRepo) - val createNote = CreateNoteUseCase(notesRepo, ValidateDuplicateNoteTitleUseCase(notesRepo)) + val createNote = CreateNoteUseCase(notesRepo) val folder = NoteFolder(id = "f1", name = "Folder") folderRepo.createFolder(folder) @@ -136,7 +132,7 @@ class NoteUseCasesTest { runBlocking { val repo = FakeNotesRepo() val observe = ObserveNotesUseCase(repo) - val create = CreateNoteUseCase(repo, ValidateDuplicateNoteTitleUseCase(repo)) + val create = CreateNoteUseCase(repo) create(Note(id = "n1", title = "Test", userId = testUserId)).getOrThrow() @@ -233,7 +229,7 @@ class NoteUseCasesTest { val duplicated = repo.getNoteById(newId) assertEquals(true, duplicated != null) - assertEquals("Hello (1)", duplicated?.title) + assertEquals("Hello Copy", duplicated?.title) assertEquals(setOf("kotlin"), duplicated?.tags) assertEquals(true, duplicated?.isFavorite) assertEquals("summary", duplicated?.summary) @@ -262,26 +258,6 @@ class NoteUseCasesTest { assertEquals("Copy", duplicated?.title) } - @Test - fun moveNoteToFolder_renamesWhenTitleExistsInTargetFolder() = - runBlocking { - val notesRepo = FakeNotesRepo() - val folderRepo = FakeFolderRepo() - - val move = MoveNoteToFolderUseCase(notesRepo, folderRepo) - folderRepo.createFolder(NoteFolder(id = "f1", name = "One")) - folderRepo.createFolder(NoteFolder(id = "f2", name = "Two")) - - notesRepo.createNote(Note(id = "n1", title = "Report", folderId = "f1")) - notesRepo.createNote(Note(id = "n2", title = "Report", folderId = "f2")) - - move("f2", "n1").getOrThrow() - - val moved = notesRepo.getNoteById("n1") - assertEquals("f2", moved?.folderId) - assertEquals("Report (1)", moved?.title) - } - @Test fun duplicateNote_throwsIfNoteNotFound() = runBlocking { @@ -400,33 +376,6 @@ class NoteUseCasesTest { assertEquals("n9", result.first().id) } - @Test - fun searchNotes_scopedToFolderId() = - runBlocking { - val repo = FakeNotesRepo() - val useCase = SearchNotesUseCase(repo) - - repo.createNote( - Note( - id = "n1", - folderId = "folder-a", - title = "Молоко в папке A", - ), - ) - repo.createNote( - Note( - id = "n2", - folderId = "folder-b", - title = "Молоко в папке B", - ), - ) - - val result = useCase("молоко", folderId = "folder-a").first() - - assertEquals(1, result.size) - assertEquals("n1", result.first().id) - } - @Test fun addTag_trimsIncomingTag() = runBlocking { diff --git a/domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt b/domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt deleted file mode 100644 index fe563b71..00000000 --- a/domain/src/test/java/com/itlab/domain/ResolveUniqueNoteTitleTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.itlab.domain - -import com.itlab.domain.usecase.noteusecase.resolveUniqueNoteTitle -import org.junit.Assert.assertEquals -import org.junit.Test - -class ResolveUniqueNoteTitleTest { - @Test - fun returnsDesiredTitleWhenNoConflict() { - assertEquals( - "Meeting", - resolveUniqueNoteTitle("Meeting", listOf("Other")), - ) - } - - @Test - fun appendsIncrementingSuffixWhenBaseTaken() { - assertEquals( - "Meeting (1)", - resolveUniqueNoteTitle("Meeting", listOf("Meeting")), - ) - assertEquals( - "Meeting (2)", - resolveUniqueNoteTitle("Meeting", listOf("Meeting", "Meeting (1)")), - ) - } - - @Test - fun isCaseInsensitive() { - assertEquals( - "Meeting (1)", - resolveUniqueNoteTitle("Meeting", listOf("meeting")), - ) - } - - @Test - fun blankTitleUsesUntitled() { - assertEquals( - "Untitled", - resolveUniqueNoteTitle(" ", emptyList()), - ) - assertEquals( - "Untitled (1)", - resolveUniqueNoteTitle("", listOf("Untitled")), - ) - } -} diff --git a/domain/src/test/java/com/itlab/domain/SearchNotesUseCaseTest.kt b/domain/src/test/java/com/itlab/domain/SearchNotesUseCaseTest.kt index 12645466..5e38349f 100644 --- a/domain/src/test/java/com/itlab/domain/SearchNotesUseCaseTest.kt +++ b/domain/src/test/java/com/itlab/domain/SearchNotesUseCaseTest.kt @@ -79,6 +79,37 @@ class SearchNotesUseCaseTest { assertEquals(0, result.size) } + @Test + fun `invoke should filter by folder when folderId is set`() = + runBlocking { + val now = Instant.fromEpochMilliseconds(System.currentTimeMillis()) + val notes = + listOf( + Note( + userId = "u1", + id = "1", + title = "Shopping List", + folderId = "folder_a", + createdAt = now, + updatedAt = now, + ), + Note( + userId = "u1", + id = "2", + title = "Shopping budget", + folderId = "folder_b", + createdAt = now, + updatedAt = now, + ), + ) + coEvery { repo.observeNotes() } returns flowOf(notes) + + val result = searchNotesUseCase("shop", folderId = "folder_a").first() + + assertEquals(1, result.size) + assertEquals("1", result[0].id) + } + @Test fun `invoke should cover all branches of content matching`() = runBlocking { From e34b91d49d3182ee584482ba3fd5b44c16efc0d9 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 17:21:09 +0300 Subject: [PATCH 33/35] fix: restore gitignore --- .gitignore | Bin 292 -> 232 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index 6c2ee243484af8128b6aa2a2cafe0f7616d6304d..5c915a534520097fa3c5a225be11833c5e6a6c0f 100644 GIT binary patch literal 232 zcmZXPu@1s83`F;Q3WNI(2v(L3jL1!_V&Nu{h=W-%2)RL$ zM}e#3zvA5K-72#Ydl*qIT6mF|k4}Pry#ER?Op|y2VZK?sYa!KY YL4jclxuU@c0-;VB5bhNdbl}0k2ipN!e*gdg From 809fe893dbbb7958a9f30e9052bd9f5f4ebd84f1 Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 17:38:19 +0300 Subject: [PATCH 34/35] fix: generate lockfiles --- app/gradle.lockfile | 46 ++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index c7357aaa..e542b76a 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -19,7 +19,7 @@ androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndro androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.asynclayoutinflater:asynclayoutinflater:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.browser:browser:1.4.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.browser:browser:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.cardview:cardview:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -122,28 +122,28 @@ androidx.core:core-ktx:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestL androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.17.0=releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.credentials:credentials-play-services-auth:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.credentials:credentials:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.credentials:credentials-play-services-auth:1.2.0-rc01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.credentials:credentials:1.2.0-rc01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.customview:customview:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.datastore:datastore-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-core-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-android:1.1.7=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core-android:1.1.7=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.datastore:datastore-core-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.datastore:datastore-core-okio-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-core-okio:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-core:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core-okio-jvm:1.1.7=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core-okio:1.1.7=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-core:1.1.7=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.datastore:datastore-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.datastore:datastore-preferences-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-preferences-core-android:1.1.7=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences-android:1.1.7=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences-core-android:1.1.7=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.datastore:datastore-preferences-core-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.datastore:datastore-preferences-core:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences-core:1.1.7=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.datastore:datastore-preferences-external-protobuf:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.datastore:datastore-preferences-jvm:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.datastore:datastore-preferences-proto:1.1.7=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore-preferences:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.datastore:datastore:1.1.7=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore-preferences:1.1.7=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.datastore:datastore:1.1.7=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.documentfile:documentfile:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.drawerlayout:drawerlayout:1.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -327,13 +327,13 @@ com.github.ajalt.clikt:clikt-jvm:5.0.2=ktlint com.github.ajalt.clikt:clikt:5.0.2=ktlint com.google.accompanist:accompanist-drawablepainter:0.32.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-ads-identifier:18.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-auth-api-phone:18.0.2=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-auth-base:18.0.4=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-auth:20.7.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-auth-api-phone:18.0.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-auth-base:18.0.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-auth:20.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-base:18.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath com.google.android.gms:play-services-base:18.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-fido:20.0.1=debugCompileClasspath +com.google.android.gms:play-services-fido:20.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath com.google.android.gms:play-services-fido:20.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-measurement-api:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.gms:play-services-measurement-base:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -346,9 +346,9 @@ com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspa com.google.android.libraries.identity.googleid:googleid:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android.material:material:1.10.0=releaseUnitTestRuntimeClasspath com.google.android.material:material:1.13.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.play:core-common:2.0.3=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.play:integrity:1.3.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.recaptcha:recaptcha:18.6.1=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.play:core-common:2.0.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.play:integrity:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.recaptcha:recaptcha:18.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-core,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.48.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-emulator-control @@ -369,11 +369,11 @@ com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-plat com.google.errorprone:error_prone_annotations:2.30.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,unified-test-platform-android-test-plugin-host-emulator-control com.google.firebase:firebase-analytics:23.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-annotations:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-appcheck-interop:17.0.0=debugCompileClasspath +com.google.firebase:firebase-appcheck-interop:17.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath com.google.firebase:firebase-appcheck-interop:17.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-appcheck:19.0.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-auth-interop:20.0.0=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-auth:24.0.1=debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-auth-interop:20.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-auth:24.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-bom:34.12.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-common:22.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-components:19.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath From 28ba0a84591408c52c6caeef4f41cfd850a9021d Mon Sep 17 00:00:00 2001 From: IntFxZen Date: Sun, 17 May 2026 17:56:49 +0300 Subject: [PATCH 35/35] fix lint --- app/build.gradle.kts | 4 ++-- .../java/com/itlab/notes/auth/AppSessionPreferences.kt | 2 +- .../com/itlab/notes/onboarding/OnboardingModifiers.kt | 6 +++--- .../itlab/notes/onboarding/OnboardingPreferences.kt | 2 +- .../itlab/notes/onboarding/WelcomeOnboardingScreen.kt | 9 ++++++++- .../main/java/com/itlab/notes/ui/auth/AuthScreen.kt | 9 ++++----- .../java/com/itlab/notes/ui/editor/EditorScreen.kt | 8 ++++---- .../java/com/itlab/notes/ui/notes/DirectoriesScreen.kt | 10 +++++----- .../main/java/com/itlab/notes/ui/notes/NotesScreen.kt | 8 ++++---- gradle/libs.versions.toml | 3 +++ 10 files changed, 35 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cfc1d19d..23e66a09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,7 +90,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) - implementation("androidx.compose.foundation:foundation") + implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) @@ -112,5 +112,5 @@ dependencies { implementation(libs.firebase.analytics) implementation(libs.firebase.auth) implementation(libs.kotlinx.coroutines.play.services) - implementation("com.google.android.gms:play-services-auth:20.7.0") + implementation(libs.play.services.auth) } diff --git a/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt b/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt index ceb12871..55c79c8d 100644 --- a/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt +++ b/app/src/main/java/com/itlab/notes/auth/AppSessionPreferences.kt @@ -9,7 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private val Context.appSessionDataStore: DataStore by preferencesDataStore( +internal val Context.appSessionDataStore: DataStore by preferencesDataStore( name = "app_session_preferences", ) diff --git a/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt b/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt index 08f93dae..96e0a3c2 100644 --- a/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt +++ b/app/src/main/java/com/itlab/notes/onboarding/OnboardingModifiers.kt @@ -13,9 +13,9 @@ val LocalOnboardingRegistrar = } @Composable -fun onboardingTargetModifier(key: String): Modifier { - val registrar = LocalOnboardingRegistrar.current ?: return Modifier - return Modifier.onGloballyPositioned { coordinates -> +fun Modifier.onboardingTarget(key: String): Modifier { + val registrar = LocalOnboardingRegistrar.current ?: return this + return onGloballyPositioned { coordinates -> val bounds = coordinates.boundsInRoot() registrar( key, diff --git a/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt b/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt index 7bb34307..efa918ec 100644 --- a/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt +++ b/app/src/main/java/com/itlab/notes/onboarding/OnboardingPreferences.kt @@ -9,7 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private val Context.onboardingDataStore: DataStore by preferencesDataStore( +internal val Context.onboardingDataStore: DataStore by preferencesDataStore( name = "onboarding_preferences", ) diff --git a/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt b/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt index 620a9551..4dd79ca7 100644 --- a/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt +++ b/app/src/main/java/com/itlab/notes/onboarding/WelcomeOnboardingScreen.kt @@ -29,6 +29,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -154,7 +157,11 @@ private fun welcomePagerIndicator( val colors = MaterialTheme.colorScheme val activeColor = colors.primary val inactiveColor = colors.onSurfaceVariant.copy(alpha = 0.35f) - val scrollPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction + val scrollPosition by remember { + derivedStateOf { + pagerState.currentPage + pagerState.currentPageOffsetFraction + } + } Row( modifier = modifier, diff --git a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt index d5819c27..d5e00382 100644 --- a/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/auth/AuthScreen.kt @@ -210,7 +210,7 @@ private fun authMethodChoiceContent( Icon( painter = painterResource(R.drawable.ic_google), contentDescription = null, - modifier = authMethodIconModifier(), + modifier = Modifier.authMethodIcon(), tint = Color.Unspecified, ) Text("Continue with Google") @@ -230,7 +230,7 @@ private fun authMethodChoiceContent( Icon( imageVector = Icons.Rounded.Email, contentDescription = null, - modifier = authMethodIconModifier(), + modifier = Modifier.authMethodIcon(), ) Text("Sign in with Email") } @@ -426,9 +426,8 @@ private fun authEmailContent( } } -private fun authMethodIconModifier(): Modifier = - Modifier - .size(30.dp) +private fun Modifier.authMethodIcon(): Modifier = + size(30.dp) .padding(end = 12.dp) @Composable diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index 7df79b62..ab49fc79 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -854,7 +854,7 @@ private fun editorPlainTextField( textStyle = textStyle, singleLine = singleLine, interactionSource = interactionSource, - innerTextField = innerTextField, + content = innerTextField, ) }, ) @@ -867,12 +867,12 @@ private fun editorPlainTextFieldDecoration( textStyle: TextStyle, singleLine: Boolean, interactionSource: MutableInteractionSource, - innerTextField: @Composable () -> Unit, + content: @Composable () -> Unit, ) { val colors = MaterialTheme.colorScheme TextFieldDefaults.DecorationBox( value = value, - innerTextField = innerTextField, + innerTextField = content, enabled = true, singleLine = singleLine, visualTransformation = VisualTransformation.None, @@ -1042,7 +1042,7 @@ private fun editorContentField( textStyle = textStyle, singleLine = false, interactionSource = interactionSource, - innerTextField = innerTextField, + content = innerTextField, ) }, ) diff --git a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt index 97b21b70..4e3c18c2 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/DirectoriesScreen.kt @@ -85,7 +85,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.itlab.notes.onboarding.OnboardingTargets -import com.itlab.notes.onboarding.onboardingTargetModifier +import com.itlab.notes.onboarding.onboardingTarget import com.itlab.notes.ui.toSingleLineText private const val DIRECTORY_NAME_TAKEN_ERROR = "A directory with this name already exists" @@ -322,7 +322,7 @@ private fun directoriesTopBar( onClick = if (showSignOut) onSignOut else onReturnToSignIn, modifier = if (showSignOut) { - onboardingTargetModifier(OnboardingTargets.DIRECTORIES_SIGN_OUT) + Modifier.onboardingTarget(OnboardingTargets.DIRECTORIES_SIGN_OUT) } else { Modifier }, @@ -341,7 +341,7 @@ private fun directoriesTopBar( } IconButton( onClick = onAddDirectoryClick, - modifier = onboardingTargetModifier(OnboardingTargets.DIRECTORIES_ADD), + modifier = Modifier.onboardingTarget(OnboardingTargets.DIRECTORIES_ADD), ) { Icon( Icons.Rounded.Add, @@ -410,7 +410,7 @@ private fun directoriesList( directorySearchBar( query = searchQuery, onQueryChange = onSearchQueryChange, - modifier = onboardingTargetModifier(OnboardingTargets.DIRECTORIES_SEARCH), + modifier = Modifier.onboardingTarget(OnboardingTargets.DIRECTORIES_SEARCH), ) LazyColumn( modifier = Modifier.weight(1f), @@ -596,7 +596,7 @@ private fun directoriesBlock( .padding(horizontal = 12.dp, vertical = 0.dp) .then( if (dir.id == tourHighlightDirectoryId) { - onboardingTargetModifier(OnboardingTargets.DIRECTORIES_FOLDER_ROW) + Modifier.onboardingTarget(OnboardingTargets.DIRECTORIES_FOLDER_ROW) } else { Modifier }, diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt index 3d2ee597..0bc60649 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NotesScreen.kt @@ -58,7 +58,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.itlab.notes.onboarding.OnboardingTargets -import com.itlab.notes.onboarding.onboardingTargetModifier +import com.itlab.notes.onboarding.onboardingTarget @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -426,7 +426,7 @@ private fun notesFab(onAddNoteClick: () -> Unit) { val colors = MaterialTheme.colorScheme FloatingActionButton( onClick = onAddNoteClick, - modifier = onboardingTargetModifier(OnboardingTargets.NOTES_FAB), + modifier = Modifier.onboardingTarget(OnboardingTargets.NOTES_FAB), containerColor = colors.primary, ) { Icon( @@ -488,7 +488,7 @@ private fun notesListContent( isSelected = note.id in selectedNoteIds, modifier = if (note.id == tourNoteId) { - onboardingTargetModifier(OnboardingTargets.NOTES_NOTE_ROW) + Modifier.onboardingTarget(OnboardingTargets.NOTES_NOTE_ROW) } else { Modifier }, @@ -617,7 +617,7 @@ private fun searchField( modifier = Modifier .padding(vertical = 16.dp) - .then(onboardingTargetModifier(OnboardingTargets.NOTES_SEARCH)), + .onboardingTarget(OnboardingTargets.NOTES_SEARCH), placeholderText = "Search notes", ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 29db351a..6a82849f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ lifecycleViewmodelKtx = "2.10.0" koin = "4.2.1" coilCompose = "2.7.0" datastore = "1.1.7" +playServicesAuth = "20.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -41,6 +42,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } @@ -74,6 +76,7 @@ kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "ko firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" } firebase-storage = { group = "com.google.firebase", name = "firebase-storage" } firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } +play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } firebase-ui-auth = { group = "com.firebaseui", name = "firebase-ui-auth", version = "8.0.2" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidx-work-testing" }