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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user