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)
|
||||
private set
|
||||
|
||||
var isCollapsed by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var collapseProgress by mutableStateOf(0f)
|
||||
private set
|
||||
|
||||
val currentYear: Int get() = selectedDate.year
|
||||
@Suppress("DEPRECATION")
|
||||
val currentMonth: Int get() = selectedDate.monthNumber
|
||||
@ -33,6 +39,16 @@ class CalendarViewModel {
|
||||
selectedDate = date
|
||||
}
|
||||
|
||||
fun collapse() {
|
||||
isCollapsed = true
|
||||
collapseProgress = 1f
|
||||
}
|
||||
|
||||
fun expand() {
|
||||
isCollapsed = false
|
||||
collapseProgress = 0f
|
||||
}
|
||||
|
||||
fun getIsoWeekNumber(date: LocalDate): Int {
|
||||
val jan4 = LocalDate(date.year, 1, 4)
|
||||
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
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.datetime.DatePeriod
|
||||
import kotlinx.datetime.LocalDate
|
||||
@ -20,28 +23,57 @@ fun CalendarMonthPage(
|
||||
selectedDate: LocalDate,
|
||||
today: LocalDate,
|
||||
onDateClick: (LocalDate) -> Unit,
|
||||
collapseProgress: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val days = remember(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) {
|
||||
days.chunked(7).forEach { week ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
) {
|
||||
week.forEach { dayData ->
|
||||
DayCell(
|
||||
date = dayData.date,
|
||||
isCurrentMonth = dayData.isCurrentMonth,
|
||||
isSelected = dayData.date == selectedDate,
|
||||
isToday = dayData.date == today,
|
||||
onClick = { onDateClick(dayData.date) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.offset(y = offsetY.dp)
|
||||
.alpha(alpha)
|
||||
) {
|
||||
week.forEach { dayData ->
|
||||
DayCell(
|
||||
date = dayData.date,
|
||||
isCurrentMonth = dayData.isCurrentMonth,
|
||||
isSelected = dayData.date == selectedDate,
|
||||
isToday = dayData.date == today,
|
||||
onClick = { onDateClick(dayData.date) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,4 +98,4 @@ private fun generateMonthDays(year: Int, month: Int): List<DayData> {
|
||||
isCurrentMonth = date.monthNumber == month && date.year == year
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -27,21 +27,40 @@ fun CalendarMonthView(
|
||||
var currentYear by remember { mutableIntStateOf(viewModel.currentYear) }
|
||||
var currentMonth by remember { mutableIntStateOf(viewModel.currentMonth) }
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp)) {
|
||||
MonthHeader(
|
||||
year = currentYear,
|
||||
month = currentMonth,
|
||||
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
|
||||
)
|
||||
WeekdayHeader(modifier = Modifier.fillMaxWidth())
|
||||
CalendarPager(
|
||||
selectedDate = viewModel.selectedDate,
|
||||
today = today,
|
||||
onDateClick = { date -> viewModel.selectDate(date) },
|
||||
onMonthChanged = { year, month ->
|
||||
currentYear = year
|
||||
currentMonth = month
|
||||
},
|
||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
MonthHeader(
|
||||
year = currentYear,
|
||||
month = currentMonth,
|
||||
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate)
|
||||
)
|
||||
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(
|
||||
selectedDate = viewModel.selectedDate,
|
||||
today = today,
|
||||
onDateClick = { date -> viewModel.selectDate(date) },
|
||||
onMonthChanged = { year, month ->
|
||||
currentYear = year
|
||||
currentMonth = month
|
||||
},
|
||||
collapseProgress = viewModel.collapseProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
BottomCard(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ fun CalendarPager(
|
||||
today: LocalDate,
|
||||
onDateClick: (LocalDate) -> Unit,
|
||||
onMonthChanged: (year: Int, month: Int) -> Unit,
|
||||
collapseProgress: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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