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