优化阅读记录界面,添加按日期查看记录的功能

This commit is contained in:
HapeLee
2025-11-28 00:14:13 +08:00
parent dfbdd8a3e6
commit 101a9fa878
6 changed files with 597 additions and 94 deletions
@@ -26,8 +26,8 @@ interface ReadRecordDao {
)
val allShow: List<ReadRecordShow>
@get:Query("select sum(readTime) from readRecord")
val allTime: Long
@Query("select sum(readTime) from readRecord")
suspend fun getTotalReadTime(): Long
@Query(
"""
@@ -138,10 +138,13 @@ interface ReadRecordDao {
SELECT * FROM readRecordSession
WHERE deviceId = :deviceId
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime DESC
ORDER BY startTime ASC
""")
suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'")
suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
// 清除会话记录
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
fun deleteSessionByName(bookName: String)
@@ -90,14 +90,6 @@ class ReadRecordRepository(
}
}
suspend fun getDailyDetails(deviceId: String, date: String): List<ReadRecordDetail> {
return dao.getDetailsByDate(deviceId, date)
}
suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List<ReadRecordSession> {
return dao.getSessionsByBookAndDate(deviceId, bookName, date)
}
suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
return if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead()
@@ -106,11 +98,6 @@ class ReadRecordRepository(
}
}
suspend fun getAllSessionsForDate(date: String): List<ReadRecordSession> {
val deviceId = getCurrentDeviceId()
return dao.getSessionsByDate(deviceId, date)
}
suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
return if (query.isBlank()) {
dao.getAllDetails()
@@ -119,6 +106,20 @@ class ReadRecordRepository(
}
}
suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List<ReadRecordDetail> {
val deviceId = getCurrentDeviceId()
return if (query.isBlank()) {
dao.getDetailsByDate(deviceId, dateString)
} else {
dao.searchDetailsByDate(deviceId, dateString, query)
}
}
suspend fun getAllSessionsByDate(dateString: String): List<ReadRecordSession> {
val deviceId = getCurrentDeviceId()
return dao.getSessionsByDate(deviceId, dateString)
}
suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail)
}
@@ -129,6 +130,8 @@ class ReadRecordRepository(
}
// 暴露总时长
val allTime: Long
get() = dao.allTime
suspend fun getTotalReadTime(): Long {
return dao.getTotalReadTime()
}
}
@@ -132,7 +132,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
private fun initAllTime() {
lifecycleScope.launch {
val allTime = withContext(IO) {
appDb.readRecordDao.allTime
appDb.readRecordDao.getTotalReadTime()
}
binding.tvReadingTime.text = formatDuring(allTime)
}
@@ -17,13 +17,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.data.appDb
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.utils.startActivityForBook
@@ -34,20 +30,26 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil
import io.legado.app.base.BaseComposeActivity
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.Calendar
import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
// 包含绘制时间线所需的上下文信息
data class TimelineItem(
@@ -93,6 +95,7 @@ fun ReadRecordScreen(
val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var showCalendar by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -105,7 +108,7 @@ fun ReadRecordScreen(
LaunchedEffect(searchText) {
if (showSearch) {
kotlinx.coroutines.delay(100L)
delay(100L)
viewModel.loadData(searchText)
}
}
@@ -155,6 +158,9 @@ fun ReadRecordScreen(
val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
Icon(icon, description)
}
IconButton(onClick = { showCalendar = !showCalendar }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar")
}
IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = null)
}
@@ -172,8 +178,6 @@ fun ReadRecordScreen(
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TotalTimeHeader(state.totalReadTime)
val isEmpty = when (displayMode) {
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
@@ -184,18 +188,76 @@ fun ReadRecordScreen(
animationSpec = tween(durationMillis = 500),
label = "ContentCrossfade"
) { isListEmpty ->
if (isListEmpty){
Box(
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
EmptyMessageView(
message = "没有记录"
)
}
} else {
LazyColumn {
item {
val selectedDate = state.selectedDate
if (selectedDate != null) {
val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
bookCount = distinctBooks.size,
totalTimeMillis = dailyTime,
bookNamesForCover = distinctBooks.take(3),
viewModel = viewModel,
onClick = { }
)
}
} else {
val allBooksCount = state.latestRecords.size
val totalTime = state.totalReadTime
if (allBooksCount > 0) {
ReadingSummaryCard(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
viewModel = viewModel,
onClick = { }
)
}
}
}
item {
AnimatedVisibility(visible = showCalendar) {
CalendarSection(
selectedDate = state.selectedDate,
onDateSelected = { date ->
viewModel.setSelectedDate(date)
showCalendar = false // 选择后自动收起日历
},
onClearDate = {
viewModel.setSelectedDate(null)
showCalendar = false // 清除后自动收起
}
)
}
if (isListEmpty){
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "没有记录"
)
}
}
}
when(displayMode){
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
@@ -226,9 +288,7 @@ fun ReadRecordScreen(
stickyHeader { DateHeader(date, dailyTotalTime) }
val timelineItems = sessions.mapIndexed { index, session ->
val previousSession = sessions.getOrNull(index - 1)
val showHeader = index == 0 || session.bookName != previousSession?.bookName
val showHeader = true
TimelineItem(session, showHeader)
}
@@ -253,7 +313,7 @@ fun ReadRecordScreen(
}
}
}
}
}
}
}
@@ -296,7 +356,7 @@ fun LatestReadItem(
color = Color.Gray
)
Text(
text = "最后阅读: ${DateUtil.format(java.util.Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -317,8 +377,8 @@ fun TimelineSessionItem(
coverPath = viewModel.getBookCover(session.bookName)
}
val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm")
val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm")
val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm")
val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
val duration = session.endTime - session.startTime
val nodeRadius = 4.dp
@@ -378,22 +438,21 @@ fun TimelineSessionItem(
Text(
text = session.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1
maxLines = 2
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
}
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
}
}
}
@@ -472,32 +531,129 @@ fun DateHeader(
}
@Composable
fun TotalTimeHeader(time: Long) {
fun CalendarSection(
selectedDate: LocalDate?,
onDateSelected: (LocalDate) -> Unit,
onClearDate: () -> Unit
) {
val effectiveInitialDate = selectedDate ?: LocalDate.now()
Calendar(
modifier = Modifier.padding(horizontal = 16.dp),
initialDate = effectiveInitialDate,
selectedDate = selectedDate,
onDateSelected = onDateSelected,
onClearDate = onClearDate
)
}
@Composable
fun ReadingSummaryCard(
title: String,
bookCount: Int,
totalTimeMillis: Long,
bookNamesForCover: List<String>,
viewModel: ReadRecordViewModel,
onClick: () -> Unit
) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name ->
viewModel.getBookCover(name)
}
}
val totalDurationMinutes = totalTimeMillis / 60000
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
)
) {
Column(
Row(
modifier = Modifier
.padding(12.dp),
horizontalAlignment = Alignment.Start
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "阅读时长",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = formatDuring(time),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = "已读 ",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "$bookCount",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
Text(
text = " 本书",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
val hours = totalDurationMinutes / 60
val minutes = totalDurationMinutes % 60
val timeString = if (hours > 0) "${hours}小时${minutes}分钟" else "${minutes}分钟"
Text(
text = "共阅读 $timeString",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (bookNamesForCover.isNotEmpty()) {
BookStackView(coverPaths = coverPaths)
}
}
}
}
@Composable
fun BookStackView(coverPaths: List<String?>) {
val xOffsetStep = 12.dp
val stackWidth = 48.dp + (xOffsetStep * (coverPaths.size - 1).coerceAtLeast(0))
Box(
modifier = Modifier
.width(stackWidth)
.height(72.dp),
contentAlignment = Alignment.CenterStart
) {
coverPaths.forEachIndexed { index, path ->
Box(
modifier = Modifier
.padding(start = xOffsetStep * index)
.zIndex(index.toFloat())
.rotate(if (index % 2 == 0) 3f else -3f)
) {
Surface(
shadowElevation = 4.dp,
shape = RoundedCornerShape(4.dp),
color = Color.Transparent
) {
Cover(path = path)
}
}
}
}
}
@@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.format.DateTimeFormatter
data class ReadRecordUiState(
val isLoading: Boolean = true,
@@ -22,7 +24,8 @@ data class ReadRecordUiState(
//每日所有阅读会话
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
//最后阅读列表
val latestRecords: List<ReadRecord> = emptyList()
val latestRecords: List<ReadRecord> = emptyList(),
val selectedDate: LocalDate? = null
)
enum class DisplayMode {
@@ -48,33 +51,104 @@ class ReadRecordViewModel(
_displayMode.value = mode
}
fun setSelectedDate(date: LocalDate?) {
_uiState.update { it.copy(selectedDate = date) }
loadData()
}
fun loadData(query: String = "") {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val selectedDate = _uiState.value.selectedDate
val details = repository.getAllRecordDetails(query)
val grouped = details.groupBy { it.date }
val uniqueDates = grouped.keys.toList()
val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
for (date in uniqueDates) {
val sessions = repository.getAllSessionsForDate(date)
timelineMap[date] = sessions
}
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.allTime }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
if (selectedDate != null) {
//筛选特定日期
val dateString = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
//某一天所有书籍的明细
val dailyDetails = repository.getAllRecordDetailsByDate(dateString, query)
val grouped = dailyDetails.groupBy { it.date }
//某一天所有会话
val allSessions = repository.getAllSessionsByDate(dateString)
val mergedSessions = mergeContinuousSessions(allSessions).reversed()
val timelineMap = mapOf(dateString to mergedSessions)
//LATEST
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
} else {
val details = repository.getAllRecordDetails(query)
val grouped = details.groupBy { it.date }
val uniqueDates = grouped.keys.toList()
val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
for (date in uniqueDates) {
val rawSessions = repository.getAllSessionsByDate(date)
val mergedSessions = mergeContinuousSessions(rawSessions).reversed()
timelineMap[date] = mergedSessions
}
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
}
}
}
/**
* 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟的会话。
* 只保留最晚结束的那条会话(更新其结束时间)。
* @param sessions 原始 ReadRecordSession 列表,按开始时间排序。
* @return 合并后的 ReadRecordSession 列表。
*/
fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
if (sessions.isEmpty()) return emptyList()
val mergedList = mutableListOf<ReadRecordSession>()
mergedList.add(sessions.first().copy())
val twentyMinutesInMillis = 2 * 60 * 1000L
for (i in 1 until sessions.size) {
val currentSession = sessions[i]
val lastMergedSession = mergedList.last()
val isSameBook = currentSession.bookName == lastMergedSession.bookName
val timeGap = currentSession.startTime - lastMergedSession.endTime
val isContinuous = timeGap <= twentyMinutesInMillis && timeGap >= 0
if (isSameBook && isContinuous) {
mergedList.removeAt(mergedList.lastIndex)
val updatedSession = lastMergedSession.copy(
endTime = currentSession.endTime
)
mergedList.add(updatedSession)
} else {
mergedList.add(currentSession.copy())
}
}
return mergedList
}
fun deleteDetail(detail: ReadRecordDetail) {
viewModelScope.launch {
repository.deleteDetail(detail)
@@ -0,0 +1,267 @@
package io.legado.app.ui.widget.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.*
const val START_PAGE_OFFSET = 100
/**
* @param modifier 外部修饰符
* @param initialDate 初始显示的月份日期
* @param onDateSelected 日期被选中时的回调
*/
@Composable
fun Calendar(
modifier: Modifier = Modifier,
initialDate: LocalDate = LocalDate.now(),
onDateSelected: (LocalDate) -> Unit,
selectedDate: LocalDate? = null,
onClearDate: (() -> Unit)? = null
) {
val baseMonth = remember(selectedDate) {
selectedDate?.withDayOfMonth(1) ?: initialDate.withDayOfMonth(1)
}
val pagerState = rememberPagerState(initialPage = START_PAGE_OFFSET) {
START_PAGE_OFFSET * 2
}
val coroutineScope = rememberCoroutineScope()
val currentMonth by remember {
derivedStateOf {
val pageOffset = pagerState.currentPage - START_PAGE_OFFSET
baseMonth.plusMonths(pageOffset.toLong())
}
}
Card(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(modifier = Modifier.padding(vertical = 12.dp).animateContentSize()) {
MonthNavigation(
currentMonth = currentMonth,
selectedDate = selectedDate,
onPreviousClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
},
onNextClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
},
onClearClick = onClearDate
)
Spacer(modifier = Modifier.height(8.dp))
DayOfWeekHeader()
Spacer(modifier = Modifier.height(8.dp))
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
verticalAlignment = Alignment.Top
) { page ->
val pageOffset = page - START_PAGE_OFFSET
val monthForPage = baseMonth.plusMonths(pageOffset.toLong())
MonthPageContent(
month = monthForPage,
selectedDate = selectedDate,
onDateClick = onDateSelected
)
}
}
}
}
@Composable
fun MonthPageContent(
month: LocalDate,
selectedDate: LocalDate?,
onDateClick: (LocalDate) -> Unit
) {
val datesInMonth = getDatesInMonth(month)
val firstDayOfWeek = month.dayOfWeek.value % 7
val offset = firstDayOfWeek
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier
.fillMaxWidth(),
userScrollEnabled = false,
horizontalArrangement = Arrangement.SpaceAround
) {
items(offset) {
Spacer(modifier = Modifier.size(36.dp))
}
items(datesInMonth) { date ->
DateItem(
date = date,
isSelected = date == selectedDate,
isToday = date == LocalDate.now(),
isCurrentMonth = date.month == month.month,
onClick = { onDateClick(date) }
)
}
}
}
@Composable
fun MonthNavigation(
currentMonth: LocalDate,
selectedDate: LocalDate?,
onPreviousClick: () -> Unit,
onNextClick: () -> Unit,
onClearClick: (() -> Unit)?
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = currentMonth.year.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
AnimatedTextLine(
text = currentMonth.format(DateTimeFormatter.ofPattern("MMMM", Locale.getDefault())),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (selectedDate != null && onClearClick != null) {
FilledTonalIconButton(onClick = onClearClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear Date Selection",
)
}
}
IconButton(onClick = onPreviousClick) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Previous Month",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = onNextClick) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = "Next Month",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
fun DayOfWeekHeader() {
val dayNames = listOf(
DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY
).map {
it.getDisplayName(TextStyle.SHORT, Locale.getDefault())
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
dayNames.forEach { dayName ->
Text(
text = dayName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(36.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
@Composable
fun DateItem(
date: LocalDate,
isSelected: Boolean,
isToday: Boolean,
isCurrentMonth: Boolean,
onClick: () -> Unit
) {
val buttonColors = when {
isSelected -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
isCurrentMonth -> ButtonDefaults.textButtonColors(
contentColor = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
else -> ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
val borderStroke = if (isToday && !isSelected) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
} else {
null
}
Button(
onClick = onClick,
modifier = Modifier.size(36.dp).padding(4.dp),
shape = CircleShape,
colors = buttonColors,
border = borderStroke,
contentPadding = PaddingValues(0.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)
) {
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
fun getDatesInMonth(month: LocalDate): List<LocalDate> {
val firstDayOfMonth = month.withDayOfMonth(1)
val lengthOfMonth = month.lengthOfMonth()
return (0 until lengthOfMonth).map { firstDayOfMonth.plusDays(it.toLong()) }
}