diff --git a/app/src/main/java/com/aqsama/neomarkor/data/local/StoragePreferences.kt b/app/src/main/java/com/aqsama/neomarkor/data/local/StoragePreferences.kt index 704358f..7ceb043 100644 --- a/app/src/main/java/com/aqsama/neomarkor/data/local/StoragePreferences.kt +++ b/app/src/main/java/com/aqsama/neomarkor/data/local/StoragePreferences.kt @@ -8,20 +8,27 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.aqsama.neomarkor.domain.model.Folder import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json private val Context.dataStore by preferencesDataStore(name = "neo_markor_prefs") class StoragePreferences(private val context: Context) { + private val json = Json { ignoreUnknownKeys = true } + private val rootDirUriKey = stringPreferencesKey("root_dir_uri") private val pinnedNotesKey = stringSetPreferencesKey("pinned_notes") private val themeModeKey = intPreferencesKey("theme_mode") // 0=system, 1=light, 2=dark private val accentColorKey = intPreferencesKey("accent_color") // ARGB int private val cornerRadiusKey = floatPreferencesKey("corner_radius") // dp private val dynamicColorKey = booleanPreferencesKey("dynamic_color") + private val foldersKey = stringPreferencesKey("folders_json") + private val trashedNotesKey = stringSetPreferencesKey("trashed_notes") // ── Root directory ────────────────────────────────────────────────── @@ -80,4 +87,59 @@ class StoragePreferences(private val context: Context) { suspend fun setDynamicColor(enabled: Boolean) { context.dataStore.edit { prefs -> prefs[dynamicColorKey] = enabled } } + + // ── Folder management ─────────────────────────────────────────────── + + fun observeFolders(): Flow> = + context.dataStore.data.map { prefs -> + val raw = prefs[foldersKey] + if (raw.isNullOrBlank()) emptyList() + else try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyList() + } + } + + suspend fun saveFolders(folders: List) { + context.dataStore.edit { prefs -> + prefs[foldersKey] = json.encodeToString(folders) + } + } + + suspend fun getFolders(): List { + val prefs = context.dataStore.data.first() + val raw = prefs[foldersKey] + if (raw.isNullOrBlank()) return emptyList() + return try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyList() + } + } + + // ── Trash management ──────────────────────────────────────────────── + + fun observeTrashedNotes(): Flow> = + context.dataStore.data.map { prefs -> prefs[trashedNotesKey] ?: emptySet() } + + suspend fun moveToTrash(uriString: String) { + context.dataStore.edit { prefs -> + val current = prefs[trashedNotesKey] ?: emptySet() + prefs[trashedNotesKey] = current + uriString + } + } + + suspend fun restoreFromTrash(uriString: String) { + context.dataStore.edit { prefs -> + val current = prefs[trashedNotesKey] ?: emptySet() + prefs[trashedNotesKey] = current - uriString + } + } + + suspend fun clearTrash() { + context.dataStore.edit { prefs -> + prefs[trashedNotesKey] = emptySet() + } + } } diff --git a/app/src/main/java/com/aqsama/neomarkor/di/AppModule.kt b/app/src/main/java/com/aqsama/neomarkor/di/AppModule.kt index 2362b1a..f156867 100644 --- a/app/src/main/java/com/aqsama/neomarkor/di/AppModule.kt +++ b/app/src/main/java/com/aqsama/neomarkor/di/AppModule.kt @@ -6,6 +6,7 @@ import com.aqsama.neomarkor.domain.repository.FileRepository import com.aqsama.neomarkor.presentation.viewmodel.DashboardViewModel import com.aqsama.neomarkor.presentation.viewmodel.EditorViewModel import com.aqsama.neomarkor.presentation.viewmodel.FileBrowserViewModel +import com.aqsama.neomarkor.presentation.viewmodel.FolderViewModel import com.aqsama.neomarkor.presentation.viewmodel.SettingsViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,4 +23,5 @@ val appModule = module { viewModel { DashboardViewModel(get(), get()) } viewModel { params -> EditorViewModel(params.get(), get()) } viewModel { SettingsViewModel(get()) } + viewModel { FolderViewModel(get()) } } diff --git a/app/src/main/java/com/aqsama/neomarkor/domain/model/Folder.kt b/app/src/main/java/com/aqsama/neomarkor/domain/model/Folder.kt new file mode 100644 index 0000000..df03109 --- /dev/null +++ b/app/src/main/java/com/aqsama/neomarkor/domain/model/Folder.kt @@ -0,0 +1,38 @@ +package com.aqsama.neomarkor.domain.model + +import kotlinx.serialization.Serializable + +/** + * Represents a user-managed folder for organizing notes. + * Supports hierarchical nesting via [parentId] and manual ordering via [order]. + */ +@Serializable +data class Folder( + val id: String, + val name: String, + val color: Long = DEFAULT_FOLDER_COLOR, + val parentId: String? = null, + val order: Int = 0, + val noteCount: Int = 0, +) { + companion object { + const val DEFAULT_FOLDER_COLOR = 0xFF9E9E9E // Gray + } +} + +/** Predefined color options for folder creation and editing. */ +object FolderColors { + val colors: List> = listOf( + "Gray" to 0xFF9E9E9E, + "Red" to 0xFFE53935, + "Orange" to 0xFFFF9800, + "Yellow" to 0xFFFFEB3B, + "Green" to 0xFF4CAF50, + "Teal" to 0xFF009688, + "Blue" to 0xFF2196F3, + "Purple" to 0xFF9C27B0, + "Light Blue" to 0xFF03A9F4, + "Pink" to 0xFFE91E63, + "Brown" to 0xFF795548, + ) +} diff --git a/app/src/main/java/com/aqsama/neomarkor/navigation/NavGraph.kt b/app/src/main/java/com/aqsama/neomarkor/navigation/NavGraph.kt index 764d9cb..823de42 100644 --- a/app/src/main/java/com/aqsama/neomarkor/navigation/NavGraph.kt +++ b/app/src/main/java/com/aqsama/neomarkor/navigation/NavGraph.kt @@ -8,12 +8,14 @@ import androidx.navigation.compose.composable import com.aqsama.neomarkor.ui.screen.DashboardScreen import com.aqsama.neomarkor.ui.screen.EditorScreen import com.aqsama.neomarkor.ui.screen.FileBrowserScreen +import com.aqsama.neomarkor.ui.screen.ManageFoldersScreen import com.aqsama.neomarkor.ui.screen.SettingsScreen sealed class Screen(val route: String) { object Dashboard : Screen("dashboard") object FileBrowser : Screen("file_browser") object Settings : Screen("settings") + object ManageFolders : Screen("manage_folders") object Editor : Screen("editor/{filePath}") { /** * Encode [filePath] with URL-safe Base64 so that SAF content:// URIs — @@ -43,6 +45,7 @@ fun NeoMarkorNavGraph(navController: NavHostController) { navController.navigate(Screen.Editor.createRoute(filePath)) }, onOpenSettings = { navController.navigate(Screen.Settings.route) }, + onOpenManageFolders = { navController.navigate(Screen.ManageFolders.route) }, ) } composable(Screen.FileBrowser.route) { @@ -58,6 +61,11 @@ fun NeoMarkorNavGraph(navController: NavHostController) { onNavigateBack = { navController.popBackStack() }, ) } + composable(Screen.ManageFolders.route) { + ManageFoldersScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } composable(Screen.Editor.route) { backStackEntry -> val encodedPath = backStackEntry.arguments?.getString("filePath") ?: "" val filePath = try { diff --git a/app/src/main/java/com/aqsama/neomarkor/presentation/viewmodel/FolderViewModel.kt b/app/src/main/java/com/aqsama/neomarkor/presentation/viewmodel/FolderViewModel.kt new file mode 100644 index 0000000..bdb4704 --- /dev/null +++ b/app/src/main/java/com/aqsama/neomarkor/presentation/viewmodel/FolderViewModel.kt @@ -0,0 +1,219 @@ +package com.aqsama.neomarkor.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aqsama.neomarkor.data.local.StoragePreferences +import com.aqsama.neomarkor.domain.model.Folder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID + +class FolderViewModel( + private val storagePreferences: StoragePreferences, +) : ViewModel() { + + val folders: StateFlow> = storagePreferences.observeFolders() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _selectedFolderIds = MutableStateFlow>(emptySet()) + val selectedFolderIds: StateFlow> = _selectedFolderIds.asStateFlow() + + private val _isEditMode = MutableStateFlow(false) + val isEditMode: StateFlow = _isEditMode.asStateFlow() + + fun toggleEditMode() { + _isEditMode.value = !_isEditMode.value + if (!_isEditMode.value) { + _selectedFolderIds.value = emptySet() + } + } + + fun exitEditMode() { + _isEditMode.value = false + _selectedFolderIds.value = emptySet() + } + + fun toggleSelection(folderId: String) { + val current = _selectedFolderIds.value + _selectedFolderIds.value = if (folderId in current) current - folderId else current + folderId + } + + fun selectAll() { + _selectedFolderIds.value = folders.value.map { it.id }.toSet() + } + + fun deselectAll() { + _selectedFolderIds.value = emptySet() + } + + fun createFolder(name: String, color: Long, parentId: String? = null) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + val maxOrder = current.filter { it.parentId == parentId }.maxOfOrNull { it.order } ?: -1 + val newFolder = Folder( + id = UUID.randomUUID().toString(), + name = name, + color = color, + parentId = parentId, + order = maxOrder + 1, + ) + storagePreferences.saveFolders(current + newFolder) + } + } + + fun renameFolder(folderId: String, newName: String) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + val updated = current.map { + if (it.id == folderId) it.copy(name = newName) else it + } + storagePreferences.saveFolders(updated) + } + } + + fun deleteFolder(folderId: String) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + // Delete the folder and all its descendants + val idsToDelete = collectDescendantIds(folderId, current) + folderId + val updated = current.filter { it.id !in idsToDelete } + storagePreferences.saveFolders(updated) + } + } + + fun deleteFolders(folderIds: Set) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + val allIdsToDelete = mutableSetOf() + for (id in folderIds) { + allIdsToDelete.add(id) + allIdsToDelete.addAll(collectDescendantIds(id, current)) + } + val updated = current.filter { it.id !in allIdsToDelete } + storagePreferences.saveFolders(updated) + _selectedFolderIds.value = emptySet() + } + } + + fun setFolderColor(folderId: String, color: Long) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + val updated = current.map { + if (it.id == folderId) it.copy(color = color) else it + } + storagePreferences.saveFolders(updated) + } + } + + fun setFoldersColor(folderIds: Set, color: Long) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + val updated = current.map { + if (it.id in folderIds) it.copy(color = color) else it + } + storagePreferences.saveFolders(updated) + } + } + + fun moveFolder(folderId: String, newParentId: String?) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + // Prevent circular reference: don't move to own descendants + if (newParentId != null) { + val descendants = collectDescendantIds(folderId, current) + if (newParentId in descendants || newParentId == folderId) return@launch + } + val siblings = current.filter { it.parentId == newParentId } + val maxOrder = siblings.maxOfOrNull { it.order } ?: -1 + val updated = current.map { + if (it.id == folderId) it.copy(parentId = newParentId, order = maxOrder + 1) else it + } + storagePreferences.saveFolders(updated) + } + } + + fun moveFolders(folderIds: Set, newParentId: String?) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + // Prevent circular reference: don't move into own descendants + if (newParentId != null) { + for (id in folderIds) { + val descendants = collectDescendantIds(id, current) + if (newParentId in descendants || newParentId == id) return@launch + } + } + val siblings = current.filter { it.parentId == newParentId } + var nextOrder = (siblings.maxOfOrNull { it.order } ?: -1) + 1 + val updated = current.map { + if (it.id in folderIds) { + val folder = it.copy(parentId = newParentId, order = nextOrder) + nextOrder++ + folder + } else it + } + storagePreferences.saveFolders(updated) + _selectedFolderIds.value = emptySet() + } + } + + fun reorderFolder(folderId: String, newOrder: Int) { + viewModelScope.launch { + val current = storagePreferences.getFolders() + val folder = current.find { it.id == folderId } ?: return@launch + val siblings = current.filter { it.parentId == folder.parentId && it.id != folderId } + .sortedBy { it.order } + val totalCount = siblings.size + 1 // siblings + the moved folder + val clampedOrder = newOrder.coerceIn(0, siblings.size) + val reordered = mutableListOf() + var index = 0 + for (i in 0 until totalCount) { + if (i == clampedOrder) { + reordered.add(folder.copy(order = i)) + } else if (index < siblings.size) { + reordered.add(siblings[index].copy(order = i)) + index++ + } + } + val reorderedIds = reordered.associate { it.id to it.order } + val updated = current.map { f -> + reorderedIds[f.id]?.let { order -> f.copy(order = order) } ?: f + } + storagePreferences.saveFolders(updated) + } + } + + /** Get root folders (no parent) sorted by order. */ + fun getRootFolders(allFolders: List): List = + allFolders.filter { it.parentId == null }.sortedBy { it.order } + + /** Get child folders of a given parent sorted by order. */ + fun getChildFolders(parentId: String, allFolders: List): List = + allFolders.filter { it.parentId == parentId }.sortedBy { it.order } + + /** Check if a folder has children. */ + fun hasChildren(folderId: String, allFolders: List): Boolean = + allFolders.any { it.parentId == folderId } + + /** Count total notes recursively in a folder subtree. */ + fun countNotesInSubtree(folderId: String, allFolders: List): Int { + val folder = allFolders.find { it.id == folderId } ?: return 0 + val childCount = allFolders + .filter { it.parentId == folderId } + .sumOf { countNotesInSubtree(it.id, allFolders) } + return folder.noteCount + childCount + } + + private fun collectDescendantIds(parentId: String, allFolders: List): Set { + val children = allFolders.filter { it.parentId == parentId } + val result = mutableSetOf() + for (child in children) { + result.add(child.id) + result.addAll(collectDescendantIds(child.id, allFolders)) + } + return result + } +} diff --git a/app/src/main/java/com/aqsama/neomarkor/ui/screen/DashboardScreen.kt b/app/src/main/java/com/aqsama/neomarkor/ui/screen/DashboardScreen.kt index 29947ba..21e9dcf 100644 --- a/app/src/main/java/com/aqsama/neomarkor/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/com/aqsama/neomarkor/ui/screen/DashboardScreen.kt @@ -1,5 +1,6 @@ package com.aqsama.neomarkor.ui.screen +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -16,11 +17,14 @@ import androidx.compose.runtime.* 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.aqsama.neomarkor.domain.model.FileNode +import com.aqsama.neomarkor.domain.model.Folder import com.aqsama.neomarkor.presentation.viewmodel.DashboardViewModel +import com.aqsama.neomarkor.presentation.viewmodel.FolderViewModel import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -30,7 +34,9 @@ fun DashboardScreen( onOpenFileBrowser: () -> Unit, onOpenEditor: (String) -> Unit, onOpenSettings: () -> Unit, + onOpenManageFolders: () -> Unit, viewModel: DashboardViewModel = koinViewModel(), + folderViewModel: FolderViewModel = koinViewModel(), ) { val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -38,6 +44,7 @@ fun DashboardScreen( val hasDirectory by viewModel.hasDirectory.collectAsState() val pinnedNotes by viewModel.pinnedNotes.collectAsState() val pinnedUris by viewModel.pinnedNoteUris.collectAsState() + val folders by folderViewModel.folders.collectAsState() // Navigate to the editor whenever a new note is successfully created LaunchedEffect(Unit) { @@ -48,6 +55,8 @@ fun DashboardScreen( drawerState = drawerState, drawerContent = { NeoMarkorDrawer( + folders = folders, + folderViewModel = folderViewModel, onCloseDraw = { scope.launch { drawerState.close() } }, onOpenFileBrowser = { scope.launch { drawerState.close() } @@ -61,6 +70,10 @@ fun DashboardScreen( scope.launch { drawerState.close() } viewModel.openDailyNote() }, + onOpenManageFolders = { + scope.launch { drawerState.close() } + onOpenManageFolders() + }, ) } ) { @@ -69,7 +82,7 @@ fun DashboardScreen( TopAppBar( title = { Text( - text = "Neo-Markor", + text = "All notes", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -86,6 +99,9 @@ fun DashboardScreen( IconButton(onClick = { }) { Icon(Icons.Default.Search, contentDescription = "Search") } + IconButton(onClick = { }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, @@ -93,13 +109,13 @@ fun DashboardScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( + FloatingActionButton( onClick = { viewModel.createNewNote() }, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text("Quick Note") }, containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, - ) + ) { + Icon(Icons.Default.Edit, contentDescription = "Create new note") + } } ) { padding -> LazyColumn( @@ -113,6 +129,31 @@ fun DashboardScreen( item { NoWorkspaceCard(onOpenFileBrowser = onOpenFileBrowser) } + } else if (recentFiles.isEmpty() && pinnedNotes.isEmpty()) { + // Empty state: "No notes" prompt + item { + Box( + modifier = Modifier + .fillMaxWidth() + .fillParentMaxHeight(0.7f), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No notes", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tap the button below to create a note.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } else { // ── Pinned Notes ──────────────────────────────────── if (pinnedNotes.isNotEmpty()) { @@ -283,10 +324,13 @@ private fun RecentFileCard( @Composable private fun NeoMarkorDrawer( + folders: List, + folderViewModel: FolderViewModel, onCloseDraw: () -> Unit, onOpenFileBrowser: () -> Unit, onOpenSettings: () -> Unit, onOpenDailyNote: () -> Unit, + onOpenManageFolders: () -> Unit, ) { ModalDrawerSheet { Column(modifier = Modifier.padding(16.dp)) { @@ -301,39 +345,149 @@ private fun NeoMarkorDrawer( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary ) - IconButton(onClick = onCloseDraw) { - Icon(Icons.Default.Close, contentDescription = "Close") + IconButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") } } Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(8.dp)) + + // All notes NavigationDrawerItem( - icon = { Icon(Icons.Default.Home, contentDescription = null) }, - label = { Text("Dashboard") }, + icon = { Icon(Icons.Default.NoteAlt, contentDescription = null) }, + label = { Text("All notes") }, selected = true, onClick = onCloseDraw ) + + // Trash NavigationDrawerItem( - icon = { Icon(Icons.Default.FolderOpen, contentDescription = null) }, - label = { Text("File Browser") }, + icon = { Icon(Icons.Default.Delete, contentDescription = null) }, + label = { Text("Trash") }, selected = false, - onClick = onOpenFileBrowser + onClick = onCloseDraw ) - NavigationDrawerItem( - icon = { Icon(Icons.Default.Today, contentDescription = null) }, - label = { Text("Daily Note") }, - selected = false, - onClick = onOpenDailyNote + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Folders section header + Text( + text = "Folders", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp), ) + + // Folder list in drawer with expand/collapse + val rootFolders = folderViewModel.getRootFolders(folders) + rootFolders.forEach { folder -> + DrawerFolderItem( + folder = folder, + allFolders = folders, + depth = 0, + folderViewModel = folderViewModel, + onClick = onCloseDraw, + ) + } + Spacer(modifier = Modifier.weight(1f)) - HorizontalDivider() - NavigationDrawerItem( - icon = { Icon(Icons.Default.Settings, contentDescription = null) }, - label = { Text("Settings") }, - selected = false, - onClick = onOpenSettings + + // Manage Folders button + Button( + onClick = onOpenManageFolders, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon(Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Manage Folders") + } + } + } +} + +@Composable +private fun DrawerFolderItem( + folder: Folder, + allFolders: List, + depth: Int, + folderViewModel: FolderViewModel, + onClick: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val children = folderViewModel.getChildFolders(folder.id, allFolders) + val hasChildren = children.isNotEmpty() + val noteCount = folderViewModel.countNotesInSubtree(folder.id, allFolders) + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + start = (16 + depth * 16).dp, + end = 16.dp, + top = 10.dp, + bottom = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasChildren) { + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier.size(24.dp), + ) { + Icon( + if (expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.outline, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = Color(folder.color), + modifier = Modifier.size(20.dp), ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + folder.name, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + // Note count badge + Badge( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text("$noteCount") + } + } + + // Expanded children + AnimatedVisibility(visible = expanded) { + Column { + children.forEach { child -> + DrawerFolderItem( + folder = child, + allFolders = allFolders, + depth = depth + 1, + folderViewModel = folderViewModel, + onClick = onClick, + ) + } + } } } } diff --git a/app/src/main/java/com/aqsama/neomarkor/ui/screen/ManageFoldersScreen.kt b/app/src/main/java/com/aqsama/neomarkor/ui/screen/ManageFoldersScreen.kt new file mode 100644 index 0000000..0fe6f52 --- /dev/null +++ b/app/src/main/java/com/aqsama/neomarkor/ui/screen/ManageFoldersScreen.kt @@ -0,0 +1,655 @@ +package com.aqsama.neomarkor.ui.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aqsama.neomarkor.domain.model.Folder +import com.aqsama.neomarkor.domain.model.FolderColors +import com.aqsama.neomarkor.presentation.viewmodel.FolderViewModel +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageFoldersScreen( + onNavigateBack: () -> Unit, + viewModel: FolderViewModel = koinViewModel(), +) { + val folders by viewModel.folders.collectAsState() + val isEditMode by viewModel.isEditMode.collectAsState() + val selectedIds by viewModel.selectedFolderIds.collectAsState() + + var showCreateDialog by remember { mutableStateOf(false) } + var createSubParentId by remember { mutableStateOf(null) } + var showRenameDialog by remember { mutableStateOf(false) } + var renameTargetId by remember { mutableStateOf("") } + var renameTargetName by remember { mutableStateOf("") } + var showColorPicker by remember { mutableStateOf(false) } + var showMoveDialog by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + + // Create folder dialog + if (showCreateDialog) { + CreateFolderDialog( + onDismiss = { + showCreateDialog = false + createSubParentId = null + }, + onConfirm = { name, color -> + viewModel.createFolder(name, color, createSubParentId) + showCreateDialog = false + createSubParentId = null + }, + ) + } + + // Rename dialog + if (showRenameDialog) { + RenameFolderDialog( + currentName = renameTargetName, + onDismiss = { showRenameDialog = false }, + onConfirm = { newName -> + viewModel.renameFolder(renameTargetId, newName) + showRenameDialog = false + }, + ) + } + + // Color picker dialog + if (showColorPicker) { + FolderColorPickerDialog( + onDismiss = { showColorPicker = false }, + onColorSelected = { color -> + viewModel.setFoldersColor(selectedIds, color) + showColorPicker = false + }, + ) + } + + // Move dialog + if (showMoveDialog) { + MoveFolderDialog( + folders = folders, + excludeIds = selectedIds, + onDismiss = { showMoveDialog = false }, + onMoveToRoot = { + viewModel.moveFolders(selectedIds, null) + showMoveDialog = false + }, + onMoveToFolder = { targetId -> + viewModel.moveFolders(selectedIds, targetId) + showMoveDialog = false + }, + ) + } + + // Delete confirmation + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete Folders") }, + text = { Text("Delete ${selectedIds.size} selected folder(s)? This cannot be undone.") }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteFolders(selectedIds) + showDeleteConfirm = false + viewModel.exitEditMode() + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + if (isEditMode) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${selectedIds.size} selected", + style = MaterialTheme.typography.titleLarge, + ) + } + } else { + Text( + "Manage folders", + style = MaterialTheme.typography.titleLarge, + ) + } + }, + navigationIcon = { + if (isEditMode) { + IconButton(onClick = { + if (selectedIds.size == folders.size) { + viewModel.deselectAll() + } else { + viewModel.selectAll() + } + }) { + Icon( + if (selectedIds.size == folders.size) Icons.Default.CheckCircle + else Icons.Default.RadioButtonUnchecked, + contentDescription = "Select all", + ) + } + } else { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + }, + actions = { + TextButton(onClick = { + if (isEditMode) viewModel.exitEditMode() else viewModel.toggleEditMode() + }) { + Text( + if (isEditMode) "Done" else "Edit", + color = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + }, + bottomBar = { + if (isEditMode) { + EditModeBottomBar( + hasSelection = selectedIds.isNotEmpty(), + onMove = { showMoveDialog = true }, + onCreateSub = { + if (selectedIds.size == 1) { + createSubParentId = selectedIds.first() + showCreateDialog = true + } + }, + onFolderColor = { showColorPicker = true }, + onRename = { + if (selectedIds.size == 1) { + val folder = folders.find { it.id == selectedIds.first() } + if (folder != null) { + renameTargetId = folder.id + renameTargetName = folder.name + showRenameDialog = true + } + } + }, + onDelete = { showDeleteConfirm = true }, + ) + } + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + ) { + item { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + val rootFolders = viewModel.getRootFolders(folders) + if (rootFolders.isEmpty() && !isEditMode) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No folders yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + rootFolders.forEach { folder -> + FolderTreeItem( + folder = folder, + allFolders = folders, + depth = 0, + isEditMode = isEditMode, + selectedIds = selectedIds, + onToggleSelection = { viewModel.toggleSelection(it) }, + viewModel = viewModel, + ) + } + } + + // Create folder button + if (!isEditMode) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showCreateDialog = true } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Create folder", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun FolderTreeItem( + folder: Folder, + allFolders: List, + depth: Int, + isEditMode: Boolean, + selectedIds: Set, + onToggleSelection: (String) -> Unit, + viewModel: FolderViewModel, +) { + var expanded by remember { mutableStateOf(false) } + val children = viewModel.getChildFolders(folder.id, allFolders) + val hasChildren = children.isNotEmpty() + val noteCount = viewModel.countNotesInSubtree(folder.id, allFolders) + val isSelected = folder.id in selectedIds + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (isEditMode) onToggleSelection(folder.id) + else if (hasChildren) expanded = !expanded + } + .padding( + start = (16 + depth * 24).dp, + end = 16.dp, + top = 10.dp, + bottom = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isEditMode) { + // Checkbox for selection + Icon( + if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, + contentDescription = if (isSelected) "Selected" else "Not selected", + tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + modifier = Modifier + .size(24.dp) + .clickable { onToggleSelection(folder.id) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + } else if (hasChildren) { + // Expand/collapse chevron + Icon( + if (expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier + .size(20.dp) + .clickable { expanded = !expanded }, + ) + Spacer(modifier = Modifier.width(4.dp)) + } else { + Spacer(modifier = Modifier.width(24.dp)) + } + + // Folder icon with color + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = Color(folder.color), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + + // Folder name + Text( + text = folder.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + // Note count badge + Text( + text = "$noteCount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (!isEditMode) { + Spacer(modifier = Modifier.width(8.dp)) + // Reorder handle + Icon( + Icons.Default.UnfoldMore, + contentDescription = "Reorder", + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(20.dp), + ) + } + } + + // Children (if expanded) + AnimatedVisibility(visible = expanded) { + Column { + children.forEach { child -> + FolderTreeItem( + folder = child, + allFolders = allFolders, + depth = depth + 1, + isEditMode = isEditMode, + selectedIds = selectedIds, + onToggleSelection = onToggleSelection, + viewModel = viewModel, + ) + } + } + } + } +} + +@Composable +private fun EditModeBottomBar( + hasSelection: Boolean, + onMove: () -> Unit, + onCreateSub: () -> Unit, + onFolderColor: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 3.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + BottomBarAction( + icon = Icons.Default.DriveFileMove, + label = "Move", + enabled = hasSelection, + onClick = onMove, + ) + BottomBarAction( + icon = Icons.Default.CreateNewFolder, + label = "Create sub…", + enabled = hasSelection, + onClick = onCreateSub, + ) + BottomBarAction( + icon = Icons.Default.Palette, + label = "Folder color", + enabled = hasSelection, + onClick = onFolderColor, + ) + BottomBarAction( + icon = Icons.Default.Edit, + label = "Rename", + enabled = hasSelection, + onClick = onRename, + ) + BottomBarAction( + icon = Icons.Default.Delete, + label = "Delete", + enabled = hasSelection, + onClick = onDelete, + ) + } + } +} + +@Composable +private fun BottomBarAction( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val contentColor = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Icon( + icon, + contentDescription = label, + tint = contentColor, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + ) + } +} + +@Composable +fun CreateFolderDialog( + onDismiss: () -> Unit, + onConfirm: (name: String, color: Long) -> Unit, +) { + var folderName by remember { mutableStateOf("") } + var selectedColor by remember { mutableStateOf(FolderColors.colors.first().second) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create folder") }, + text = { + Column { + OutlinedTextField( + value = folderName, + onValueChange = { folderName = it }, + label = { Text("Folder name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Color", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(FolderColors.colors) { (_, color) -> + val isSelected = selectedColor == color + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(color)) + .then( + if (isSelected) Modifier.border( + 3.dp, + MaterialTheme.colorScheme.onSurface, + CircleShape, + ) + else Modifier + ) + .clickable { selectedColor = color }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (folderName.isNotBlank()) { + onConfirm(folderName.trim(), selectedColor) + } + }, + ) { Text("Add") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun RenameFolderDialog( + currentName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var newName by remember { mutableStateOf(currentName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename folder") }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("Folder name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { + if (newName.isNotBlank()) onConfirm(newName.trim()) + }, + ) { Text("Rename") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +fun FolderColorPickerDialog( + onDismiss: () -> Unit, + onColorSelected: (Long) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Folder color") }, + text = { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(FolderColors.colors) { (name, color) -> + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color(color)) + .clickable { onColorSelected(color) }, + contentAlignment = Alignment.Center, + ) {} + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun MoveFolderDialog( + folders: List, + excludeIds: Set, + onDismiss: () -> Unit, + onMoveToRoot: () -> Unit, + onMoveToFolder: (String) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Move to folder") }, + text = { + Column { + TextButton(onClick = onMoveToRoot) { + Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Root (no parent)") + } + folders + .filter { it.id !in excludeIds } + .forEach { folder -> + TextButton(onClick = { onMoveToFolder(folder.id) }) { + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = Color(folder.color), + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(folder.name) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/app/src/test/java/com/aqsama/neomarkor/domain/model/FolderTest.kt b/app/src/test/java/com/aqsama/neomarkor/domain/model/FolderTest.kt new file mode 100644 index 0000000..e58cacd --- /dev/null +++ b/app/src/test/java/com/aqsama/neomarkor/domain/model/FolderTest.kt @@ -0,0 +1,132 @@ +package com.aqsama.neomarkor.domain.model + +import org.junit.Assert.* +import org.junit.Test + +class FolderTest { + + @Test + fun `default folder has gray color`() { + val folder = Folder(id = "1", name = "Test") + assertEquals(Folder.DEFAULT_FOLDER_COLOR, folder.color) + } + + @Test + fun `folder with no parent is a root folder`() { + val folder = Folder(id = "1", name = "Root", parentId = null) + assertNull(folder.parentId) + } + + @Test + fun `folder with parentId is a child folder`() { + val folder = Folder(id = "2", name = "Child", parentId = "1") + assertEquals("1", folder.parentId) + } + + @Test + fun `folder copy preserves all fields`() { + val original = Folder( + id = "1", + name = "Test", + color = 0xFFFF0000, + parentId = "parent", + order = 5, + noteCount = 3, + ) + val copy = original.copy(name = "Renamed") + assertEquals("Renamed", copy.name) + assertEquals(original.id, copy.id) + assertEquals(original.color, copy.color) + assertEquals(original.parentId, copy.parentId) + assertEquals(original.order, copy.order) + assertEquals(original.noteCount, copy.noteCount) + } + + @Test + fun `FolderColors has 11 predefined colors`() { + assertEquals(11, FolderColors.colors.size) + } + + @Test + fun `FolderColors first color is Gray`() { + assertEquals("Gray", FolderColors.colors.first().first) + } + + @Test + fun `root folders can be filtered from mixed list`() { + val folders = listOf( + Folder(id = "1", name = "Root 1", parentId = null, order = 0), + Folder(id = "2", name = "Root 2", parentId = null, order = 1), + Folder(id = "3", name = "Child 1", parentId = "1", order = 0), + Folder(id = "4", name = "Child 2", parentId = "1", order = 1), + ) + val roots = folders.filter { it.parentId == null }.sortedBy { it.order } + assertEquals(2, roots.size) + assertEquals("Root 1", roots[0].name) + assertEquals("Root 2", roots[1].name) + } + + @Test + fun `child folders can be filtered by parentId`() { + val folders = listOf( + Folder(id = "1", name = "Root", parentId = null, order = 0), + Folder(id = "2", name = "Child A", parentId = "1", order = 0), + Folder(id = "3", name = "Child B", parentId = "1", order = 1), + Folder(id = "4", name = "Other", parentId = "5", order = 0), + ) + val childrenOf1 = folders.filter { it.parentId == "1" }.sortedBy { it.order } + assertEquals(2, childrenOf1.size) + assertEquals("Child A", childrenOf1[0].name) + assertEquals("Child B", childrenOf1[1].name) + } + + @Test + fun `descendant ids can be collected recursively`() { + val folders = listOf( + Folder(id = "1", name = "Root", parentId = null), + Folder(id = "2", name = "Child", parentId = "1"), + Folder(id = "3", name = "Grandchild", parentId = "2"), + Folder(id = "4", name = "Unrelated", parentId = null), + ) + + fun collectDescendantIds(parentId: String): Set { + val children = folders.filter { it.parentId == parentId } + val result = mutableSetOf() + for (child in children) { + result.add(child.id) + result.addAll(collectDescendantIds(child.id)) + } + return result + } + + val descendants = collectDescendantIds("1") + assertEquals(setOf("2", "3"), descendants) + } + + @Test + fun `folder serialization preserves data`() { + val folder = Folder( + id = "test-id", + name = "My Folder", + color = 0xFF4CAF50, + parentId = null, + order = 2, + noteCount = 5, + ) + val json = kotlinx.serialization.json.Json.encodeToString(Folder.serializer(), folder) + val decoded = kotlinx.serialization.json.Json.decodeFromString(Folder.serializer(), json) + assertEquals(folder, decoded) + } + + @Test + fun `folder list serialization round-trip`() { + val folders = listOf( + Folder(id = "1", name = "Folder A", color = 0xFFE53935, parentId = null, order = 0), + Folder(id = "2", name = "Folder B", color = 0xFF2196F3, parentId = "1", order = 0), + ) + val serializer = kotlinx.serialization.builtins.ListSerializer(Folder.serializer()) + val json = kotlinx.serialization.json.Json.encodeToString(serializer, folders) + val decoded = kotlinx.serialization.json.Json.decodeFromString(serializer, json) + assertEquals(folders, decoded) + } +}