diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt index e3faa75..a0d652c 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -47,6 +47,7 @@ import com.cornellappdev.uplift.ui.screens.onboarding.ProfileCreationScreen import com.cornellappdev.uplift.ui.screens.onboarding.SignInPromptScreen import com.cornellappdev.uplift.ui.screens.profile.ProfileScreen import com.cornellappdev.uplift.ui.screens.profile.SettingsScreen +import com.cornellappdev.uplift.ui.screens.profile.WorkoutHistoryScreen import com.cornellappdev.uplift.ui.screens.reminders.CapacityReminderScreen import com.cornellappdev.uplift.ui.screens.reminders.MainReminderScreen import com.cornellappdev.uplift.ui.screens.onboarding.WorkoutReminderOnboardingScreen @@ -265,6 +266,11 @@ fun MainNavigationWrapper( composable { SettingsScreen() } + composable { + WorkoutHistoryScreen( + onBack = { navController.popBackStack() } + ) + } composable {} composable {} } @@ -363,4 +369,7 @@ sealed class UpliftRootRoute { @Serializable data object Settings : UpliftRootRoute() + + @Serializable + data object WorkoutHistory : UpliftRootRoute() } diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/general/UpliftTabRow.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/general/UpliftTabRow.kt index e6dc569..0d40ce8 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/general/UpliftTabRow.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/general/UpliftTabRow.kt @@ -1,6 +1,12 @@ package com.cornellappdev.uplift.ui.components.general +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.TabRowDefaults.Divider +import androidx.compose.material3.Icon import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults.SecondaryIndicator @@ -11,8 +17,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -24,7 +32,12 @@ import com.cornellappdev.uplift.util.PRIMARY_YELLOW import com.cornellappdev.uplift.util.montserratFamily @Composable -fun UpliftTabRow(tabIndex: Int, tabs: List, onTabChange: (Int) -> Unit = {}) { +fun UpliftTabRow( + tabIndex: Int, + tabs: List, + icons: List? = null, + onTabChange: (Int) -> Unit = {} +) { TabRow( selectedTabIndex = tabIndex, containerColor = Color.White, @@ -43,19 +56,35 @@ fun UpliftTabRow(tabIndex: Int, tabs: List, onTabChange: (Int) -> Unit = } ) { tabs.forEachIndexed { index, title -> + val isSelected = tabIndex == index + val color = if (isSelected) PRIMARY_BLACK else GRAY04 Tab( - text = { - Text( - text = title, - color = if (tabIndex == index) PRIMARY_BLACK else GRAY04, - fontFamily = montserratFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - }, - selected = tabIndex == index, + selected = isSelected, onClick = { onTabChange(index) }, selectedContentColor = GRAY01, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (icons != null && index < icons.size) { + Icon( + painter = painterResource(id = icons[index]), + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Text( + text = title, + color = color, + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } ) } } @@ -72,4 +101,4 @@ private fun UpliftTabRowPreview() { tabIndex = it } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt index 03d4552..f4c97f0 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/components/profile/workouts/HistorySection.kt @@ -2,7 +2,6 @@ package com.cornellappdev.uplift.ui.components.profile.workouts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,7 +17,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.focusModifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight @@ -28,16 +26,17 @@ import androidx.compose.ui.unit.sp import com.cornellappdev.uplift.R import com.cornellappdev.uplift.ui.components.profile.SectionTitleText import com.cornellappdev.uplift.util.GRAY01 +import com.cornellappdev.uplift.util.GRAY04 +import com.cornellappdev.uplift.util.PRIMARY_BLACK import com.cornellappdev.uplift.util.montserratFamily -import com.cornellappdev.uplift.util.timeAgoString -import java.util.Calendar data class HistoryItem( val gymName: String, val time: String, val date: String, val timestamp: Long, - val ago: String + val ago: String, + val shortDate: String ) @Composable @@ -68,7 +67,7 @@ fun HistorySection( } @Composable -private fun HistoryList( +fun HistoryList( historyItems: List, modifier: Modifier = Modifier ) { @@ -76,14 +75,14 @@ private fun HistoryList( historyItems.take(5).forEachIndexed { index, historyItem -> HistoryItemRow(historyItem = historyItem) if (index != historyItems.size - 1) { - HorizontalDivider(color = GRAY01) + HorizontalDivider(color = GRAY01, thickness = 1.dp) } } } } @Composable -private fun HistoryItemRow( +fun HistoryItemRow( historyItem: HistoryItem ) { val gymName = historyItem.gymName @@ -94,23 +93,28 @@ private fun HistoryItemRow( Row( modifier = Modifier .fillMaxWidth() + .height(60.dp) .padding(vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom ) { - Column(){ + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ){ Text( text = gymName, fontFamily = montserratFamily, - fontSize = 14.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium, - color = Color.Black + color = PRIMARY_BLACK ) Text( text = "$date ยท $time", fontFamily = montserratFamily, fontSize = 12.sp, - fontWeight = FontWeight.Light, - color = Color.Gray + fontWeight = FontWeight.Medium, + color = GRAY04 ) } Text( @@ -124,7 +128,7 @@ private fun HistoryItemRow( } @Composable -private fun EmptyHistorySection(){ +fun EmptyHistorySection(){ Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -161,11 +165,11 @@ private fun EmptyHistorySection(){ private fun HistorySectionPreview() { val now = System.currentTimeMillis() val historyItems = listOf( - HistoryItem("Morrison", "11:00 PM", "March 29, 2024", now - (1 * 24 * 60 * 60 * 1000), "1 day ago"), - HistoryItem("Noyes", "1:00 PM", "March 29, 2024", now - (3 * 24 * 60 * 60 * 1000), "2 days ago"), - HistoryItem("Teagle Up", "2:00 PM", "March 29, 2024", now - (7 * 24 * 60 * 60 * 1000), "1 day ago"), - HistoryItem("Teagle Down", "12:00 PM", "March 29, 2024", now - (15 * 24 * 60 * 60 * 1000), "1 day ago"), - HistoryItem("Helen Newman", "10:00 AM", "March 29, 2024", now, "Today"), + HistoryItem("Morrison", "11:00 PM", "March 29, 2024", now - (1 * 24 * 60 * 60 * 1000), "1 day ago", "Mar 28"), + HistoryItem("Noyes", "1:00 PM", "March 29, 2024", now - (3 * 24 * 60 * 60 * 1000), "2 days ago", "Mar 26"), + HistoryItem("Teagle Up", "2:00 PM", "March 29, 2024", now - (7 * 24 * 60 * 60 * 1000), "1 week ago", "Mar 22"), + HistoryItem("Teagle Down", "12:00 PM", "March 29, 2024", now - (15 * 24 * 60 * 60 * 1000), "2 weeks ago", "Mar 14"), + HistoryItem("Helen Newman", "10:00 AM", "March 29, 2024", now, "Today", "Mar 29"), ) Column( modifier = Modifier @@ -178,4 +182,4 @@ private fun HistorySectionPreview() { ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt index b5b28d0..de19243 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/ProfileScreen.kt @@ -170,11 +170,11 @@ private fun ProfileScreenTopBar( private fun ProfileScreenContentPreview() { val now = System.currentTimeMillis() val historyItems = listOf( - HistoryItem("Morrison", "11:00 PM", "March 29, 2024", now - (1 * 24 * 60 * 60 * 1000), "1 day ago"), - HistoryItem("Noyes", "1:00 PM", "March 29, 2024", now - (3 * 24 * 60 * 60 * 1000), "2 days ago"), - HistoryItem("Teagle Up", "2:00 PM", "March 29, 2024", now - (7 * 24 * 60 * 60 * 1000), "1 day ago"), - HistoryItem("Teagle Down", "12:00 PM", "March 29, 2024", now - (15 * 24 * 60 * 60 * 1000), "1 day ago"), - HistoryItem("Helen Newman", "10:00 AM", "March 29, 2024", now, "Today"), + HistoryItem("Morrison", "11:00 PM", "March 29, 2024", now, "Today", "Mar 29"), + HistoryItem("Noyes", "1:00 PM", "March 28, 2024", now - 86400000L, "Yesterday", "Mar 28"), + HistoryItem("Teagle Up", "2:00 PM", "February 15, 2024", now - 4000000000L, "1 month ago", "Feb 15"), + HistoryItem("Helen Newman", "9:30 AM", "February 10, 2024", now - 4430000000L, "1 month ago", "Feb 10"), + HistoryItem("Morrison", "6:45 PM", "February 3, 2024", now - 5030000000L, "1 month ago", "Feb 3") ) ProfileScreenContent( uiState = ProfileUiState( diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/WorkoutHistoryScreen.kt b/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/WorkoutHistoryScreen.kt new file mode 100644 index 0000000..380fdae --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/ui/screens/profile/WorkoutHistoryScreen.kt @@ -0,0 +1,612 @@ +package com.cornellappdev.uplift.ui.screens.profile + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.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.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.cornellappdev.uplift.R +import com.cornellappdev.uplift.ui.components.general.UpliftTabRow +import com.cornellappdev.uplift.ui.components.profile.workouts.EmptyHistorySection +import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItem +import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItemRow +import com.cornellappdev.uplift.ui.viewmodels.profile.HistoryListItem +import com.cornellappdev.uplift.ui.viewmodels.profile.ProfileUiState +import com.cornellappdev.uplift.ui.viewmodels.profile.ProfileViewModel +import com.cornellappdev.uplift.util.GRAY01 +import com.cornellappdev.uplift.util.GRAY04 +import com.cornellappdev.uplift.util.LIGHT_GRAY +import com.cornellappdev.uplift.util.LIGHT_YELLOW +import com.cornellappdev.uplift.util.PRIMARY_BLACK +import com.cornellappdev.uplift.util.PRIMARY_YELLOW +import com.cornellappdev.uplift.util.montserratFamily +import java.time.Instant +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +fun WorkoutHistoryScreen( + viewModel: ProfileViewModel = hiltViewModel(), + onBack: () -> Unit +) { + val uiState = viewModel.collectUiStateValue() + WorkoutHistoryScreenContent(uiState = uiState, onBack = onBack) +} + +@Composable +fun WorkoutHistoryScreenContent( + uiState: ProfileUiState, + onBack: () -> Unit +) { + var selectedTab by remember { mutableIntStateOf(0) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + WorkoutHistoryHeader(onBack = onBack) + UpliftTabRow( + tabIndex = selectedTab, + tabs = listOf("Calendar", "List"), + icons = listOf(R.drawable.ic_calendar_tab, R.drawable.ic_list_tab), + onTabChange = { selectedTab = it } + ) + + if (uiState.historyItems.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + EmptyHistorySection() + } + } else { + AnimatedContent(targetState = selectedTab, label = "historyTabContent") { tab -> + when (tab) { + 0 -> WorkoutHistoryCalendarView(workoutDates = uiState.workoutDates) + 1 -> WorkoutHistoryListView(listItems = uiState.historyListItems) + } + } + } + } +} + +@Composable +private fun WorkoutHistoryHeader(onBack: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(LIGHT_GRAY) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + onClick = { onBack() }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_back_arrow), + contentDescription = "Back", + modifier = Modifier + .size(24.dp), + tint = PRIMARY_BLACK + ) + } + + Text( + text = "History", + fontFamily = montserratFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = PRIMARY_BLACK, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(24.dp)) + } +} + +@Composable +private fun WorkoutHistoryListView(listItems: List) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + item(key = "top_spacer") { + Spacer(modifier = Modifier.height(8.dp)) + } + + items( + items = listItems, + key = { listItem -> + when (listItem) { + is HistoryListItem.Header -> "header_${listItem.month}" + is HistoryListItem.Workout -> "workout_${listItem.item.timestamp}" + is HistoryListItem.SpacerItem -> "spacer_${listItem.month}" + } + } + ) { listItem -> + when (listItem) { + is HistoryListItem.Header -> { + Text( + text = listItem.month, + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(top = 8.dp) + ) + } + + is HistoryListItem.Workout -> { + Column { + HistoryItemRow(historyItem = listItem.item) + if (listItem.showDivider) { + HorizontalDivider(color = GRAY01, thickness = 1.dp) + } + } + } + + is HistoryListItem.SpacerItem -> { + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } +} + +@Composable +private fun WorkoutHistoryCalendarView(workoutDates: Map>) { + var currentMonth by remember { mutableStateOf(YearMonth.now()) } + var selectedDate by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Month Selector + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { + currentMonth = currentMonth.minusMonths(1) + selectedDate = null + } + + ) { + Icon( + painter = painterResource(id = R.drawable.ic_back_month), + contentDescription = "Previous Month", + modifier = Modifier + .size(16.dp), + tint = PRIMARY_BLACK + ) + } + Text( + text = currentMonth.format(DateTimeFormatter.ofPattern("MMM yyyy", Locale.US)), + fontFamily = montserratFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = PRIMARY_BLACK, + modifier = Modifier.width(100.dp), + textAlign = TextAlign.Center + ) + + IconButton( + onClick = { + currentMonth = currentMonth.plusMonths(1) + selectedDate = null + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_advance_month), + contentDescription = "Next Month", + modifier = Modifier + .size(16.dp), + tint = PRIMARY_BLACK + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Days of Week Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val daysOfWeek = listOf("M", "T", "W", "Th", "F", "Sa", "Su") + daysOfWeek.forEach { day -> + Text( + text = day, + modifier = Modifier.width(40.dp), + fontFamily = montserratFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Calendar Grid + AnimatedContent( + targetState = currentMonth, + label = "calendarMonthTransition" + ) { animatedMonth -> + + val daysInMonth = animatedMonth.lengthOfMonth() + val firstOfMonth = animatedMonth.atDay(1) + val firstDayOfWeek = (firstOfMonth.dayOfWeek.value + 6) % 7 + + val weeks = remember(animatedMonth) { + val list = mutableListOf>() + var currentDay = 1 + + while (currentDay <= daysInMonth) { + val week = (0..6).map { i -> + val dayIndex = list.size * 7 + i + val dayOfMonth = dayIndex - firstDayOfWeek + 1 + + if (dayOfMonth in 1..daysInMonth) { + animatedMonth.atDay(dayOfMonth) + } else null + } + + list.add(week) + currentDay += 7 - (if (list.size == 1) firstDayOfWeek else 0) + } + + list + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + weeks.forEach { week -> + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + week.forEachIndexed { index, date -> + if (date != null) { + val hasWorkout = workoutDates.containsKey(date) + val isToday = date == LocalDate.now() + + CalendarDayCell( + day = date.dayOfMonth.toString(), + isToday = isToday, + hasWorkout = hasWorkout, + modifier = Modifier.clickable { + selectedDate = + if (hasWorkout && selectedDate != date) date else null + } + ) + } else { + Spacer(modifier = Modifier.size(40.dp)) + } + } + } + + // Dropdown + val selectedInThisWeek = week.find { it == selectedDate } + + Column( + modifier = Modifier.animateContentSize( + animationSpec = tween(450) + ) + ) { + AnimatedVisibility( + visible = selectedInThisWeek != null, + enter = fadeIn(tween(300)) + expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(350) + ), + exit = fadeOut(tween(800)) + shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(700) + ) + ) { + selectedInThisWeek?.let { selected -> + val workouts = workoutDates[selected] ?: emptyList() + val dayOfWeekIndex = week.indexOf(selected) + + Column { + Spacer(modifier = Modifier.height(12.dp)) + + workouts.forEach { workout -> + WorkoutCalendarDropdown( + historyItem = workout, + dayOfWeekIndex = dayOfWeekIndex + ) + } + } + } + } + } + + } + } + } + } + } +} + + +@Composable +private fun WorkoutCalendarDropdown( + historyItem: HistoryItem, + dayOfWeekIndex: Int +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val horizontalPadding = 16.dp + val availableWidth = screenWidth - (horizontalPadding * 2) + val cellWidth = availableWidth / 7 + + // Calculate arrow offset to point to the center of the day cell + val arrowXOffset = (cellWidth * dayOfWeekIndex) + (cellWidth / 2) + val density = LocalDensity.current + + val shape = remember(arrowXOffset, density) { + GenericShape { size, _ -> + val arrowWidth = with(density) { 12.dp.toPx() } + val arrowHeight = with(density) { 8.dp.toPx() } + val cornerRadius = with(density) { 12.dp.toPx() } + val arrowX = with(density) { arrowXOffset.toPx() } + + // Top edge with arrow + moveTo(0f, arrowHeight + cornerRadius) + arcTo( + rect = Rect(0f, arrowHeight, cornerRadius * 2, arrowHeight + cornerRadius * 2), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(arrowX - arrowWidth / 2, arrowHeight) + lineTo(arrowX, 0f) + lineTo(arrowX + arrowWidth / 2, arrowHeight) + lineTo(size.width - cornerRadius, arrowHeight) + arcTo( + rect = Rect(size.width - cornerRadius * 2, arrowHeight, size.width, arrowHeight + cornerRadius * 2), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + // Right edge + lineTo(size.width, size.height - cornerRadius) + arcTo( + rect = Rect(size.width - cornerRadius * 2, size.height - cornerRadius * 2, size.width, size.height), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + // Bottom edge + lineTo(cornerRadius, size.height) + arcTo( + rect = Rect(0f, size.height - cornerRadius * 2, cornerRadius * 2, size.height), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + close() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = LIGHT_YELLOW, shape = shape) + .border(width = 1.dp, color = PRIMARY_YELLOW, shape = shape) + .padding(top = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = historyItem.gymName, + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = PRIMARY_BLACK + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = historyItem.time, + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = GRAY04 + ) + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .size(2.dp) + .background(GRAY04, CircleShape) + ) + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = historyItem.shortDate, + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = GRAY04 + ) + } + } + Text( + text = historyItem.ago, + fontFamily = montserratFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = PRIMARY_BLACK + ) + } + } +} + +@Composable +private fun CalendarDayCell( + day: String, + isToday: Boolean, + hasWorkout: Boolean, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.width(40.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .background( + color = if (isToday) LIGHT_YELLOW else Color.Transparent, + shape = CircleShape + ) + .then( + if (isToday) Modifier.border(1.dp, PRIMARY_YELLOW, CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + Text( + text = day, + fontFamily = montserratFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = PRIMARY_BLACK, + textAlign = TextAlign.Center + ) + } + if (hasWorkout) { + Box( + modifier = Modifier + .size(8.dp) + .background(color = PRIMARY_YELLOW, shape = CircleShape) + ) + } else { + Spacer(modifier = Modifier.size(8.dp)) + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun WorkoutHistoryScreenPreview() { + val now = System.currentTimeMillis() + val historyItems = listOf( + HistoryItem("Morrison", "11:00 PM", "March 29, 2024", now, "Today", "Mar 29"), + HistoryItem("Noyes", "1:00 PM", "March 28, 2024", now - 86400000L, "Yesterday", "Mar 28"), + HistoryItem("Teagle Up", "2:00 PM", "February 15, 2024", now - 4000000000L, "1 month ago", "Feb 15"), + HistoryItem("Helen Newman", "9:30 AM", "February 10, 2024", now - 4430000000L, "1 month ago", "Feb 10"), + HistoryItem("Morrison", "6:45 PM", "February 3, 2024", now - 5030000000L, "1 month ago", "Feb 3") + ) + + val listItems = buildList { + historyItems + .sortedByDescending { it.timestamp } + .groupBy { + Instant.ofEpochMilli(it.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.US)) + } + .forEach { (month, items) -> + add(HistoryListItem.Header(month)) + items.forEachIndexed { index, item -> + add( + HistoryListItem.Workout( + item, + index < items.lastIndex + ) + ) + } + add(HistoryListItem.SpacerItem(month)) + } + } + + val workoutDates = historyItems.groupBy { + Instant.ofEpochMilli(it.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + + WorkoutHistoryScreenContent( + uiState = ProfileUiState( + historyItems = historyItems, + historyListItems = listItems, + workoutDates = workoutDates + ), + onBack = {} + ) +} diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt index bd907d0..76b15ad 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/ProfileViewModel.kt @@ -3,9 +3,7 @@ package com.cornellappdev.uplift.ui.viewmodels.profile import android.net.Uri import android.util.Log import androidx.lifecycle.viewModelScope -import coil.util.CoilUtils.result import com.cornellappdev.uplift.data.repositories.ProfileRepository -import com.cornellappdev.uplift.data.repositories.UserInfoRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.components.profile.workouts.HistoryItem import com.cornellappdev.uplift.ui.nav.RootNavigationRepository @@ -19,9 +17,18 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalQueries.localDate import java.util.Locale import javax.inject.Inject +sealed class HistoryListItem { + data class Header(val month: String) : HistoryListItem() + data class Workout( + val item: HistoryItem, + val showDivider: Boolean + ) : HistoryListItem() + data class SpacerItem(val month: String) : HistoryListItem() +} data class ProfileUiState( val loading: Boolean = false, val error: Boolean = false, @@ -36,7 +43,9 @@ data class ProfileUiState( val historyItems: List = emptyList(), val daysOfMonth: List = emptyList(), val completedDays: List = emptyList(), - val workoutsCompleted: Int = 0 + val workoutsCompleted: Int = 0, + val historyListItems: List = emptyList(), + val workoutDates: Map> = emptyMap() ) @HiltViewModel @@ -79,7 +88,8 @@ class ProfileViewModel @Inject constructor( time = formatTime.format(workoutInstant), date = formatDate.format(workoutInstant), timestamp = it.timestamp, - ago = calendar.timeAgoString() + ago = calendar.timeAgoString(), + shortDate = shortDateFormatter.format(workoutInstant) ) } @@ -98,6 +108,36 @@ class ProfileViewModel @Inject constructor( val workoutsCompleted = profile.weeklyWorkoutDays.size + val grouped = historyItems + .sortedByDescending { it.timestamp } + .groupBy { + Instant.ofEpochMilli(it.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.US)) + } + + val historyListItems = buildList{ + grouped.forEach { (month, items) -> + add(HistoryListItem.Header(month)) + items.forEachIndexed { index, item -> + add( + HistoryListItem.Workout( + item = item, + showDivider = index < items.lastIndex + ) + ) + } + add(HistoryListItem.SpacerItem(month)) + } + } + + val workoutDates = historyItems.groupBy { + Instant.ofEpochMilli(it.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + applyMutation { copy( loading = false, @@ -112,7 +152,9 @@ class ProfileViewModel @Inject constructor( historyItems = historyItems, daysOfMonth = daysOfMonth, completedDays = completedDays, - workoutsCompleted = workoutsCompleted + workoutsCompleted = workoutsCompleted, + historyListItems = historyListItems, + workoutDates = workoutDates ) } @@ -137,7 +179,7 @@ class ProfileViewModel @Inject constructor( } fun toHistory() { - // replace with the actual route once history exists + rootNavigationRepository.navigate(UpliftRootRoute.WorkoutHistory) } @@ -156,5 +198,9 @@ class ProfileViewModel @Inject constructor( .ofPattern("EEE") .withLocale(Locale.US) .withZone(ZoneId.systemDefault()) -} + private val shortDateFormatter = DateTimeFormatter + .ofPattern("MMM d") + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) +} diff --git a/app/src/main/res/drawable/ic_advance_month.xml b/app/src/main/res/drawable/ic_advance_month.xml new file mode 100644 index 0000000..6365ab7 --- /dev/null +++ b/app/src/main/res/drawable/ic_advance_month.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_back_month.xml b/app/src/main/res/drawable/ic_back_month.xml new file mode 100644 index 0000000..f500d59 --- /dev/null +++ b/app/src/main/res/drawable/ic_back_month.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_calendar_tab.xml b/app/src/main/res/drawable/ic_calendar_tab.xml new file mode 100644 index 0000000..a869fec --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_tab.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_list_tab.xml b/app/src/main/res/drawable/ic_list_tab.xml new file mode 100644 index 0000000..3e291c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_list_tab.xml @@ -0,0 +1,21 @@ + + + + +