Skip to content

Commit 0fbc5a9

Browse files
committed
🔧 feat: added calendar heatmap
Signed-off-by: shub39 <cptnshubham39@gmail.com>
1 parent 74631a0 commit 0fbc5a9

10 files changed

Lines changed: 696 additions & 182 deletions

File tree

androidApp/src/main/java/com/shub39/grit/viewmodel/HabitViewModel.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import com.shub39.grit.core.habits.presentation.HabitState
2525
import com.shub39.grit.core.habits.presentation.HabitsAction
2626
import com.shub39.grit.domain.AlarmScheduler
2727
import com.shub39.grit.domain.SettingsDatastore
28-
import kotlin.time.ExperimentalTime
2928
import kotlinx.coroutines.Job
3029
import kotlinx.coroutines.flow.MutableStateFlow
3130
import kotlinx.coroutines.flow.SharingStarted
@@ -114,15 +113,19 @@ class HabitViewModel(
114113
_state.update { habitState ->
115114
habitState.copy(
116115
overallAnalytics =
117-
habitState.overallAnalytics.copy(completedHabits = completedHabits)
116+
habitState.overallAnalytics.copy(
117+
completedHabits =
118+
if (completedHabits.isNotEmpty()) {
119+
action.date to completedHabits
120+
} else null
121+
)
118122
)
119123
}
120124
}
121125
}
122126
}
123127
}
124128

125-
@OptIn(ExperimentalTime::class)
126129
private fun observeHabitStatuses() {
127130
habitStatusJob?.cancel()
128131
habitStatusJob =
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
3-
</resources>
3+
</resources>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (C) 2026 Shubham Gorai
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package com.shub39.grit.core.habits.domain
18+
19+
import grit.shared.generated.resources.Res
20+
import grit.shared.generated.resources.monthly
21+
import grit.shared.generated.resources.yearly
22+
import org.jetbrains.compose.resources.StringResource
23+
24+
enum class CalendarType {
25+
MONTH,
26+
YEAR;
27+
28+
companion object {
29+
fun CalendarType.toStringRes(): StringResource {
30+
return when (this) {
31+
MONTH -> Res.string.monthly
32+
YEAR -> Res.string.yearly
33+
}
34+
}
35+
}
36+
}

shared/src/commonMain/kotlin/com/shub39/grit/core/habits/domain/OverallAnalytics.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@ import kotlinx.serialization.Serializable
2121

2222
typealias HeatMapData = Map<LocalDate, Int>
2323

24-
@Serializable
25-
data class HabitRanking(val title: String, val consistency: Float)
24+
@Serializable data class HabitRanking(val title: String, val consistency: Float)
2625

2726
@Serializable
2827
data class OverallAnalytics(
2928
val heatMapData: HeatMapData = emptyMap(),
3029
val weekDayFrequencyData: WeekDayFrequencyData = emptyMap(),
31-
val completedHabits: List<String> = emptyList(),
30+
val completedHabits: Pair<LocalDate, List<String>>? = null,
3231
val consistency: Float = 0f,
3332
val topHabits: List<HabitRanking> = emptyList(),
3433
)

shared/src/commonMain/kotlin/com/shub39/grit/core/habits/presentation/ui/HabitsGraph.kt

Lines changed: 109 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@
1616
*/
1717
package com.shub39.grit.core.habits.presentation.ui
1818

19-
import androidx.compose.animation.AnimatedContent
2019
import androidx.compose.animation.AnimatedVisibility
2120
import androidx.compose.animation.fadeIn
2221
import androidx.compose.animation.fadeOut
23-
import androidx.compose.animation.togetherWith
2422
import androidx.compose.foundation.background
2523
import androidx.compose.foundation.layout.Box
2624
import androidx.compose.foundation.layout.Column
@@ -41,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
4139
import androidx.compose.material3.TopAppBarScrollBehavior
4240
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
4341
import androidx.compose.runtime.Composable
42+
import androidx.compose.runtime.LaunchedEffect
4443
import androidx.compose.runtime.derivedStateOf
4544
import androidx.compose.runtime.getValue
4645
import androidx.compose.runtime.remember
@@ -58,6 +57,7 @@ import com.shub39.grit.core.habits.presentation.HabitsAction
5857
import com.shub39.grit.core.habits.presentation.ui.component.HabitListFABs
5958
import com.shub39.grit.core.habits.presentation.ui.sections.AnalyticsPage
6059
import com.shub39.grit.core.habits.presentation.ui.sections.Calendar
60+
import com.shub39.grit.core.habits.presentation.ui.sections.CalendarHeatMap
6161
import com.shub39.grit.core.habits.presentation.ui.sections.HabitsList
6262
import com.shub39.grit.core.habits.presentation.ui.sections.OverallAnalytics
6363
import com.shub39.grit.core.navigation.horizontalTransitionMetadata
@@ -81,6 +81,8 @@ private sealed interface HabitRoutes : NavKey {
8181
@Serializable data object OverallAnalytics : HabitRoutes
8282

8383
@Serializable data object Calendar : HabitRoutes
84+
85+
@Serializable data object CalendarHeatMap : HabitRoutes
8486
}
8587

8688
private val config = SavedStateConfiguration {
@@ -90,6 +92,7 @@ private val config = SavedStateConfiguration {
9092
subclass(HabitRoutes.HabitAnalytics::class, HabitRoutes.HabitAnalytics.serializer())
9193
subclass(HabitRoutes.OverallAnalytics::class, HabitRoutes.OverallAnalytics.serializer())
9294
subclass(HabitRoutes.Calendar::class, HabitRoutes.Calendar.serializer())
95+
subclass(HabitRoutes.CalendarHeatMap::class, HabitRoutes.CalendarHeatMap.serializer())
9396
}
9497
}
9598
}
@@ -180,6 +183,9 @@ fun HabitsGraph(
180183
isUserSubscribed = isUserSubscribed,
181184
onAction = onAction,
182185
modifier = Modifier.background(MaterialTheme.colorScheme.background),
186+
onNavigateToCalendarHeatMap = {
187+
backstack.add(HabitRoutes.CalendarHeatMap)
188+
},
183189
)
184190
}
185191

