瀏覽代碼

新增日历,优化通知

sequoia00 1 月之前
父節點
當前提交
df10cee57b

+ 13 - 0
app/src/main/AndroidManifest.xml

@@ -3,6 +3,8 @@
 
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
+    <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
 
     <application
         android:allowBackup="true"
@@ -16,6 +18,17 @@
             android:name=".reminder.ReminderReceiver"
             android:exported="false" />
 
+        <receiver
+            android:name=".reminder.ReminderRescheduleReceiver"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+                <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
+                <action android:name="android.intent.action.TIME_SET" />
+                <action android:name="android.intent.action.TIMEZONE_CHANGED" />
+            </intent-filter>
+        </receiver>
+
         <activity
             android:name=".MainActivity"
             android:exported="true"

+ 3 - 0
app/src/main/java/com/tixing/app/data/GoalDao.kt

@@ -12,6 +12,9 @@ interface GoalDao {
     @Query("SELECT * FROM goals WHERE isActive = 1 ORDER BY createdAtMillis DESC")
     fun observeActiveGoals(): Flow<List<GoalEntity>>
 
+    @Query("SELECT * FROM goals WHERE isActive = 1 ORDER BY createdAtMillis DESC")
+    suspend fun getActiveGoals(): List<GoalEntity>
+
     @Query("SELECT * FROM goals ORDER BY createdAtMillis DESC")
     fun observeAllGoals(): Flow<List<GoalEntity>>
 

+ 10 - 3
app/src/main/java/com/tixing/app/data/HabitRepository.kt

@@ -44,6 +44,7 @@ class HabitRepository(
                 .mapNotNull { checkIn ->
                     val goal = goalMap[checkIn.goalId] ?: return@mapNotNull null
                     HistoryRow(
+                        goalId = goal.id,
                         goalTitle = goal.title,
                         category = goal.category,
                         dateKey = checkIn.dateKey,
@@ -65,18 +66,23 @@ class HabitRepository(
         return goalDao.getGoalById(id)!!
     }
 
+    suspend fun getActiveGoals(): List<GoalEntity> = goalDao.getActiveGoals()
+
     suspend fun deleteGoal(goal: GoalEntity) {
         goalDao.deleteGoal(goal)
     }
 
     suspend fun setTodayCheckIn(goalId: Int, isCompleted: Boolean) {
-        val todayKey = LocalDate.now().format(formatter)
-        val existing = checkInDao.getByGoalAndDate(goalId, todayKey)
+        setCheckIn(goalId, LocalDate.now().format(formatter), isCompleted)
+    }
+
+    suspend fun setCheckIn(goalId: Int, dateKey: String, isCompleted: Boolean) {
+        val existing = checkInDao.getByGoalAndDate(goalId, dateKey)
         if (existing == null) {
             checkInDao.insertCheckIn(
                 CheckInEntity(
                     goalId = goalId,
-                    dateKey = todayKey,
+                    dateKey = dateKey,
                     isCompleted = isCompleted
                 )
             )
@@ -111,6 +117,7 @@ class HabitRepository(
 }
 
 data class HistoryRow(
+    val goalId: Int,
     val goalTitle: String,
     val category: String,
     val dateKey: String,

+ 42 - 0
app/src/main/java/com/tixing/app/reminder/ReminderRescheduleReceiver.kt

@@ -0,0 +1,42 @@
+package com.tixing.app.reminder
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.tixing.app.data.AppDatabase
+import com.tixing.app.data.HabitRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class ReminderRescheduleReceiver : BroadcastReceiver() {
+    override fun onReceive(context: Context, intent: Intent) {
+        val action = intent.action.orEmpty()
+        if (
+            action != Intent.ACTION_BOOT_COMPLETED &&
+            action != Intent.ACTION_MY_PACKAGE_REPLACED &&
+            action != Intent.ACTION_TIMEZONE_CHANGED &&
+            action != Intent.ACTION_TIME_CHANGED
+        ) {
+            return
+        }
+
+        val appContext = context.applicationContext
+        goAsync().also { pendingResult ->
+            CoroutineScope(Dispatchers.IO).launch {
+                try {
+                    val database = AppDatabase.getInstance(appContext)
+                    val repository = HabitRepository(
+                        goalDao = database.goalDao(),
+                        checkInDao = database.checkInDao()
+                    )
+                    repository.getActiveGoals().forEach { goal ->
+                        ReminderScheduler.scheduleDailyReminder(appContext, goal)
+                    }
+                } finally {
+                    pendingResult.finish()
+                }
+            }
+        }
+    }
+}

+ 36 - 16
app/src/main/java/com/tixing/app/reminder/ReminderScheduler.kt

@@ -16,24 +16,15 @@ object ReminderScheduler {
 
     fun scheduleDailyReminder(context: Context, goal: GoalEntity) {
         val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
-        val intent = Intent(context, ReminderReceiver::class.java).apply {
-            putExtra(EXTRA_GOAL_ID, goal.id)
-            putExtra(EXTRA_GOAL_TITLE, goal.title)
-            putExtra(EXTRA_HOUR, goal.reminderHour)
-            putExtra(EXTRA_MINUTE, goal.reminderMinute)
-        }
-
-        val pendingIntent = PendingIntent.getBroadcast(
-            context,
-            goal.id,
-            intent,
-            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
-        )
+        val pendingIntent = buildPendingIntent(context, goal)
 
         val triggerAt = nextTriggerMillis(goal.reminderHour, goal.reminderMinute)
 
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
-            alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
+            alarmManager.setAlarmClock(
+                AlarmManager.AlarmClockInfo(triggerAt, buildOpenAppPendingIntent(context, goal.id)),
+                pendingIntent
+            )
             return
         }
 
@@ -41,15 +32,44 @@ object ReminderScheduler {
     }
 
     fun cancelReminder(context: Context, goalId: Int) {
-        val intent = Intent(context, ReminderReceiver::class.java)
         val pendingIntent = PendingIntent.getBroadcast(
             context,
             goalId,
-            intent,
+            Intent(context, ReminderReceiver::class.java),
             PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
         )
         val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
         alarmManager.cancel(pendingIntent)
+        pendingIntent.cancel()
+    }
+
+    private fun buildPendingIntent(context: Context, goal: GoalEntity): PendingIntent {
+        val intent = Intent(context, ReminderReceiver::class.java).apply {
+            putExtra(EXTRA_GOAL_ID, goal.id)
+            putExtra(EXTRA_GOAL_TITLE, goal.title)
+            putExtra(EXTRA_HOUR, goal.reminderHour)
+            putExtra(EXTRA_MINUTE, goal.reminderMinute)
+        }
+
+        return PendingIntent.getBroadcast(
+            context,
+            goal.id,
+            intent,
+            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
+    }
+
+    private fun buildOpenAppPendingIntent(context: Context, goalId: Int): PendingIntent {
+        val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+            ?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) }
+            ?: Intent()
+
+        return PendingIntent.getActivity(
+            context,
+            goalId + 10_000,
+            openIntent,
+            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
     }
 
     private fun nextTriggerMillis(hour: Int, minute: Int): Long {

+ 213 - 36
app/src/main/java/com/tixing/app/ui/AppScreen.kt

@@ -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()) {

+ 46 - 0
app/src/main/java/com/tixing/app/ui/AppViewModel.kt

@@ -1,6 +1,8 @@
 package com.tixing.app.ui
 
 import android.app.Application
+import android.app.AlarmManager
+import android.content.Context
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
 import com.tixing.app.data.AppDatabase
@@ -14,12 +16,16 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.YearMonth
+import java.time.format.DateTimeFormatter
 
 class AppViewModel(application: Application) : AndroidViewModel(application) {
     private val repository = HabitRepository(
         goalDao = AppDatabase.getInstance(application).goalDao(),
         checkInDao = AppDatabase.getInstance(application).checkInDao()
     )
+    private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
 
     private val _goalProgress = MutableStateFlow<List<GoalProgress>>(emptyList())
     val goalProgress: StateFlow<List<GoalProgress>> = _goalProgress.asStateFlow()
@@ -27,6 +33,12 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
     private val _history = MutableStateFlow<List<HistoryRow>>(emptyList())
     val history: StateFlow<List<HistoryRow>> = _history.asStateFlow()
 
+    private val _calendarMonth = MutableStateFlow(YearMonth.now())
+    val calendarMonth: StateFlow<YearMonth> = _calendarMonth.asStateFlow()
+
+    private val _selectedDate = MutableStateFlow(LocalDate.now())
+    val selectedDate: StateFlow<LocalDate> = _selectedDate.asStateFlow()
+
     init {
         repository.observeGoalProgress()
             .onEach { _goalProgress.value = it }
@@ -35,6 +47,8 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
         repository.observeHistory()
             .onEach { _history.value = it }
             .launchIn(viewModelScope)
+
+        rescheduleAllReminders()
     }
 
     fun addGoal(title: String, category: String, reminderHour: Int, reminderMinute: Int) {
@@ -56,4 +70,36 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
             repository.setTodayCheckIn(goalId, completed)
         }
     }
+
+    fun toggleCheckIn(goalId: Int, date: LocalDate, completed: Boolean) {
+        viewModelScope.launch {
+            repository.setCheckIn(goalId, date.format(formatter), completed)
+        }
+    }
+
+    fun selectDate(date: LocalDate) {
+        _selectedDate.value = date
+        _calendarMonth.value = YearMonth.from(date)
+    }
+
+    fun previousMonth() {
+        _calendarMonth.value = _calendarMonth.value.minusMonths(1)
+    }
+
+    fun nextMonth() {
+        _calendarMonth.value = _calendarMonth.value.plusMonths(1)
+    }
+
+    private fun rescheduleAllReminders() {
+        viewModelScope.launch {
+            repository.getActiveGoals().forEach { goal ->
+                ReminderScheduler.scheduleDailyReminder(getApplication(), goal)
+            }
+        }
+    }
+
+    fun canScheduleExactAlarms(): Boolean {
+        val alarmManager = getApplication<Application>().getSystemService(Context.ALARM_SERVICE) as AlarmManager
+        return alarmManager.canScheduleExactAlarms()
+    }
 }

+ 6 - 0
app/src/main/java/com/tixing/app/util/DateUtils.kt

@@ -1,11 +1,13 @@
 package com.tixing.app.util
 
 import java.time.LocalDate
+import java.time.YearMonth
 import java.time.format.DateTimeFormatter
 
 object DateUtils {
     private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
     private val displayFormatter = DateTimeFormatter.ofPattern("MM月dd日")
+    private val monthFormatter = DateTimeFormatter.ofPattern("yyyy年MM月")
 
     fun todayKey(): String = LocalDate.now().format(dateFormatter)
 
@@ -14,6 +16,10 @@ object DateUtils {
         return date.format(displayFormatter)
     }
 
+    fun toDateKey(date: LocalDate): String = date.format(dateFormatter)
+
+    fun toMonthLabel(month: YearMonth): String = month.format(monthFormatter)
+
     fun lastNDaysKeys(days: Int): List<String> {
         val today = LocalDate.now()
         return (0 until days).map { offset ->