diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt new file mode 100644 index 0000000..6b3e43d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt @@ -0,0 +1,343 @@ +package com.avinal.memos.ui.memos + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +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.rememberCoroutineScope +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.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.avinal.memos.AppDependencies +import com.avinal.memos.ui.settings.SettingsScreen +import com.avinal.memos.ui.tasks.TaskListScreen +import com.avinal.memos.ui.theme.LocalAccentColor +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private val pivotTitles = listOf("explore", "memos", "tasks", "settings") +private const val START_PAGE = 1 +private const val PARALLAX_FACTOR = 0.5f + +@Composable +fun MainScreen( + deps: AppDependencies, + onMemoClick: (String) -> Unit, + onCreateMemo: () -> Unit, + onLogout: () -> Unit, +) { + val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { pivotTitles.size }) + val scope = rememberCoroutineScope() + val accent = LocalAccentColor.current + val density = LocalDensity.current + + var dateFilter by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 6.dp), + ) { + val scrollFraction = pagerState.currentPage + pagerState.currentPageOffsetFraction + val parallaxOffset = with(density) { (-scrollFraction * PARALLAX_FACTOR * 100.dp.toPx()).toInt() } + + Row( + modifier = Modifier + .offset { IntOffset(parallaxOffset, 0) } + .padding(start = 24.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + pivotTitles.forEachIndexed { index, title -> + val distance = kotlin.math.abs(scrollFraction - index) + val alpha = (1f - distance * 0.5f).coerceIn(0.2f, 1f) + val isSelected = pagerState.currentPage == index + + Text( + text = title, + fontSize = 28.sp, + fontWeight = FontWeight.Light, + color = if (isSelected) accent.copy(alpha = alpha) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.5f), + maxLines = 1, + overflow = TextOverflow.Visible, + softWrap = false, + modifier = Modifier + .clickable { scope.launch { pagerState.animateScrollToPage(index) } } + .padding(vertical = 4.dp), + ) + } + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1, + ) { page -> + Box(modifier = Modifier.fillMaxSize()) { + when (page) { + 0 -> ExplorerPage( + deps = deps, + onMemoClick = onMemoClick, + onDateSelected = { date -> + dateFilter = date + scope.launch { pagerState.animateScrollToPage(1) } + }, + ) + 1 -> MemoListScreen( + deps = deps, + onMemoClick = onMemoClick, + onCreateMemo = onCreateMemo, + dateFilter = dateFilter, + onClearDateFilter = { dateFilter = null }, + ) + 2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick) + 3 -> SettingsScreen(deps = deps, onLogout = onLogout) + } + } + } + } +} + +@Composable +private fun ExplorerPage( + deps: AppDependencies, + onMemoClick: (String) -> Unit, + onDateSelected: (String) -> Unit, +) { + val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList()) + val accent = LocalAccentColor.current + val textColor = MaterialTheme.colorScheme.onBackground + val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant + var searchQuery by remember { mutableStateOf("") } + var showSearch by remember { mutableStateOf(false) } + + val now = remember { kotlin.time.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) } + var calYear by remember { mutableStateOf(now.year) } + var calMonthIdx by remember { mutableStateOf(now.month.ordinal) } + + val memosByDate = remember(memos) { + memos.groupBy { memo -> + val local = memo.displayTime.toLocalDateTime(TimeZone.currentSystemDefault()) + "${local.year}-${local.month.ordinal + 1}-${local.day}" + } + } + + val daysInMonth = remember(calYear, calMonthIdx) { + val lengths = listOf(31, if (calYear % 4 == 0 && (calYear % 100 != 0 || calYear % 400 == 0)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + lengths[calMonthIdx] + } + + val monthNames = listOf("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec") + val allTags = remember(memos) { memos.flatMap { it.tags }.distinct().sorted() } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 12.dp, top = 6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("${memos.size} memos", fontSize = 14.sp, color = subtleColor) + Icon( + Icons.Default.Search, contentDescription = "Search", + modifier = Modifier.size(20.dp).clickable { showSearch = !showSearch }, tint = subtleColor, + ) + } + + AnimatedVisibility(visible = showSearch, enter = expandVertically(), exit = shrinkVertically()) { + OutlinedTextField( + value = searchQuery, onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + placeholder = { Text("search memos...", fontSize = 14.sp) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + trailingIcon = { + if (searchQuery.isNotEmpty()) { + Icon(Icons.Default.Close, contentDescription = "Clear", + modifier = Modifier.size(16.dp).clickable { searchQuery = "" }, tint = subtleColor) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = accent, unfocusedBorderColor = subtleColor.copy(alpha = 0.3f), cursorColor = accent, + ), + ) + } + + if (showSearch && searchQuery.isNotBlank()) { + val results = memos.filter { it.content.contains(searchQuery, ignoreCase = true) } + Spacer(Modifier.height(8.dp)) + Text("${results.size} results", fontSize = 12.sp, color = subtleColor) + results.take(10).forEach { memo -> + val snippet = memo.content.lines().first().take(60) + Text( + snippet, fontSize = 14.sp, color = textColor, maxLines = 1, + modifier = Modifier.fillMaxWidth().clickable { onMemoClick(memo.id) }.padding(vertical = 6.dp), + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous month", + modifier = Modifier.size(22.dp).clickable { + if (calMonthIdx == 0) { calMonthIdx = 11; calYear-- } else calMonthIdx-- + }, + tint = subtleColor, + ) + Text( + "${monthNames[calMonthIdx]} $calYear", + fontSize = 19.sp, fontWeight = FontWeight.Light, color = textColor, + ) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next month", + modifier = Modifier.size(22.dp).clickable { + if (calMonthIdx == 11) { calMonthIdx = 0; calYear++ } else calMonthIdx++ + }, + tint = subtleColor, + ) + } + + Spacer(Modifier.height(8.dp)) + + val dayLabels = listOf("m", "t", "w", "t", "f", "s", "s") + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + dayLabels.forEach { day -> + Text(day, fontSize = 11.sp, color = subtleColor.copy(alpha = 0.5f), modifier = Modifier.width(28.dp), textAlign = TextAlign.Center) + } + } + Spacer(Modifier.height(4.dp)) + + val firstDayOfWeek = remember(calYear, calMonthIdx) { + val month = calMonthIdx + 1 + val y = if (month <= 2) calYear - 1 else calYear + val m = if (month <= 2) month + 12 else month + val h = (1 + (13 * (m + 1)) / 5 + y + y / 4 - y / 100 + y / 400) % 7 + val mondayBased = ((h + 5) % 7) + if (mondayBased < 0) mondayBased + 7 else mondayBased + } + + val cells = buildList { + repeat(firstDayOfWeek) { add(0) } + for (d in 1..daysInMonth) add(d) + } + + val isCurrentMonth = calYear == now.year && calMonthIdx == now.month.ordinal + + cells.chunked(7).forEach { week -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + for (i in 0 until 7) { + val day = week.getOrNull(i) ?: 0 + if (day == 0) { + Box(Modifier.size(28.dp)) + } else { + val dateKey = "$calYear-${calMonthIdx + 1}-$day" + val count = memosByDate[dateKey]?.size ?: 0 + val isToday = isCurrentMonth && day == now.day + val intensity = if (count > 0) (count.coerceAtMost(4).toFloat() / 4f) else 0f + + Box( + modifier = Modifier.size(28.dp).clip(RoundedCornerShape(4.dp)) + .background( + when { + isToday -> accent + count > 0 -> accent.copy(alpha = 0.15f + intensity * 0.45f) + else -> Color.Transparent + } + ) + .clickable(enabled = count > 0) { + onDateSelected(dateKey) + }, + contentAlignment = Alignment.Center, + ) { + Text( + "$day", fontSize = 11.sp, + color = when { + isToday -> Color.White + count > 0 -> textColor + else -> subtleColor.copy(alpha = 0.4f) + }, + ) + } + } + } + } + } + + if (allTags.isNotEmpty()) { + Spacer(Modifier.height(20.dp)) + Text("tags", fontSize = 19.sp, fontWeight = FontWeight.Light, color = textColor) + Spacer(Modifier.height(8.dp)) + allTags.forEach { tag -> + val count = memos.count { it.tags.contains(tag) } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("#$tag", fontSize = 14.sp, color = accent) + Text("$count", fontSize = 12.sp, color = subtleColor) + } + } + } + + Spacer(Modifier.height(24.dp)) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt new file mode 100644 index 0000000..c3bd258 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt @@ -0,0 +1,116 @@ +package com.avinal.memos.ui.memos + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.avinal.memos.AppDependencies +import com.avinal.memos.ui.components.AttachmentGrid +import com.avinal.memos.ui.components.MarkdownText +import com.avinal.memos.ui.components.ReactionBar +import com.avinal.memos.ui.theme.LocalAccentColor +import kotlinx.coroutines.flow.first + +@Composable +fun MemoDetailScreen( + memoId: String, + deps: AppDependencies, + onBack: () -> Unit, + onEdit: () -> Unit, + onDeleted: () -> Unit, +) { + val viewModel = viewModel { MemoDetailViewModel(memoId, deps.memoRepository) } + val memo by viewModel.memo.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val serverUrl by produceState("") { value = deps.tokenStore.serverUrl.first() ?: "" } + val accent = LocalAccentColor.current + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding(), + ) { + Text( + "← back", + fontSize = 14.sp, + color = accent, + modifier = Modifier + .clickable(onClick = onBack) + .padding(start = 24.dp, top = 12.dp, bottom = 12.dp), + ) + + when { + isLoading && memo == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = accent) + } + } + memo == null -> { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("could not load memo", fontSize = 15.sp) + Spacer(Modifier.height(8.dp)) + Text("retry", fontSize = 14.sp, color = accent, modifier = Modifier.clickable { viewModel.retry() }) + } + } + } + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 12.dp), + ) { + Text( + memo!!.visibility.name.lowercase(), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + MarkdownText( + markdown = memo!!.content, + onTaskToggle = { lineIndex, checked -> viewModel.toggleTask(lineIndex, checked) }, + ) + + if (memo!!.attachments.any { it.isImage } && serverUrl.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + AttachmentGrid(attachments = memo!!.attachments, serverUrl = serverUrl) + } + + if (memo!!.reactions.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + ReactionBar(reactions = memo!!.reactions) + } + + Spacer(Modifier.height(24.dp)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt new file mode 100644 index 0000000..2bffded --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt @@ -0,0 +1,61 @@ +package com.avinal.memos.ui.memos + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.avinal.memos.api.ApiResult +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoRepository +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 + +class MemoDetailViewModel( + private val memoId: String, + private val memoRepository: MemoRepository, +) : ViewModel() { + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + val memo: StateFlow = memoRepository.observeMemo(memoId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + init { loadMemo() } + + private fun loadMemo() { + viewModelScope.launch { + _isLoading.value = true + memoRepository.getMemo(memoId) + _isLoading.value = false + } + } + + fun retry() = loadMemo() + + fun toggleTask(lineIndex: Int, checked: Boolean) { + val current = memo.value ?: return + val lines = current.content.lines().toMutableList() + if (lineIndex !in lines.indices) return + + val line = lines[lineIndex] + lines[lineIndex] = if (checked) { + line.replaceFirst("- [ ]", "- [x]") + } else { + line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]") + } + + viewModelScope.launch { + memoRepository.updateMemo(memoId, content = lines.joinToString("\n")) + } + } + + suspend fun deleteMemoAndWait(): Boolean { + return when (memoRepository.deleteMemo(memoId)) { + is ApiResult.Success -> true + else -> false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoEditorScreen.kt new file mode 100644 index 0000000..3ccd7a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoEditorScreen.kt @@ -0,0 +1,154 @@ +package com.avinal.memos.ui.memos + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.avinal.memos.AppDependencies +import com.avinal.memos.domain.MemoVisibility +import com.avinal.memos.ui.theme.LocalAccentColor + +@Composable +fun MemoEditorScreen( + memoId: String?, + deps: AppDependencies, + onBack: () -> Unit, + onSaved: () -> Unit, +) { + val viewModel = viewModel { MemoEditorViewModel(memoId, deps.memoRepository) } + val uiState by viewModel.uiState.collectAsState() + val accent = LocalAccentColor.current + var showDiscardDialog by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.isSaved) { if (uiState.isSaved) onSaved() } + + val handleBack = { if (uiState.isDirty) showDiscardDialog = true else onBack() } + BackHandler(enabled = uiState.isDirty) { showDiscardDialog = true } + + if (showDiscardDialog) { + AlertDialog( + onDismissRequest = { showDiscardDialog = false }, + title = { Text("discard changes?") }, + confirmButton = { + TextButton(onClick = { showDiscardDialog = false; onBack() }) { Text("discard") } + }, + dismissButton = { + TextButton(onClick = { showDiscardDialog = false }) { Text("keep editing") } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .padding(start = 24.dp, end = 24.dp, top = 12.dp), + ) { + Text( + if (memoId == null) "new memo" else "edit memo", + fontSize = 32.sp, + fontWeight = FontWeight.Light, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(18.dp)) + + TextField( + value = uiState.content, + onValueChange = viewModel::updateContent, + modifier = Modifier.fillMaxWidth().weight(1f), + placeholder = { Text("any thoughts...", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) }, + textStyle = MaterialTheme.typography.bodyMedium, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = accent, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ), + ) + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + var showVisMenu by remember { mutableStateOf(false) } + androidx.compose.foundation.layout.Box { + Text( + uiState.visibility.name.lowercase(), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { showVisMenu = true }, + ) + DropdownMenu(expanded = showVisMenu, onDismissRequest = { showVisMenu = false }) { + MemoVisibility.entries.forEach { vis -> + Text( + vis.name.lowercase(), + fontSize = 14.sp, + modifier = Modifier.fillMaxWidth().clickable { viewModel.setVisibility(vis); showVisMenu = false } + .padding(horizontal = 16.dp, vertical = 10.dp), + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "cancel", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { handleBack() }, + ) + if (uiState.isSaving) { + CircularProgressIndicator(color = accent, strokeWidth = 2.dp) + } else { + Text( + "save", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (uiState.content.isNotBlank()) accent else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.then( + if (uiState.content.isNotBlank()) Modifier.clickable(onClick = viewModel::save) else Modifier + ), + ) + } + } + } + + if (uiState.error != null) { + Text(uiState.error!!, fontSize = 13.sp, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(12.dp)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoEditorViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoEditorViewModel.kt new file mode 100644 index 0000000..54e4988 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoEditorViewModel.kt @@ -0,0 +1,92 @@ +package com.avinal.memos.ui.memos + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.avinal.memos.api.ApiResult +import com.avinal.memos.domain.MemoRepository +import com.avinal.memos.domain.MemoVisibility +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MemoEditorUiState( + val content: String = "", + val originalContent: String = "", + val visibility: MemoVisibility = MemoVisibility.PRIVATE, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val isSaved: Boolean = false, + val isEditMode: Boolean = false, +) { + val isDirty: Boolean get() = content != originalContent +} + +class MemoEditorViewModel( + private val memoId: String?, + private val memoRepository: MemoRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(MemoEditorUiState(isEditMode = memoId != null)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + if (memoId != null) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val memo = memoRepository.getMemo(memoId) + if (memo != null) { + _uiState.update { + it.copy( + content = memo.content, + originalContent = memo.content, + visibility = memo.visibility, + isLoading = false, + ) + } + } else { + _uiState.update { it.copy(isLoading = false, error = "Memo not found") } + } + } + } + } + + fun updateContent(content: String) { + _uiState.update { it.copy(content = content, error = null) } + } + + fun setVisibility(visibility: MemoVisibility) { + _uiState.update { it.copy(visibility = visibility) } + } + + fun save() { + val state = _uiState.value + if (state.content.isBlank()) { + _uiState.update { it.copy(error = "Content cannot be empty") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isSaving = true, error = null) } + val result = if (memoId != null) { + memoRepository.updateMemo( + id = memoId, + content = state.content, + visibility = state.visibility, + ) + } else { + memoRepository.createMemo(state.content, state.visibility) + } + + when (result) { + is ApiResult.Success -> _uiState.update { it.copy(isSaving = false, isSaved = true) } + is ApiResult.Error -> _uiState.update { it.copy(isSaving = false, error = result.message) } + is ApiResult.NetworkError -> _uiState.update { + it.copy(isSaving = false, error = result.exception.message ?: "Network error") + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt new file mode 100644 index 0000000..4fdb2c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt @@ -0,0 +1,270 @@ +package com.avinal.memos.ui.memos + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.avinal.memos.AppDependencies +import com.avinal.memos.domain.MemoVisibility +import com.avinal.memos.ui.components.MemoCard +import com.avinal.memos.ui.theme.LocalAccentColor +import kotlinx.coroutines.flow.first +import kotlinx.datetime.toLocalDateTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemoListScreen( + deps: AppDependencies, + onMemoClick: (String) -> Unit, + onCreateMemo: () -> Unit, + dateFilter: String? = null, + onClearDateFilter: (() -> Unit)? = null, +) { + val viewModel = viewModel { MemoListViewModel(deps.memoRepository) } + val allMemos by viewModel.memos.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + val memos = remember(allMemos, dateFilter) { + if (dateFilter == null) allMemos + else { + val parts = dateFilter.split("-") + if (parts.size == 3) { + val (year, month, day) = parts.map { it.toIntOrNull() ?: 0 } + allMemos.filter { memo -> + val local = memo.displayTime.toLocalDateTime(kotlinx.datetime.TimeZone.currentSystemDefault()) + local.year == year && (local.month.ordinal + 1) == month && local.day == day + } + } else allMemos + } + } + val listState = rememberLazyListState() + val serverUrl by produceState("") { value = deps.tokenStore.serverUrl.first() ?: "" } + val accent = LocalAccentColor.current + val textColor = MaterialTheme.colorScheme.onBackground + val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant + + var composeText by remember { mutableStateOf("") } + var composeVisibility by remember { mutableStateOf(MemoVisibility.PRIVATE) } + var showVisibilityPicker by remember { mutableStateOf(false) } + + val reachedEnd by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() + lastVisibleItem != null && lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 3 + } + } + + LaunchedEffect(reachedEnd) { + if (reachedEnd && !uiState.isLoadingMore && uiState.searchQuery.isEmpty()) viewModel.loadMore() + } + + if (showVisibilityPicker) { + AlertDialog( + onDismissRequest = { showVisibilityPicker = false }, + containerColor = MaterialTheme.colorScheme.surface, + title = null, + text = { + Column { + MemoVisibility.entries.forEach { vis -> + Text( + vis.name.lowercase(), + fontSize = 17.sp, + color = if (vis == composeVisibility) accent else textColor, + modifier = Modifier + .fillMaxWidth() + .clickable { composeVisibility = vis; showVisibilityPicker = false } + .padding(vertical = 10.dp), + ) + } + } + }, + confirmButton = {}, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + if (dateFilter != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("memos from $dateFilter", fontSize = 13.sp, color = accent) + Text( + "clear", + fontSize = 13.sp, + color = subtleColor, + modifier = Modifier.clickable { onClearDateFilter?.invoke() }.padding(4.dp), + ) + } + Spacer(Modifier.fillMaxWidth().height(1.dp).padding(start = 24.dp).background(subtleColor.copy(alpha = 0.15f))) + } + + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.weight(1f), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + item(key = "compose") { + var showInsertMenu by remember { mutableStateOf(false) } + + if (showInsertMenu) { + AlertDialog( + onDismissRequest = { showInsertMenu = false }, + containerColor = MaterialTheme.colorScheme.surface, + title = null, + text = { + Column { + listOf("media", "file", "link memo", "code block").forEach { item -> + Text( + item, fontSize = 17.sp, color = textColor, + modifier = Modifier.fillMaxWidth() + .clickable { + showInsertMenu = false + when (item) { + "code block" -> composeText += "\n```\n\n```" + "link memo" -> composeText += "\n[memo]()" + else -> { /* TODO: file picker */ } + } + } + .padding(vertical = 10.dp), + ) + } + } + }, + confirmButton = {}, + ) + } + + Column(modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)) { + TextField( + value = composeText, + onValueChange = { composeText = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text("any thoughts...", fontSize = 15.sp, color = subtleColor.copy(alpha = 0.4f)) + }, + singleLine = false, + minLines = 1, + maxLines = 10, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = accent, + unfocusedIndicatorColor = subtleColor.copy(alpha = 0.2f), + cursorColor = accent, + ), + ) + + Spacer(Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "+", + fontSize = 18.sp, + fontWeight = FontWeight.Light, + color = subtleColor, + modifier = Modifier.clickable { showInsertMenu = true }, + ) + Text( + composeVisibility.name.lowercase(), + fontSize = 12.sp, + color = subtleColor, + modifier = Modifier.clickable { showVisibilityPicker = true }, + ) + } + + Text( + "post", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (composeText.isNotBlank()) accent else subtleColor.copy(alpha = 0.3f), + modifier = Modifier + .then( + if (composeText.isNotBlank()) Modifier.clickable { + viewModel.createMemo(composeText, composeVisibility) + composeText = "" + } else Modifier + ) + .padding(horizontal = 4.dp, vertical = 4.dp), + ) + } + } + + Spacer( + Modifier.fillMaxWidth().height(1.dp) + .padding(start = 24.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) + ) + } + + if (memos.isEmpty() && !uiState.isRefreshing) { + item { + Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { + Text("no memos yet", fontSize = 15.sp, color = subtleColor) + } + } + } + + items(memos, key = { it.id }) { memo -> + MemoCard( + memo = memo, + onClick = { onMemoClick(memo.id) }, + serverUrl = serverUrl, + onPin = { viewModel.togglePin(memo) }, + onArchive = { viewModel.archiveMemo(memo.id) }, + onDelete = { viewModel.deleteMemo(memo.id) }, + onSave = { content, visibility -> + viewModel.updateMemo(memo.id, content, visibility) + }, + onReact = { emoji -> viewModel.reactToMemo(memo.id, emoji) }, + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt new file mode 100644 index 0000000..50dc742 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt @@ -0,0 +1,109 @@ +package com.avinal.memos.ui.memos + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.avinal.memos.api.ApiResult +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MemoListUiState( + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val searchQuery: String = "", + val isSearching: Boolean = false, + val error: String? = null, +) + +class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel() { + + private val _uiState = MutableStateFlow(MemoListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + private var searchJob: Job? = null + + val memos: StateFlow> = combine( + memoRepository.observeMemos(), + _searchQuery, + ) { allMemos, query -> + if (query.isBlank()) { + allMemos + } else { + val q = query.lowercase() + allMemos.filter { memo -> + memo.content.lowercase().contains(q) || + memo.tags.any { it.lowercase().contains(q) } || + memo.snippet.lowercase().contains(q) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun updateSearchQuery(query: String) { + _uiState.update { it.copy(searchQuery = query) } + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(300) + _searchQuery.value = query + } + } + + fun clearSearch() { + _uiState.update { it.copy(searchQuery = "") } + _searchQuery.value = "" + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true, error = null) } + when (val result = memoRepository.refreshMemos()) { + is ApiResult.Success -> _uiState.update { it.copy(isRefreshing = false) } + is ApiResult.Error -> _uiState.update { it.copy(isRefreshing = false, error = result.message) } + is ApiResult.NetworkError -> _uiState.update { + it.copy(isRefreshing = false, error = result.exception.message) + } + } + } + } + + fun loadMore() { + viewModelScope.launch { + _uiState.update { it.copy(isLoadingMore = true) } + memoRepository.loadNextPage() + _uiState.update { it.copy(isLoadingMore = false) } + } + } + + fun createMemo(content: String, visibility: com.avinal.memos.domain.MemoVisibility) { + viewModelScope.launch { memoRepository.createMemo(content, visibility) } + } + + fun deleteMemo(id: String) { + viewModelScope.launch { memoRepository.deleteMemo(id) } + } + + fun archiveMemo(id: String) { + viewModelScope.launch { memoRepository.archiveMemo(id) } + } + + fun togglePin(memo: Memo) { + viewModelScope.launch { memoRepository.updateMemo(memo.id, pinned = !memo.pinned) } + } + + fun updateMemo(id: String, content: String, visibility: com.avinal.memos.domain.MemoVisibility) { + viewModelScope.launch { memoRepository.updateMemo(id, content = content, visibility = visibility) } + } + + fun reactToMemo(memoId: String, emoji: String) { + viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) } + } +}