|
|
@@ -2,6 +2,7 @@ package com.tixing.app.ui
|
|
|
|
|
|
import android.app.TimePickerDialog
|
|
|
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
|
|
|
@@ -15,13 +16,17 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|
|
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.lazy.LazyColumn
|
|
|
import androidx.compose.foundation.lazy.items
|
|
|
import androidx.compose.foundation.rememberScrollState
|
|
|
+import androidx.compose.foundation.shape.CircleShape
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
|
import androidx.compose.foundation.verticalScroll
|
|
|
import androidx.compose.material.icons.Icons
|
|
|
import androidx.compose.material.icons.filled.Add
|
|
|
+import androidx.compose.material.icons.filled.ChevronLeft
|
|
|
+import androidx.compose.material.icons.filled.ChevronRight
|
|
|
import androidx.compose.material.icons.filled.CheckCircle
|
|
|
import androidx.compose.material.icons.filled.DateRange
|
|
|
import androidx.compose.material.icons.filled.Delete
|
|
|
@@ -62,11 +67,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
|
import com.tixing.app.data.GoalProgress
|
|
|
import com.tixing.app.data.HistoryRow
|
|
|
import com.tixing.app.util.DateUtils
|
|
|
+import java.time.LocalDate
|
|
|
+import java.time.YearMonth
|
|
|
|
|
|
private enum class HomeTab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
|
|
|
TODAY("今日", Icons.Default.CheckCircle),
|
|
|
GOALS("目标", Icons.Default.ListAlt),
|
|
|
- HISTORY("历史", Icons.Default.DateRange),
|
|
|
+ HISTORY("日历", Icons.Default.DateRange),
|
|
|
STATS("统计", Icons.Default.QueryStats)
|
|
|
}
|
|
|
|
|
|
@@ -74,6 +81,8 @@ private enum class HomeTab(val label: String, val icon: androidx.compose.ui.grap
|
|
|
fun AppScreen(vm: AppViewModel) {
|
|
|
val goals by vm.goalProgress.collectAsStateWithLifecycle()
|
|
|
val history by vm.history.collectAsStateWithLifecycle()
|
|
|
+ val calendarMonth by vm.calendarMonth.collectAsStateWithLifecycle()
|
|
|
+ val selectedDate by vm.selectedDate.collectAsStateWithLifecycle()
|
|
|
var tab by remember { mutableStateOf(HomeTab.TODAY) }
|
|
|
var showAddDialog by remember { mutableStateOf(false) }
|
|
|
|
|
|
@@ -134,7 +143,17 @@ fun AppScreen(vm: AppViewModel) {
|
|
|
when (tab) {
|
|
|
HomeTab.TODAY -> TodayScreen(goals = goals, onToggle = vm::toggleToday)
|
|
|
HomeTab.GOALS -> GoalsScreen(goals = goals, onDelete = vm::deleteGoal)
|
|
|
- HomeTab.HISTORY -> HistoryScreen(history = history)
|
|
|
+ HomeTab.HISTORY -> CalendarScreen(
|
|
|
+ goals = goals,
|
|
|
+ history = history,
|
|
|
+ month = calendarMonth,
|
|
|
+ selectedDate = selectedDate,
|
|
|
+ canScheduleExactAlarms = vm.canScheduleExactAlarms(),
|
|
|
+ onPreviousMonth = vm::previousMonth,
|
|
|
+ onNextMonth = vm::nextMonth,
|
|
|
+ onSelectDate = vm::selectDate,
|
|
|
+ onToggleDate = vm::toggleCheckIn
|
|
|
+ )
|
|
|
HomeTab.STATS -> StatsScreen(goals = goals)
|
|
|
}
|
|
|
}
|
|
|
@@ -288,49 +307,161 @@ private fun GoalsScreen(
|
|
|
}
|
|
|
|
|
|
@Composable
|
|
|
-private fun HistoryScreen(history: List<HistoryRow>) {
|
|
|
- if (history.isEmpty()) {
|
|
|
- EmptyTip("暂无历史记录", "完成一次打卡后,这里会出现你的轨迹")
|
|
|
+private fun CalendarScreen(
|
|
|
+ goals: List<GoalProgress>,
|
|
|
+ history: List<HistoryRow>,
|
|
|
+ month: YearMonth,
|
|
|
+ selectedDate: LocalDate,
|
|
|
+ canScheduleExactAlarms: Boolean,
|
|
|
+ onPreviousMonth: () -> Unit,
|
|
|
+ onNextMonth: () -> Unit,
|
|
|
+ onSelectDate: (LocalDate) -> Unit,
|
|
|
+ onToggleDate: (Int, LocalDate, Boolean) -> Unit
|
|
|
+) {
|
|
|
+ if (goals.isEmpty()) {
|
|
|
+ EmptyTip("暂无可补打卡目标", "先去“目标”页添加目标")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- val grouped = history.groupBy { it.dateKey }
|
|
|
+ val completedByDate = history
|
|
|
+ .filter { it.isCompleted }
|
|
|
+ .groupBy { it.dateKey }
|
|
|
+ .mapValues { entry -> entry.value.map { it.goalId }.toSet() }
|
|
|
+ val selectedKey = DateUtils.toDateKey(selectedDate)
|
|
|
+ val selectedCompletedGoalIds = completedByDate[selectedKey].orEmpty()
|
|
|
|
|
|
- LazyColumn(
|
|
|
- contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
|
|
|
+ Column(
|
|
|
+ modifier = Modifier
|
|
|
+ .fillMaxSize()
|
|
|
+ .verticalScroll(rememberScrollState())
|
|
|
+ .padding(horizontal = 16.dp, vertical = 10.dp),
|
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
|
) {
|
|
|
- grouped.forEach { (date, rows) ->
|
|
|
- item(key = date) {
|
|
|
- Card(shape = RoundedCornerShape(18.dp)) {
|
|
|
- Column(modifier = Modifier.padding(14.dp)) {
|
|
|
+ if (!canScheduleExactAlarms) {
|
|
|
+ Card(
|
|
|
+ shape = RoundedCornerShape(18.dp),
|
|
|
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
|
|
+ ) {
|
|
|
+ Text(
|
|
|
+ text = "提醒权限提示:当前系统未授予精确闹钟权限,提醒会尽量触发但可能延后。请在系统设置里允许本应用的“闹钟和提醒/精确闹钟”。",
|
|
|
+ modifier = Modifier.padding(14.dp),
|
|
|
+ style = MaterialTheme.typography.bodyMedium,
|
|
|
+ color = MaterialTheme.colorScheme.onErrorContainer
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Card(shape = RoundedCornerShape(22.dp)) {
|
|
|
+ Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
|
+ Row(
|
|
|
+ modifier = Modifier.fillMaxWidth(),
|
|
|
+ horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
+ verticalAlignment = Alignment.CenterVertically
|
|
|
+ ) {
|
|
|
+ IconButton(onClick = onPreviousMonth) {
|
|
|
+ Icon(Icons.Default.ChevronLeft, contentDescription = "上个月")
|
|
|
+ }
|
|
|
+ Text(
|
|
|
+ text = DateUtils.toMonthLabel(month),
|
|
|
+ style = MaterialTheme.typography.titleLarge,
|
|
|
+ fontWeight = FontWeight.Bold
|
|
|
+ )
|
|
|
+ IconButton(onClick = onNextMonth) {
|
|
|
+ Icon(Icons.Default.ChevronRight, contentDescription = "下个月")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
|
|
+ listOf("一", "二", "三", "四", "五", "六", "日").forEach { weekday ->
|
|
|
Text(
|
|
|
- text = DateUtils.toDisplayDate(date),
|
|
|
- style = MaterialTheme.typography.titleMedium,
|
|
|
- fontWeight = FontWeight.Bold
|
|
|
+ text = weekday,
|
|
|
+ modifier = Modifier.weight(1f),
|
|
|
+ style = MaterialTheme.typography.bodySmall,
|
|
|
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
|
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ MonthGrid(
|
|
|
+ month = month,
|
|
|
+ selectedDate = selectedDate,
|
|
|
+ completedByDate = completedByDate,
|
|
|
+ onSelectDate = onSelectDate
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Card(shape = RoundedCornerShape(20.dp)) {
|
|
|
+ Column(modifier = Modifier.padding(14.dp)) {
|
|
|
+ Text(
|
|
|
+ text = "${DateUtils.toDisplayDate(selectedKey)} 补打卡",
|
|
|
+ style = MaterialTheme.typography.titleMedium,
|
|
|
+ fontWeight = FontWeight.Bold
|
|
|
+ )
|
|
|
+ Spacer(modifier = Modifier.height(8.dp))
|
|
|
+ goals.forEachIndexed { index, item ->
|
|
|
+ val checked = selectedCompletedGoalIds.contains(item.goal.id)
|
|
|
+ Row(
|
|
|
+ modifier = Modifier
|
|
|
+ .fillMaxWidth()
|
|
|
+ .padding(vertical = 6.dp),
|
|
|
+ horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
+ verticalAlignment = Alignment.CenterVertically
|
|
|
+ ) {
|
|
|
+ Column(modifier = Modifier.weight(1f)) {
|
|
|
+ Text(item.goal.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
|
|
+ Text(
|
|
|
+ item.goal.category,
|
|
|
+ style = MaterialTheme.typography.bodySmall,
|
|
|
+ color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
|
+ )
|
|
|
+ }
|
|
|
+ Checkbox(
|
|
|
+ checked = checked,
|
|
|
+ onCheckedChange = { onToggleDate(item.goal.id, selectedDate, it) }
|
|
|
)
|
|
|
- Spacer(modifier = Modifier.height(8.dp))
|
|
|
- rows.forEachIndexed { index, row ->
|
|
|
- Row(
|
|
|
- modifier = Modifier
|
|
|
- .fillMaxWidth()
|
|
|
- .padding(vertical = 6.dp),
|
|
|
- horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
- verticalAlignment = Alignment.CenterVertically
|
|
|
- ) {
|
|
|
- Text(
|
|
|
- text = "${row.goalTitle}(${row.category})",
|
|
|
- style = MaterialTheme.typography.bodyMedium
|
|
|
- )
|
|
|
- Text(
|
|
|
- text = if (row.isCompleted) "已完成" else "未完成",
|
|
|
- color = if (row.isCompleted) Color(0xFF227D5F) else MaterialTheme.colorScheme.error,
|
|
|
- style = MaterialTheme.typography.bodyMedium,
|
|
|
- fontWeight = FontWeight.SemiBold
|
|
|
- )
|
|
|
- }
|
|
|
- if (index != rows.lastIndex) HorizontalDivider()
|
|
|
+ }
|
|
|
+ if (index != goals.lastIndex) HorizontalDivider()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@Composable
|
|
|
+private fun MonthGrid(
|
|
|
+ month: YearMonth,
|
|
|
+ selectedDate: LocalDate,
|
|
|
+ completedByDate: Map<String, Set<Int>>,
|
|
|
+ onSelectDate: (LocalDate) -> Unit
|
|
|
+) {
|
|
|
+ val firstDay = month.atDay(1)
|
|
|
+ val firstOffset = firstDay.dayOfWeek.value - 1
|
|
|
+ val days = month.lengthOfMonth()
|
|
|
+ val cells = firstOffset + days
|
|
|
+ val rows = (cells + 6) / 7
|
|
|
+
|
|
|
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
|
+ repeat(rows) { rowIndex ->
|
|
|
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
|
|
+ repeat(7) { columnIndex ->
|
|
|
+ val cellIndex = rowIndex * 7 + columnIndex
|
|
|
+ val dayNumber = cellIndex - firstOffset + 1
|
|
|
+ if (dayNumber in 1..days) {
|
|
|
+ val date = month.atDay(dayNumber)
|
|
|
+ val dateKey = DateUtils.toDateKey(date)
|
|
|
+ val hasCompleted = completedByDate[dateKey].orEmpty().isNotEmpty()
|
|
|
+ Box(modifier = Modifier.weight(1f)) {
|
|
|
+ CalendarDayCell(
|
|
|
+ date = date,
|
|
|
+ selected = date == selectedDate,
|
|
|
+ hasCompleted = hasCompleted,
|
|
|
+ onClick = { onSelectDate(date) }
|
|
|
+ )
|
|
|
}
|
|
|
+ } else {
|
|
|
+ Spacer(modifier = Modifier.weight(1f))
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -338,6 +469,52 @@ private fun HistoryScreen(history: List<HistoryRow>) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+@Composable
|
|
|
+private fun CalendarDayCell(
|
|
|
+ date: LocalDate,
|
|
|
+ selected: Boolean,
|
|
|
+ hasCompleted: Boolean,
|
|
|
+ onClick: () -> Unit
|
|
|
+) {
|
|
|
+ val background = when {
|
|
|
+ selected -> MaterialTheme.colorScheme.primary
|
|
|
+ hasCompleted -> MaterialTheme.colorScheme.primaryContainer
|
|
|
+ else -> Color.Transparent
|
|
|
+ }
|
|
|
+ val textColor = when {
|
|
|
+ selected -> MaterialTheme.colorScheme.onPrimary
|
|
|
+ else -> MaterialTheme.colorScheme.onSurface
|
|
|
+ }
|
|
|
+
|
|
|
+ Box(
|
|
|
+ modifier = Modifier
|
|
|
+ .height(42.dp)
|
|
|
+ .padding(horizontal = 2.dp)
|
|
|
+ .background(background, CircleShape)
|
|
|
+ .clickable(onClick = onClick),
|
|
|
+ contentAlignment = Alignment.Center
|
|
|
+ ) {
|
|
|
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
|
+ Text(
|
|
|
+ text = date.dayOfMonth.toString(),
|
|
|
+ color = textColor,
|
|
|
+ style = MaterialTheme.typography.bodyMedium,
|
|
|
+ fontWeight = if (selected || hasCompleted) FontWeight.Bold else FontWeight.Normal
|
|
|
+ )
|
|
|
+ if (hasCompleted) {
|
|
|
+ Box(
|
|
|
+ modifier = Modifier
|
|
|
+ .size(4.dp)
|
|
|
+ .background(
|
|
|
+ if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
|
|
|
+ CircleShape
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
@Composable
|
|
|
private fun StatsScreen(goals: List<GoalProgress>) {
|
|
|
if (goals.isEmpty()) {
|