Skip to content

Commit ed92773

Browse files
committed
feat: add a month picker to quickly change between different months.
The new month picker allows to change quickly between months up to 2 years in the future and 2 years in the past. When selecting a month, automatically selects the first day of that month.
1 parent a87c5a1 commit ed92773

5 files changed

Lines changed: 206 additions & 56 deletions

File tree

feature/calendar/src/main/java/com/markel/flowstate/feature/calendar/components/CalendarContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,8 @@ fun CalendarContent(
177177
onTodayClick = {
178178
onDateSelected(java.time.LocalDate.now())
179179
scrollTrigger = System.currentTimeMillis()
180-
}
180+
},
181+
onDateSelected = onDateSelected
181182
)
182183

183184
// WEEKDAYS
Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
package com.markel.flowstate.feature.calendar.components.calendarview
22

3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.foundation.clickable
35
import androidx.compose.foundation.layout.Arrangement
6+
import androidx.compose.foundation.layout.Column
47
import androidx.compose.foundation.layout.Row
58
import androidx.compose.foundation.layout.fillMaxWidth
69
import androidx.compose.foundation.layout.padding
10+
import androidx.compose.foundation.shape.RoundedCornerShape
711
import androidx.compose.material3.Icon
812
import androidx.compose.material3.IconButton
913
import androidx.compose.material3.MaterialTheme
1014
import androidx.compose.material3.Text
1115
import androidx.compose.runtime.Composable
16+
import androidx.compose.runtime.getValue
17+
import androidx.compose.runtime.mutableStateOf
18+
import androidx.compose.runtime.remember
19+
import androidx.compose.runtime.setValue
1220
import androidx.compose.ui.Alignment
1321
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.draw.clip
1423
import androidx.compose.ui.graphics.Color
1524
import androidx.compose.ui.graphics.vector.ImageVector
1625
import androidx.compose.ui.res.painterResource
@@ -37,70 +46,94 @@ fun CalendarMonthHeader(
3746
monthState: CalendarState,
3847
weekState: WeekCalendarState,
3948
selectedDate: java.time.LocalDate,
40-
onTodayClick: () -> Unit
49+
onTodayClick: () -> Unit,
50+
onDateSelected: (java.time.LocalDate) -> Unit
4151
) {
42-
Row(
43-
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 16.dp),
44-
verticalAlignment = Alignment.CenterVertically,
45-
horizontalArrangement = Arrangement.SpaceBetween
46-
) {
47-
val displayMonth = if (isExpanded) {
48-
monthState.firstVisibleMonth.yearMonth
49-
} else {
50-
// In the week view: if the selected day is visible, show that month
51-
// Else, show the one of the visible week (when scrolling)
52-
val selectedYearMonth = YearMonth.from(selectedDate)
53-
val visibleWeekYearMonth = YearMonth.from(weekState.firstVisibleWeek.days.first().date)
5452

55-
val isSelectedInVisibleWeek = weekState.firstVisibleWeek.days.any { it.date == selectedDate }
53+
var isPickerExpanded by remember { mutableStateOf(false) }
54+
55+
val displayMonth = if (isExpanded) {
56+
monthState.firstVisibleMonth.yearMonth
57+
} else {
58+
// In the week view: if the selected day is visible, show that month
59+
// Else, show the one of the visible week (when scrolling)
60+
val selectedYearMonth = YearMonth.from(selectedDate)
61+
val visibleWeekYearMonth = YearMonth.from(weekState.firstVisibleWeek.days.first().date)
62+
if (weekState.firstVisibleWeek.days.any { it.date == selectedDate }) selectedYearMonth else visibleWeekYearMonth // Check if is selected in the visible week
63+
}
64+
65+
Column(modifier = Modifier.fillMaxWidth()) {
66+
Row(
67+
modifier = Modifier
68+
.fillMaxWidth()
69+
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 16.dp),
70+
verticalAlignment = Alignment.CenterVertically,
71+
horizontalArrangement = Arrangement.SpaceBetween
72+
) {
73+
Row(
74+
verticalAlignment = Alignment.CenterVertically,
75+
modifier = Modifier
76+
.clip(RoundedCornerShape(12.dp))
77+
.clickable { isPickerExpanded = !isPickerExpanded }
78+
.padding(4.dp)
79+
) {
80+
val monthTitle = displayMonth.month
81+
.getDisplayName(TextStyle.FULL, LocalLocale.current.platformLocale)
82+
.replaceFirstChar { it.uppercase() }
5683

57-
if (isSelectedInVisibleWeek) {
58-
selectedYearMonth
59-
} else {
60-
visibleWeekYearMonth
84+
Text(
85+
text = buildAnnotatedString {
86+
withStyle(
87+
style = SpanStyle(
88+
fontWeight = FontWeight.Bold,
89+
letterSpacing = (-0.35).sp,
90+
fontStyle = FontStyle.Italic,
91+
fontSize = 34.sp,
92+
color = MaterialTheme.colorScheme.onSurface
93+
)
94+
) {
95+
append(monthTitle)
96+
}
97+
append(" ")
98+
withStyle(
99+
style = SpanStyle(
100+
fontWeight = FontWeight.Medium,
101+
fontSize = 16.sp,
102+
color = MaterialTheme.colorScheme.tertiary
103+
)
104+
) {
105+
append(displayMonth.year.toString())
106+
}
107+
},
108+
lineHeight = 36.sp
109+
)
110+
Icon(
111+
imageVector = ImageVector.vectorResource(if (isPickerExpanded) R.drawable.arrow_drop_up_24px else R.drawable.arrow_drop_down_24px),
112+
contentDescription = null,
113+
modifier = Modifier.align(Alignment.Bottom)
114+
)
61115
}
62116

117+
// Today button to center the selected day in the calendar to today
118+
IconButton(
119+
onClick = onTodayClick,
120+
) {
121+
TodayIcon(
122+
outlineColor = MaterialTheme.colorScheme.onSurface,
123+
dotColor = MaterialTheme.colorScheme.tertiary
124+
)
125+
}
63126
}
64-
val monthTitle = displayMonth.month
65-
.getDisplayName(TextStyle.FULL, LocalLocale.current.platformLocale)
66-
.replaceFirstChar { it.uppercase() }
127+
AnimatedVisibility(visible = isPickerExpanded) {
128+
MonthPicker(
129+
currentMonth = displayMonth,
130+
onMonthClick = { targetMonth ->
131+
val firstDay = targetMonth.atDay(1)
67132

68-
Text(
69-
text = buildAnnotatedString {
70-
withStyle(
71-
style = SpanStyle(
72-
fontWeight = FontWeight.Bold ,
73-
letterSpacing = (-0.35).sp,
74-
fontStyle = FontStyle.Italic,
75-
fontSize = 34.sp,
76-
color = MaterialTheme.colorScheme.onSurface
77-
)
78-
) {
79-
append(monthTitle)
80-
}
81-
append(" ")
82-
withStyle(
83-
style = SpanStyle(
84-
fontWeight = FontWeight.Medium,
85-
fontSize = 16.sp,
86-
color = MaterialTheme.colorScheme.tertiary
87-
)
88-
) {
89-
append(displayMonth.year.toString())
133+
// Select the first day of the month. This triggers the LaunchedEffect in the CalendarContent that animates the scrolling.
134+
onDateSelected(firstDay)
90135
}
91-
},
92-
lineHeight = 36.sp
93-
)
94-
95-
// Today button to center the selected day in the calendar to today
96-
IconButton(
97-
onClick = onTodayClick ,
98-
) {
99-
TodayIcon(
100-
outlineColor = MaterialTheme.colorScheme.onSurface,
101-
dotColor = MaterialTheme.colorScheme.tertiary
102136
)
103137
}
104-
105138
}
106139
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.markel.flowstate.feature.calendar.components.calendarview
2+
3+
import androidx.compose.foundation.layout.*
4+
import androidx.compose.foundation.lazy.LazyRow
5+
import androidx.compose.foundation.lazy.items
6+
import androidx.compose.foundation.lazy.rememberLazyListState
7+
import androidx.compose.foundation.shape.CircleShape
8+
import androidx.compose.material3.*
9+
import androidx.compose.runtime.*
10+
import androidx.compose.ui.Alignment
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.platform.LocalLocale
13+
import androidx.compose.ui.unit.dp
14+
import java.time.YearMonth
15+
import java.time.format.TextStyle
16+
17+
sealed interface MonthPickerItem {
18+
data class Month(val yearMonth: YearMonth) : MonthPickerItem
19+
data class Year(val year: Int) : MonthPickerItem
20+
}
21+
22+
@OptIn(ExperimentalMaterial3Api::class)
23+
@Composable
24+
fun MonthPicker(
25+
currentMonth: YearMonth,
26+
onMonthClick: (YearMonth) -> Unit,
27+
modifier: Modifier = Modifier
28+
) {
29+
val pickerItems = remember {
30+
val items = mutableListOf<MonthPickerItem>()
31+
val startMonth = YearMonth.now().minusYears(2).withMonth(1)
32+
val endMonth = YearMonth.now().plusYears(2).withMonth(12)
33+
var curr = startMonth
34+
while (!curr.isAfter(endMonth)) {
35+
if (curr.monthValue == 1) items.add(MonthPickerItem.Year(curr.year))
36+
items.add(MonthPickerItem.Month(curr))
37+
curr = curr.plusMonths(1)
38+
}
39+
items
40+
}
41+
42+
val listState = rememberLazyListState()
43+
44+
// Scroll to the current month
45+
LaunchedEffect(Unit) {
46+
val index = pickerItems.indexOfFirst { it is MonthPickerItem.Month && it.yearMonth == currentMonth }
47+
if (index >= 0) listState.scrollToItem(maxOf(0, index - 2))
48+
}
49+
50+
LazyRow(
51+
state = listState,
52+
modifier = modifier.fillMaxWidth(),
53+
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 8.dp),
54+
horizontalArrangement = Arrangement.spacedBy(4.dp),
55+
verticalAlignment = Alignment.CenterVertically
56+
) {
57+
items(pickerItems) { item ->
58+
when (item) {
59+
is MonthPickerItem.Month -> {
60+
val isSelected = item.yearMonth == currentMonth
61+
FilterChip(
62+
selected = isSelected,
63+
onClick = { onMonthClick(item.yearMonth) },
64+
label = {
65+
Text(
66+
item.yearMonth.month.getDisplayName(TextStyle.SHORT, LocalLocale.current.platformLocale)
67+
.replaceFirstChar { it.uppercase() }
68+
)
69+
},
70+
colors = FilterChipDefaults.filterChipColors(
71+
selectedContainerColor = MaterialTheme.colorScheme.tertiary,
72+
selectedLabelColor = MaterialTheme.colorScheme.onTertiary,
73+
containerColor = MaterialTheme.colorScheme.surfaceContainer,
74+
labelColor = MaterialTheme.colorScheme.onSurface,
75+
),
76+
shape = if (isSelected) CircleShape else FilterChipDefaults.shape,
77+
border = null
78+
)
79+
}
80+
is MonthPickerItem.Year -> {
81+
Surface(
82+
modifier = Modifier.height(32.dp)
83+
) {
84+
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 12.dp)) {
85+
Text(
86+
text = item.year.toString(),
87+
style = MaterialTheme.typography.labelLarge,
88+
color = MaterialTheme.colorScheme.onSurfaceVariant
89+
)
90+
}
91+
}
92+
}
93+
}
94+
}
95+
}
96+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960"
6+
android:tint="?attr/colorControlNormal">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M480,607.65L266.85,394.5L693.15,394.5L480,607.65Z"/>
10+
</vector>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960"
6+
android:tint="?attr/colorControlNormal">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M266.85,565.5L480,352.35L693.15,565.5L266.85,565.5Z"/>
10+
</vector>

0 commit comments

Comments
 (0)