Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────

Expand Down Expand Up @@ -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<List<Folder>> =
context.dataStore.data.map { prefs ->
val raw = prefs[foldersKey]
if (raw.isNullOrBlank()) emptyList()
else try {
json.decodeFromString<List<Folder>>(raw)
} catch (_: Exception) {
emptyList()
}
}

suspend fun saveFolders(folders: List<Folder>) {
context.dataStore.edit { prefs ->
prefs[foldersKey] = json.encodeToString(folders)
}
}

suspend fun getFolders(): List<Folder> {
val prefs = context.dataStore.data.first()
val raw = prefs[foldersKey]
if (raw.isNullOrBlank()) return emptyList()
return try {
json.decodeFromString<List<Folder>>(raw)
} catch (_: Exception) {
emptyList()
}
}

// ── Trash management ────────────────────────────────────────────────

fun observeTrashedNotes(): Flow<Set<String>> =
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()
}
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/aqsama/neomarkor/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,4 +23,5 @@ val appModule = module {
viewModel { DashboardViewModel(get(), get()) }
viewModel { params -> EditorViewModel(params.get<String>(), get()) }
viewModel { SettingsViewModel(get()) }
viewModel { FolderViewModel(get()) }
}
38 changes: 38 additions & 0 deletions app/src/main/java/com/aqsama/neomarkor/domain/model/Folder.kt
Original file line number Diff line number Diff line change
@@ -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<Pair<String, Long>> = 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,
)
}
8 changes: 8 additions & 0 deletions app/src/main/java/com/aqsama/neomarkor/navigation/NavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<Folder>> = storagePreferences.observeFolders()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

private val _selectedFolderIds = MutableStateFlow<Set<String>>(emptySet())
val selectedFolderIds: StateFlow<Set<String>> = _selectedFolderIds.asStateFlow()

private val _isEditMode = MutableStateFlow(false)
val isEditMode: StateFlow<Boolean> = _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<String>) {
viewModelScope.launch {
val current = storagePreferences.getFolders()
val allIdsToDelete = mutableSetOf<String>()
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<String>, 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<String>, 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<Folder>()
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<Folder>): List<Folder> =
allFolders.filter { it.parentId == null }.sortedBy { it.order }

/** Get child folders of a given parent sorted by order. */
fun getChildFolders(parentId: String, allFolders: List<Folder>): List<Folder> =
allFolders.filter { it.parentId == parentId }.sortedBy { it.order }

/** Check if a folder has children. */
fun hasChildren(folderId: String, allFolders: List<Folder>): Boolean =
allFolders.any { it.parentId == folderId }

/** Count total notes recursively in a folder subtree. */
fun countNotesInSubtree(folderId: String, allFolders: List<Folder>): 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<Folder>): Set<String> {
val children = allFolders.filter { it.parentId == parentId }
val result = mutableSetOf<String>()
for (child in children) {
result.add(child.id)
result.addAll(collectDescendantIds(child.id, allFolders))
}
return result
}
}
Loading