1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add memo screens: panorama feed, detail, editor, explorer

MainScreen: WP Panorama-style navigation with parallax-scrolling pivot
headers (explore/memos/tasks/settings). Headers drift at 50% of swipe
speed. Active title in accent color, inactive fades with distance.

MemoListScreen: cardless feed with inline compose bar (expandable text
field, + insert menu, visibility picker, accent "post" button).
Pull-to-refresh, date-based filtering from explorer calendar.

MemoDetailScreen: full content view with markdown rendering, attachment
display, reactions. Back link, retry on load failure.

MemoEditorScreen: Metro-style editor with 32sp Light title, accent
underline text field, dirty state tracking, discard confirmation dialog.

ExplorerPage: activity calendar (month heatmap with navigation arrows),
search bar, tag list with counts. Calendar dates link to memos page
with date filter instead of opening detail screen.

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-19 17:11:03 +05:30
parent 0377095eaa
commit 9b5e18939b
7 changed files with 1145 additions and 0 deletions
@@ -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<String?>(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))
}
}
@@ -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))
}
}
}
}
}
@@ -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<Boolean> = _isLoading.asStateFlow()
val memo: StateFlow<Memo?> = 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
}
}
}
@@ -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))
}
}
}
@@ -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<MemoEditorUiState> = _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")
}
}
}
}
}
@@ -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) },
)
}
}
}
}
}
@@ -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<MemoListUiState> = _uiState.asStateFlow()
private val _searchQuery = MutableStateFlow("")
private var searchJob: Job? = null
val memos: StateFlow<List<Memo>> = 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) }
}
}