diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 575fa83..e3932d8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.ascently" minSdk = 31 targetSdk = 36 - versionCode = 43 - versionName = "2.1.1" + versionCode = 44 + versionName = "2.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt index 84a8ab2..d5655bc 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt @@ -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(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, + gyms: List, + 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) } diff --git a/android/app/src/main/res/drawable/ic_splash.xml b/android/app/src/main/res/drawable/ic_splash.xml new file mode 100644 index 0000000..1a554a5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_splash.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 39e68ea..b278fb5 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -4,7 +4,7 @@ diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 597b85f..347dabb 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Services/SyncService.swift b/ios/Ascently/Services/SyncService.swift index ea98eec..617d356 100644 --- a/ios/Ascently/Services/SyncService.swift +++ b/ios/Ascently/Services/SyncService.swift @@ -158,7 +158,7 @@ class SyncService: ObservableObject { let modifiedProblems = dataManager.problems.filter { problem in problem.updatedAt > lastSync }.map { problem -> BackupProblem in - var backupProblem = BackupProblem(from: problem) + let backupProblem = BackupProblem(from: problem) if !problem.imagePaths.isEmpty { let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in ImageNamingUtils.generateImageFilename( diff --git a/ios/Ascently/Utils/OrientationAwareImage.swift b/ios/Ascently/Utils/OrientationAwareImage.swift index 3e1057c..c1184af 100644 --- a/ios/Ascently/Utils/OrientationAwareImage.swift +++ b/ios/Ascently/Utils/OrientationAwareImage.swift @@ -31,7 +31,7 @@ struct OrientationAwareImage: View { .onAppear { loadImageWithCorrectOrientation() } - .onChange(of: imagePath) { _ in + .onChange(of: imagePath) { _, _ in loadImageWithCorrectOrientation() } } diff --git a/ios/Ascently/Views/CalendarView.swift b/ios/Ascently/Views/CalendarView.swift new file mode 100644 index 0000000..a897609 --- /dev/null +++ b/ios/Ascently/Views/CalendarView.swift @@ -0,0 +1,338 @@ +import SwiftUI + +struct CalendarView: View { + @EnvironmentObject var dataManager: ClimbingDataManager + let sessions: [ClimbSession] + @Binding var selectedMonth: Date + @Binding var selectedDate: Date? + let onNavigateToSession: (UUID) -> Void + + var calendar: Calendar { + Calendar.current + } + + var monthYearString: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: selectedMonth) + } + + var sessionsByDate: [Date: [ClimbSession]] { + Dictionary(grouping: sessions) { session in + calendar.startOfDay(for: session.date) + } + } + + var daysInMonth: [Date?] { + guard let monthInterval = calendar.dateInterval(of: .month, for: selectedMonth), + calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) != nil + else { + return [] + } + + let days = calendar.generateDates( + inside: monthInterval, + matching: DateComponents(hour: 0, minute: 0, second: 0) + ) + + let firstDayOfMonth = days.first ?? monthInterval.start + let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth) + let offset = firstWeekday - 1 + + var paddedDays: [Date?] = Array(repeating: nil, count: offset) + paddedDays.append(contentsOf: days.map { $0 as Date? }) + + let remainder = paddedDays.count % 7 + if remainder != 0 { + paddedDays.append(contentsOf: Array(repeating: nil, count: 7 - remainder)) + } + + return paddedDays + } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + if let activeSession = dataManager.activeSession, + let gym = dataManager.gym(withId: activeSession.gymId) + { + ActiveSessionBanner(session: activeSession, gym: gym) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 16) + } + + VStack(spacing: 8) { + HStack { + Button(action: { changeMonth(by: -1) }) { + Image(systemName: "chevron.left") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.blue) + } + .frame(width: 44, height: 44) + + Spacer() + + Text(monthYearString) + .font(.title3) + .fontWeight(.semibold) + + Spacer() + + Button(action: { changeMonth(by: 1) }) { + Image(systemName: "chevron.right") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.blue) + } + .frame(width: 44, height: 44) + } + + Button(action: { + let today = Date() + selectedMonth = today + selectedDate = today + }) { + Text("Today") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color.blue) + .clipShape(Capsule()) + } + } + .padding(.vertical, 16) + .padding(.horizontal) + + HStack(spacing: 0) { + ForEach(["S", "M", "T", "W", "T", "F", "S"], id: \.self) { day in + Text(day) + .font(.caption2) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal) + .padding(.bottom, 8) + + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), + spacing: 4 + ) { + ForEach(daysInMonth.indices, id: \.self) { index in + if let date = daysInMonth[index] { + CalendarDayCell( + date: date, + sessions: sessionsByDate[calendar.startOfDay(for: date)] ?? [], + isSelected: selectedDate.map { + calendar.isDate($0, inSameDayAs: date) + } + ?? false, + isToday: calendar.isDateInToday(date), + isInCurrentMonth: calendar.isDate( + date, equalTo: selectedMonth, toGranularity: .month) + ) { + if !sessionsByDate[calendar.startOfDay(for: date), default: []] + .isEmpty + { + if selectedDate.map({ calendar.isDate($0, inSameDayAs: date) }) + ?? false + { + selectedDate = nil + } else { + selectedDate = date + } + } + } + } else { + Color.clear + .aspectRatio(1, contentMode: .fit) + } + } + } + .padding(.horizontal) + + if let selected = selectedDate, + let sessionsOnDate = sessionsByDate[calendar.startOfDay(for: selected)], + !sessionsOnDate.isEmpty + { + Divider() + .padding(.vertical, 16) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + Text("Sessions on \(formatSelectedDate(selected))") + .font(.headline) + .fontWeight(.semibold) + .padding(.horizontal) + + VStack(spacing: 12) { + ForEach(sessionsOnDate) { session in + SessionCard( + session: session, + onTap: { + onNavigateToSession(session.id) + } + ) + .padding(.horizontal) + } + } + } + .padding(.bottom, 16) + } + } + } + } + + func changeMonth(by value: Int) { + if let newMonth = calendar.date(byAdding: .month, value: value, to: selectedMonth) { + selectedMonth = newMonth + selectedDate = nil + } + } + + func formatSelectedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM d, yyyy" + return formatter.string(from: date) + } +} + +struct CalendarDayCell: View { + let date: Date + let sessions: [ClimbSession] + let isSelected: Bool + let isToday: Bool + let isInCurrentMonth: Bool + let onTap: () -> Void + + var dayNumber: String { + let formatter = DateFormatter() + formatter.dateFormat = "d" + return formatter.string(from: date) + } + + var body: some View { + Button(action: onTap) { + VStack(spacing: 6) { + Text(dayNumber) + .font(.system(size: 17)) + .fontWeight(sessions.isEmpty ? .regular : .medium) + .foregroundColor( + isSelected + ? .white + : isToday + ? .blue + : !isInCurrentMonth + ? .secondary.opacity(0.3) + : sessions.isEmpty ? .secondary : .primary + ) + + if !sessions.isEmpty { + Circle() + .fill(isSelected ? .white : .blue) + .frame(width: 4, height: 4) + } else { + Spacer() + .frame(height: 4) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill( + isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke( + isToday && !isSelected ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 1 + ) + ) + } + .buttonStyle(PlainButtonStyle()) + .disabled(sessions.isEmpty) + } +} + +struct SessionCard: View { + @EnvironmentObject var dataManager: ClimbingDataManager + let session: ClimbSession + let onTap: () -> Void + + var gym: Gym? { + dataManager.gym(withId: session.gymId) + } + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(gym?.name ?? "Unknown Gym") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + + if let duration = session.duration { + Text("Duration: \(duration) minutes") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let notes = session.notes, !notes.isEmpty { + Text(notes) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(Color(.tertiaryLabel)) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .onTapGesture { + onTap() + } + } +} + +extension Calendar { + func generateDates( + inside interval: DateInterval, + matching components: DateComponents + ) -> [Date] { + var dates: [Date] = [] + dates.append(interval.start) + + enumerateDates( + startingAfter: interval.start, + matching: components, + matchingPolicy: .nextTime + ) { date, _, stop in + if let date = date { + if date < interval.end { + dates.append(date) + } else { + stop = true + } + } + } + + return dates + } +} diff --git a/ios/Ascently/Views/SessionsView.swift b/ios/Ascently/Views/SessionsView.swift index 3852036..d4d1fbc 100644 --- a/ios/Ascently/Views/SessionsView.swift +++ b/ios/Ascently/Views/SessionsView.swift @@ -1,9 +1,24 @@ import Combine import SwiftUI +enum SessionViewMode: String { + case list + case calendar +} + struct SessionsView: View { @EnvironmentObject var dataManager: ClimbingDataManager @State private var showingAddSession = false + @AppStorage("sessionViewMode") private var viewMode: SessionViewMode = .list + @State private var selectedMonth = Date() + @State private var selectedDate: Date? = nil + @State private var selectedSessionId: UUID? = nil + + private var completedSessions: [ClimbSession] { + dataManager.sessions + .filter { $0.status == .completed } + .sorted { $0.date > $1.date } + } var body: some View { NavigationStack { @@ -11,7 +26,18 @@ struct SessionsView: View { if dataManager.sessions.isEmpty && dataManager.activeSession == nil { EmptySessionsView() } else { - SessionsList() + if viewMode == .list { + SessionsList() + } else { + CalendarView( + sessions: completedSessions, + selectedMonth: $selectedMonth, + selectedDate: $selectedDate, + onNavigateToSession: { sessionId in + selectedSessionId = sessionId + } + ) + } } } .navigationTitle("Sessions") @@ -36,6 +62,20 @@ struct SessionsView: View { ) } + // View mode toggle + if !dataManager.sessions.isEmpty || dataManager.activeSession != nil { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + viewMode = viewMode == .list ? .calendar : .list + selectedDate = nil + } + }) { + Image(systemName: viewMode == .list ? "calendar" : "list.bullet") + .font(.body) + .fontWeight(.semibold) + } + } + if dataManager.gyms.isEmpty { EmptyView() } else if dataManager.activeSession == nil { @@ -52,6 +92,14 @@ struct SessionsView: View { .sheet(isPresented: $showingAddSession) { AddEditSessionView() } + .navigationDestination(isPresented: .constant(selectedSessionId != nil)) { + if let sessionId = selectedSessionId { + SessionDetailView(sessionId: sessionId) + .onDisappear { + selectedSessionId = nil + } + } + } } } }