[Mobile] 2.2.0 - Calendar View
This commit is contained in:
@@ -1,16 +1,26 @@
|
||||
package com.atridad.ascently.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -23,6 +33,16 @@ import com.atridad.ascently.ui.components.ActiveSessionBanner
|
||||
import com.atridad.ascently.ui.components.SyncIndicator
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
enum class ViewMode {
|
||||
LIST,
|
||||
CALENDAR
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -33,7 +53,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// Filter out active sessions from regular session list
|
||||
val sharedPreferences =
|
||||
context.getSharedPreferences("SessionsPreferences", Context.MODE_PRIVATE)
|
||||
val savedViewMode = sharedPreferences.getString("view_mode", "LIST")
|
||||
var viewMode by remember {
|
||||
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
|
||||
}
|
||||
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
|
||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||
|
||||
@@ -55,12 +83,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewMode =
|
||||
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
||||
selectedDate = null
|
||||
sharedPreferences.edit().putString("view_mode", viewMode.name).apply()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
|
||||
else Icons.AutoMirrored.Filled.List,
|
||||
contentDescription =
|
||||
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Active session banner
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
@@ -83,20 +129,40 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
when (viewMode) {
|
||||
ViewMode.LIST -> {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name
|
||||
?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewMode.CALENDAR -> {
|
||||
CalendarView(
|
||||
sessions = completedSessions,
|
||||
gyms = gyms,
|
||||
activeSession = activeSession,
|
||||
activeSessionGym = activeSessionGym,
|
||||
selectedMonth = selectedMonth,
|
||||
onMonthChange = { selectedMonth = it },
|
||||
selectedDate = selectedDate,
|
||||
onDateSelected = { selectedDate = it },
|
||||
onNavigateToSessionDetail = onNavigateToSessionDetail,
|
||||
onEndSession = {
|
||||
activeSession?.let { viewModel.endSession(context, it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show UI state messages and errors
|
||||
uiState.message?.let { message ->
|
||||
LaunchedEffect(message) {
|
||||
kotlinx.coroutines.delay(5000)
|
||||
@@ -245,6 +311,238 @@ fun EmptyStateMessage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarView(
|
||||
sessions: List<ClimbSession>,
|
||||
gyms: List<com.atridad.ascently.data.model.Gym>,
|
||||
activeSession: ClimbSession?,
|
||||
activeSessionGym: com.atridad.ascently.data.model.Gym?,
|
||||
selectedMonth: YearMonth,
|
||||
onMonthChange: (YearMonth) -> Unit,
|
||||
selectedDate: LocalDate?,
|
||||
onDateSelected: (LocalDate?) -> Unit,
|
||||
onNavigateToSessionDetail: (String) -> Unit,
|
||||
onEndSession: () -> Unit
|
||||
) {
|
||||
val sessionsByDate =
|
||||
remember(sessions) {
|
||||
sessions.groupBy {
|
||||
try {
|
||||
java.time.Instant.parse(it.date)
|
||||
.atZone(java.time.ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
} catch (e: Exception) {
|
||||
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (activeSession != null && activeSessionGym != null) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = { onNavigateToSessionDetail(activeSession.id) },
|
||||
onEndSession = onEndSession
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
||||
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
|
||||
Text(
|
||||
text =
|
||||
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
||||
Text("›", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val today = LocalDate.now()
|
||||
onMonthChange(YearMonth.from(today))
|
||||
onDateSelected(today)
|
||||
},
|
||||
shape = RoundedCornerShape(50),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Today",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
|
||||
Text(
|
||||
text = day,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val firstDayOfMonth = selectedMonth.atDay(1)
|
||||
val daysInMonth = selectedMonth.lengthOfMonth()
|
||||
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
|
||||
val totalCells =
|
||||
((firstDayOfWeek + daysInMonth) / 7.0).let {
|
||||
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
|
||||
}
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.fillMaxWidth()) {
|
||||
items(totalCells) { index ->
|
||||
val dayNumber = index - firstDayOfWeek + 1
|
||||
|
||||
if (dayNumber in 1..daysInMonth) {
|
||||
val date = selectedMonth.atDay(dayNumber)
|
||||
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
|
||||
val isSelected = date == selectedDate
|
||||
val isToday = date == LocalDate.now()
|
||||
|
||||
CalendarDay(
|
||||
day = dayNumber,
|
||||
hasSession = sessionsOnDate.isNotEmpty(),
|
||||
isSelected = isSelected,
|
||||
isToday = isToday,
|
||||
onClick = {
|
||||
if (sessionsOnDate.isNotEmpty()) {
|
||||
onDateSelected(if (isSelected) null else date)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.aspectRatio(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDate != null) {
|
||||
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(sessionsOnSelectedDate) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarDay(
|
||||
day: Int,
|
||||
hasSession: Boolean,
|
||||
isSelected: Boolean,
|
||||
isToday: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable(enabled = hasSession, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
|
||||
if (hasSession) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.7f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(dateString: String): String {
|
||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||
}
|
||||
|
||||
19
android/app/src/main/res/drawable/ic_splash.xml
Normal file
19
android/app/src/main/res/drawable/ic_splash.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#FFC107"
|
||||
android:pathData="M24,74 L42,34 L60,74 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F44336"
|
||||
android:pathData="M41,74 L59,24 L84,74 Z" />
|
||||
</vector>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user