@@ -195,6 +201,19 @@ fun HabitsGraph(
195201
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
196202
)
197203
}
204+
205+
entry<HabitRoutes.CalendarHeatMap>(metadata = horizontalTransitionMetadata()) {
206+
CalendarHeatMap(
207+
state = state,
208+
onNavigateBack = {
209+
if (backstack.size != 1) backstack.removeLastOrNull()
210+
},
211+
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
212+
onChangeSelectedDay = {
213+
onAction(HabitsAction.FetchCompletedHabitsForDate(it))
214+
},
215+
)
216+
}
198217
},
199218
)
200219
} else {
@@ -256,68 +275,96 @@ private fun ExpandedScreen(
256275
shape = RoundedCornerShape(topStart = 28.dp),
257276
modifier = Modifier.weight(1f),
258277
) {
259-
val motionScheme = MaterialTheme.motionScheme
260-
AnimatedContent(
261-
targetState = state.analyticsHabitId,
262-
transitionSpec = {
263-
fadeIn(motionScheme.fastEffectsSpec()) togetherWith
264-
fadeOut(motionScheme.fastEffectsSpec())
265-
},
266-
) { analyticsHabitId ->
267-
if (analyticsHabitId != null) {
268-
val backstack = rememberNavBackStack(config, HabitRoutes.HabitAnalytics)
278+
val backstack = rememberNavBackStack(config, HabitRoutes.HabitAnalytics)
269279

270-
NavDisplay(
271-
backStack = backstack,
272-
entryProvider =
273-
entryProvider {
274-
entry<HabitRoutes.HabitAnalytics> {
275-
AnalyticsPage(
276-
state = state,
277-
onAction = onAction,
278-
onNavigateBack = {
279-
onAction(HabitsAction.PrepareAnalytics(null))
280-
},
281-
onNavigateToPaywall = onNavigateToPaywall,
282-
onNavigateToCalendar = {
283-
backstack.add(HabitRoutes.Calendar)
284-
},
285-
isUserSubscribed = isUserSubscribed,
286-
)
287-
}
288-
289-
entry<HabitRoutes.Calendar>(
290-
metadata = horizontalTransitionMetadata()
291-
) {
292-
Calendar(
293-
state = state,
294-
onNavigateBack = {
295-
if (backstack.size != 1)
296-
backstack.removeLastOrNull()
297-
},
298-
onDateClick = { habit, date ->
299-
onAction(HabitsAction.InsertStatus(habit, date))
300-
},
301-
modifier =
302-
Modifier.background(
303-
MaterialTheme.colorScheme
304-
.surfaceContainerHighest
305-
),
306-
)
307-
}
308-
},
309-
)
310-
} else {
311-
OverallAnalytics(
312-
state = state,
313-
onNavigateBack = {},
314-
showNavigateBack = false,
315-
onNavigateToPaywall = onNavigateToPaywall,
316-
isUserSubscribed = isUserSubscribed,
317-
onAction = onAction,
318-
)
319-
}
280+
LaunchedEffect(state.analyticsHabitId) {
281+
backstack.add(
282+
if (state.analyticsHabitId != null) {
283+
HabitRoutes.HabitAnalytics
284+
} else {
285+
HabitRoutes.OverallAnalytics
286+
}
287+
)
320288
}
289+
290+
NavDisplay(
291+
backStack = backstack,
292+
entryProvider =
293+
entryProvider {
294+
entry<HabitRoutes.HabitAnalytics>(
295+
metadata = verticalTransitionMetadata()
296+
) {
297+
AnalyticsPage(
298+
state = state,
299+
onAction = onAction,
300+
onNavigateBack = {
301+
onAction(HabitsAction.PrepareAnalytics(null))
302+
},
303+
onNavigateToPaywall = onNavigateToPaywall,
304+
onNavigateToCalendar = { backstack.add(HabitRoutes.Calendar) },
305+
isUserSubscribed = isUserSubscribed,
306+
modifier =
307+
Modifier.background(
308+
MaterialTheme.colorScheme.surfaceContainerHighest
309+
),
310+
)
311+
}
312+
313+
entry<HabitRoutes.Calendar>(metadata = horizontalTransitionMetadata()) {
314+
Calendar(
315+
state = state,
316+
onNavigateBack = {
317+
if (backstack.size != 1) backstack.removeLastOrNull()
318+
},
319+
onDateClick = { habit, date ->
320+
onAction(HabitsAction.InsertStatus(habit, date))
321+
},
322+
modifier =
323+
Modifier.background(
324+
MaterialTheme.colorScheme.surfaceContainerHighest
325+
),
326+
)
327+
}
328+
329+
entry<HabitRoutes.OverallAnalytics>(
330+
metadata = verticalTransitionMetadata()
331+
) {
332+
OverallAnalytics(
333+
state = state,
334+
onNavigateBack = {},
335+
showNavigateBack = false,
336+
onNavigateToPaywall = onNavigateToPaywall,
337+
isUserSubscribed = isUserSubscribed,
338+
onAction = onAction,
339+
onNavigateToCalendarHeatMap = {
340+
backstack.add(HabitRoutes.CalendarHeatMap)
341+
},
342+
modifier =
343+
Modifier.background(
344+
MaterialTheme.colorScheme.surfaceContainerHighest
345+
),
346+
)
347+
}
348+
349+
entry<HabitRoutes.CalendarHeatMap>(
350+
metadata = horizontalTransitionMetadata()
351+
) {
352+
CalendarHeatMap(
353+
state = state,
354+
onNavigateBack = {
355+
if (backstack.size != 1) backstack.removeLastOrNull()
356+
},
357+
modifier =
358+
Modifier.background(
359+
MaterialTheme.colorScheme.surfaceContainerHighest
360+
),
361+
onChangeSelectedDay = {
362+
onAction(HabitsAction.FetchCompletedHabitsForDate(it))
363+
},
364+
)
365+
}
366+
},
367+
)
321368
}
322369
}
323370
}

0 commit comments

Comments
 (0)