Add week/month view toggle with collapse animation and bottom card
Add isCollapsed/collapseProgress state to CalendarViewModel for toggling between month and week views. CalendarMonthPage animates non-selected weeks out with offset and alpha. Introduce WeekPager for single-week HorizontalPager and BottomCard with drag-to-collapse gesture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
624fd8905c
commit
e53c3d8705
@ -25,6 +25,12 @@ class CalendarViewModel {
|
|||||||
var selectedDate by mutableStateOf(today)
|
var selectedDate by mutableStateOf(today)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var isCollapsed by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var collapseProgress by mutableStateOf(0f)
|
||||||
|
private set
|
||||||
|
|
||||||
val currentYear: Int get() = selectedDate.year
|
val currentYear: Int get() = selectedDate.year
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val currentMonth: Int get() = selectedDate.monthNumber
|
val currentMonth: Int get() = selectedDate.monthNumber
|
||||||
@ -33,6 +39,16 @@ class CalendarViewModel {
|
|||||||
selectedDate = date
|
selectedDate = date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun collapse() {
|
||||||
|
isCollapsed = true
|
||||||
|
collapseProgress = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expand() {
|
||||||
|
isCollapsed = false
|
||||||
|
collapseProgress = 0f
|
||||||
|
}
|
||||||
|
|
||||||
fun getIsoWeekNumber(date: LocalDate): Int {
|
fun getIsoWeekNumber(date: LocalDate): Int {
|
||||||
val jan4 = LocalDate(date.year, 1, 4)
|
val jan4 = LocalDate(date.year, 1, 4)
|
||||||
val jan4DayOfWeek = jan4.dayOfWeek.ordinal
|
val jan4DayOfWeek = jan4.dayOfWeek.ordinal
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
package plus.rua.project.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import plus.rua.project.CalendarViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomCard(
|
||||||
|
viewModel: CalendarViewModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectVerticalDragGestures { _, dragAmount ->
|
||||||
|
if (dragAmount < 0 && !viewModel.isCollapsed) {
|
||||||
|
viewModel.collapse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 8.dp, bottom = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(Color.Gray.copy(alpha = 0.4f))
|
||||||
|
.fillMaxWidth(0.15f)
|
||||||
|
.height(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,15 @@
|
|||||||
package plus.rua.project.ui
|
package plus.rua.project.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.datetime.DatePeriod
|
import kotlinx.datetime.DatePeriod
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@ -20,18 +23,46 @@ fun CalendarMonthPage(
|
|||||||
selectedDate: LocalDate,
|
selectedDate: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
onDateClick: (LocalDate) -> Unit,
|
onDateClick: (LocalDate) -> Unit,
|
||||||
|
collapseProgress: Float,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val days = remember(year, month) {
|
val days = remember(year, month) {
|
||||||
generateMonthDays(year, month)
|
generateMonthDays(year, month)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val weeks = days.chunked(7)
|
||||||
|
val selectedWeekIndex = remember(weeks, selectedDate) {
|
||||||
|
weeks.indexOfFirst { week -> week.any { it.date == selectedDate } }
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
days.chunked(7).forEach { week ->
|
weeks.forEachIndexed { weekIndex, week ->
|
||||||
|
val animatedProgress = animateFloatAsState(
|
||||||
|
targetValue = collapseProgress,
|
||||||
|
label = "collapse-$weekIndex"
|
||||||
|
).value
|
||||||
|
|
||||||
|
val isAboveSelected = weekIndex < selectedWeekIndex
|
||||||
|
val isBelowSelected = weekIndex > selectedWeekIndex
|
||||||
|
|
||||||
|
val offsetY = when {
|
||||||
|
isAboveSelected -> -animatedProgress * 200f
|
||||||
|
isBelowSelected -> animatedProgress * 200f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
val alpha = when {
|
||||||
|
isAboveSelected || isBelowSelected -> 1f - animatedProgress
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alpha > 0.01f) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 2.dp)
|
.padding(vertical = 2.dp)
|
||||||
|
.offset(y = offsetY.dp)
|
||||||
|
.alpha(alpha)
|
||||||
) {
|
) {
|
||||||
week.forEach { dayData ->
|
week.forEach { dayData ->
|
||||||
DayCell(
|
DayCell(
|
||||||
@ -47,6 +78,7 @@ fun CalendarMonthPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class DayData(
|
private data class DayData(
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
|
|||||||
@ -27,13 +27,26 @@ fun CalendarMonthView(
|
|||||||
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) }
|
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) }
|
||||||
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) }
|
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) }
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp)) {
|
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
MonthHeader(
|
MonthHeader(
|
||||||
year = currentYear,
|
year = currentYear,
|
||||||
month = currentMonth,
|
month = currentMonth,
|
||||||
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
|
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
|
||||||
)
|
)
|
||||||
WeekdayHeader(modifier = Modifier.fillMaxWidth())
|
WeekdayHeader(modifier = Modifier.fillMaxWidth())
|
||||||
|
if (viewModel.isCollapsed) {
|
||||||
|
WeekPager(
|
||||||
|
selectedDate = viewModel.selectedDate,
|
||||||
|
today = today,
|
||||||
|
onDateClick = { date -> viewModel.selectDate(date) },
|
||||||
|
onWeekChanged = { weekMonday ->
|
||||||
|
currentYear = weekMonday.year
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
currentMonth = weekMonday.monthNumber
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
CalendarPager(
|
CalendarPager(
|
||||||
selectedDate = viewModel.selectedDate,
|
selectedDate = viewModel.selectedDate,
|
||||||
today = today,
|
today = today,
|
||||||
@ -42,6 +55,12 @@ fun CalendarMonthView(
|
|||||||
currentYear = year
|
currentYear = year
|
||||||
currentMonth = month
|
currentMonth = month
|
||||||
},
|
},
|
||||||
|
collapseProgress = viewModel.collapseProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BottomCard(
|
||||||
|
viewModel = viewModel,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ fun CalendarPager(
|
|||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
onDateClick: (LocalDate) -> Unit,
|
onDateClick: (LocalDate) -> Unit,
|
||||||
onMonthChanged: (year: Int, month: Int) -> Unit,
|
onMonthChanged: (year: Int, month: Int) -> Unit,
|
||||||
|
collapseProgress: Float,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val initialYearMonth = remember { today.toYearMonth() }
|
val initialYearMonth = remember { today.toYearMonth() }
|
||||||
@ -61,7 +62,8 @@ fun CalendarPager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
collapseProgress = collapseProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
package plus.rua.project.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerDefaults
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DatePeriod
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
private const val START_PAGE = Int.MAX_VALUE / 2
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WeekPager(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onDateClick: (LocalDate) -> Unit,
|
||||||
|
onWeekChanged: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val initialWeekMonday = remember { selectedDate.toWeekMonday() }
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = START_PAGE,
|
||||||
|
pageCount = { Int.MAX_VALUE }
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||||
|
val weekMonday = pageToWeekMonday(page, initialWeekMonday)
|
||||||
|
onWeekChanged(weekMonday)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
|
||||||
|
modifier = modifier
|
||||||
|
) { page ->
|
||||||
|
val weekMonday = pageToWeekMonday(page, initialWeekMonday)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
(0 until 7).forEach { dayOffset ->
|
||||||
|
val date = weekMonday.plus(DatePeriod(days = dayOffset))
|
||||||
|
DayCell(
|
||||||
|
date = date,
|
||||||
|
isCurrentMonth = true,
|
||||||
|
isSelected = date == selectedDate,
|
||||||
|
isToday = date == today,
|
||||||
|
onClick = { onDateClick(date) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDate.toWeekMonday(): LocalDate {
|
||||||
|
val dayOfWeekOrdinal = dayOfWeek.ordinal // Monday=0 ... Sunday=6
|
||||||
|
return minus(DatePeriod(days = dayOfWeekOrdinal))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pageToWeekMonday(page: Int, initial: LocalDate): LocalDate {
|
||||||
|
val offset = page - START_PAGE
|
||||||
|
return initial.plus(DatePeriod(days = offset * 7))
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user