diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index 0bb4470b8..e60548246 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -26,8 +26,8 @@ interface ReadRecordDao { ) val allShow: List - @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 + @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'") + suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List + // 清除会话记录 @Query("DELETE FROM readRecordSession WHERE bookName = :bookName") fun deleteSessionByName(bookName: String) diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index d1cbbd84f..4e5f27639 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -90,14 +90,6 @@ class ReadRecordRepository( } } - suspend fun getDailyDetails(deviceId: String, date: String): List { - return dao.getDetailsByDate(deviceId, date) - } - - suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List { - return dao.getSessionsByBookAndDate(deviceId, bookName, date) - } - suspend fun getLatestReadRecords(query: String = ""): List { return if (query.isBlank()) { dao.getAllReadRecordsSortedByLastRead() @@ -106,11 +98,6 @@ class ReadRecordRepository( } } - suspend fun getAllSessionsForDate(date: String): List { - val deviceId = getCurrentDeviceId() - return dao.getSessionsByDate(deviceId, date) - } - suspend fun getAllRecordDetails(query: String = ""): List { return if (query.isBlank()) { dao.getAllDetails() @@ -119,6 +106,20 @@ class ReadRecordRepository( } } + suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List { + val deviceId = getCurrentDeviceId() + return if (query.isBlank()) { + dao.getDetailsByDate(deviceId, dateString) + } else { + dao.searchDetailsByDate(deviceId, dateString, query) + } + } + + suspend fun getAllSessionsByDate(dateString: String): List { + 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() + } + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt index 138b44008..3fb938b4f 100644 --- a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt @@ -132,7 +132,7 @@ class ReadRecordActivity : BaseActivity() { private fun initAllTime() { lifecycleScope.launch { val allTime = withContext(IO) { - appDb.readRecordDao.allTime + appDb.readRecordDao.getTotalReadTime() } binding.tvReadingTime.text = formatDuring(allTime) } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt index 9abb90511..3f1f05570 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt @@ -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, + 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) { + 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) + } + } } } } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index c4938e51e..7c576bb6d 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -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> = emptyMap(), //最后阅读列表 - val latestRecords: List = emptyList() + val latestRecords: List = 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>() - 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>() + + 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): List { + if (sessions.isEmpty()) return emptyList() + + val mergedList = mutableListOf() + 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) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt b/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt new file mode 100644 index 000000000..b65c63020 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt @@ -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 { + val firstDayOfMonth = month.withDayOfMonth(1) + val lengthOfMonth = month.lengthOfMonth() + return (0 until lengthOfMonth).map { firstDayOfMonth.plusDays(it.toLong()) } +} \ No newline at end of file