[Mobile] 2.2.0 - Calendar View
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.ascently"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 43
|
versionCode = 44
|
||||||
versionName = "2.1.1"
|
versionName = "2.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
package com.atridad.ascently.ui.screens
|
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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.components.SyncIndicator
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import com.atridad.ascently.utils.DateFormatUtils
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -33,7 +53,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
val activeSession by viewModel.activeSession.collectAsState()
|
val activeSession by viewModel.activeSession.collectAsState()
|
||||||
val uiState by viewModel.uiState.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 completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||||
|
|
||||||
@@ -55,12 +83,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
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)
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Active session banner
|
|
||||||
ActiveSessionBanner(
|
ActiveSessionBanner(
|
||||||
activeSession = activeSession,
|
activeSession = activeSession,
|
||||||
gym = activeSessionGym,
|
gym = activeSessionGym,
|
||||||
@@ -83,20 +129,40 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
actionText = ""
|
actionText = ""
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
when (viewMode) {
|
||||||
|
ViewMode.LIST -> {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(completedSessions) { session ->
|
items(completedSessions) { session ->
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == session.gymId }?.name
|
||||||
|
?: "Unknown Gym",
|
||||||
onClick = { onNavigateToSessionDetail(session.id) }
|
onClick = { onNavigateToSessionDetail(session.id) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show UI state messages and errors
|
|
||||||
uiState.message?.let { message ->
|
uiState.message?.let { message ->
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
kotlinx.coroutines.delay(5000)
|
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 {
|
private fun formatDate(dateString: String): String {
|
||||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
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">
|
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
|
||||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
<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>
|
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Binary file not shown.
@@ -158,7 +158,7 @@ class SyncService: ObservableObject {
|
|||||||
let modifiedProblems = dataManager.problems.filter { problem in
|
let modifiedProblems = dataManager.problems.filter { problem in
|
||||||
problem.updatedAt > lastSync
|
problem.updatedAt > lastSync
|
||||||
}.map { problem -> BackupProblem in
|
}.map { problem -> BackupProblem in
|
||||||
var backupProblem = BackupProblem(from: problem)
|
let backupProblem = BackupProblem(from: problem)
|
||||||
if !problem.imagePaths.isEmpty {
|
if !problem.imagePaths.isEmpty {
|
||||||
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
||||||
ImageNamingUtils.generateImageFilename(
|
ImageNamingUtils.generateImageFilename(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ struct OrientationAwareImage: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadImageWithCorrectOrientation()
|
loadImageWithCorrectOrientation()
|
||||||
}
|
}
|
||||||
.onChange(of: imagePath) { _ in
|
.onChange(of: imagePath) { _, _ in
|
||||||
loadImageWithCorrectOrientation()
|
loadImageWithCorrectOrientation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
338
ios/Ascently/Views/CalendarView.swift
Normal file
338
ios/Ascently/Views/CalendarView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SessionViewMode: String {
|
||||||
|
case list
|
||||||
|
case calendar
|
||||||
|
}
|
||||||
|
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var showingAddSession = false
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -11,7 +26,18 @@ struct SessionsView: View {
|
|||||||
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
|
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
|
||||||
EmptySessionsView()
|
EmptySessionsView()
|
||||||
} else {
|
} else {
|
||||||
|
if viewMode == .list {
|
||||||
SessionsList()
|
SessionsList()
|
||||||
|
} else {
|
||||||
|
CalendarView(
|
||||||
|
sessions: completedSessions,
|
||||||
|
selectedMonth: $selectedMonth,
|
||||||
|
selectedDate: $selectedDate,
|
||||||
|
onNavigateToSession: { sessionId in
|
||||||
|
selectedSessionId = sessionId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Sessions")
|
.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 {
|
if dataManager.gyms.isEmpty {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else if dataManager.activeSession == nil {
|
} else if dataManager.activeSession == nil {
|
||||||
@@ -52,6 +92,14 @@ struct SessionsView: View {
|
|||||||
.sheet(isPresented: $showingAddSession) {
|
.sheet(isPresented: $showingAddSession) {
|
||||||
AddEditSessionView()
|
AddEditSessionView()
|
||||||
}
|
}
|
||||||
|
.navigationDestination(isPresented: .constant(selectedSessionId != nil)) {
|
||||||
|
if let sessionId = selectedSessionId {
|
||||||
|
SessionDetailView(sessionId: sessionId)
|
||||||
|
.onDisappear {
|
||||||
|
selectedSessionId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user