diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c928a22..103c27c 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 = 4 - versionName = "2.3.0" + versionCode = 47 + versionName = "2.3.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1d0ffa4..e55b417 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ + diff --git a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt index 77e27e3..2c4f07f 100644 --- a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt +++ b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt @@ -6,6 +6,8 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.os.Build +import android.os.Bundle import android.os.IBinder import androidx.core.app.NotificationCompat import com.atridad.ascently.MainActivity @@ -15,6 +17,7 @@ import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.widget.ClimbStatsWidgetProvider import java.time.LocalDateTime +import java.time.ZoneId import java.time.temporal.ChronoUnit import kotlinx.coroutines.* import kotlinx.coroutines.flow.firstOrNull @@ -209,33 +212,8 @@ class SessionTrackingService : Service() { repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() } - val duration = - session.startTime?.let { startTime -> - try { - val start = LocalDateTime.parse(startTime) - val now = LocalDateTime.now() - val totalSeconds = ChronoUnit.SECONDS.between(start, now) - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - val seconds = totalSeconds % 60 - - when { - hours > 0 -> "${hours}h ${minutes}m ${seconds}s" - minutes > 0 -> "${minutes}m ${seconds}s" - else -> "${totalSeconds}s" - } - } catch (_: Exception) { - "Active" - } - } - ?: "Active" - - val notification = + val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Climbing Session Active") - .setContentText( - "${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts" - ) .setSmallIcon(R.drawable.ic_mountains) .setOngoing(true) .setAutoCancel(false) @@ -253,7 +231,64 @@ class SessionTrackingService : Service() { "End Session", createStopPendingIntent(sessionId) ) - .build() + + // Use Live Update + if (Build.VERSION.SDK_INT >= 36) { + val startTimeMillis = + session.startTime?.let { startTime -> + try { + val start = LocalDateTime.parse(startTime) + val zoneId = ZoneId.systemDefault() + start.atZone(zoneId).toInstant().toEpochMilli() + } catch (_: Exception) { + System.currentTimeMillis() + } + } + ?: System.currentTimeMillis() + + notificationBuilder + .setContentTitle("Climbing Session Active") + .setContentText( + "${gym?.name ?: "Gym"} • ${attempts.size} attempts" + ) + .setWhen(startTimeMillis) + .setUsesChronometer(true) + .setShowWhen(true) + + val extras = Bundle() + extras.putBoolean("android.extra.REQUEST_PROMOTED_ONGOING", true) + notificationBuilder.setExtras(extras) + } else { + // Fallback for older versions + val duration = + session.startTime?.let { startTime -> + try { + val start = LocalDateTime.parse(startTime) + val now = LocalDateTime.now() + val totalSeconds = ChronoUnit.SECONDS.between(start, now) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + when { + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${totalSeconds}s" + } + } catch (_: Exception) { + "Active" + } + } + ?: "Active" + + notificationBuilder + .setContentTitle("Climbing Session Active") + .setContentText( + "${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts" + ) + } + + val notification = notificationBuilder.build() startForeground(NOTIFICATION_ID, notification) 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 3987255..0651f47 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 @@ -5,8 +5,6 @@ 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 @@ -38,6 +36,7 @@ import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale +import androidx.core.content.edit enum class ViewMode { LIST, @@ -60,7 +59,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST) } var selectedMonth by remember { mutableStateOf(YearMonth.now()) } - var selectedDate by remember { mutableStateOf(null) } + var selectedDate by remember { mutableStateOf(LocalDate.now()) } val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } @@ -89,7 +88,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String viewMode = if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST selectedDate = null - sharedPreferences.edit().putString("view_mode", viewMode.name).apply() + sharedPreferences.edit { putString("view_mode", viewMode.name) } } ) { Icon( @@ -147,16 +146,11 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String CalendarView( sessions = completedSessions, gyms = gyms, - activeSession = activeSession, - activeSessionGym = activeSessionGym, - selectedMonth = selectedMonth, + selectedMonth = selectedMonth, onMonthChange = { selectedMonth = it }, selectedDate = selectedDate, onDateSelected = { selectedDate = it }, - onNavigateToSessionDetail = onNavigateToSessionDetail, - onEndSession = { - activeSession?.let { viewModel.endSession(context, it.id) } - } + onNavigateToSessionDetail = onNavigateToSessionDetail ) } } @@ -315,14 +309,11 @@ fun EmptyStateMessage( 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 + onNavigateToSessionDetail: (String) -> Unit ) { val sessionsByDate = remember(sessions) { @@ -331,144 +322,155 @@ fun CalendarView( java.time.Instant.parse(it.date) .atZone(java.time.ZoneId.systemDefault()) .toLocalDate() - } catch (e: Exception) { + } catch (_: Exception) { LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE) } } } - Column(modifier = Modifier.fillMaxSize()) { - 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 + 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 + } + val numRows = totalCells / 7 + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) { - Text("‹", style = MaterialTheme.typography.headlineMedium) + 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 = - "${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}", - style = MaterialTheme.typography.titleMedium, + text = day, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold ) + } + } - IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) { - Text("›", style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(8.dp)) + } + + items(numRows) { rowIndex -> + Row(modifier = Modifier.fillMaxWidth()) { + for (colIndex in 0 until 7) { + val index = rowIndex * 7 + colIndex + val dayNumber = index - firstDayOfWeek + 1 + + Box(modifier = Modifier.weight(1f)) { + 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)) + } } } - - 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)) + item { + 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)) - } + Text( + text = + "Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) } + + 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)) + } + + item { Spacer(modifier = Modifier.height(16.dp)) } } } } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt index 88f57d2..348d013 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt @@ -583,41 +583,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) { Spacer(modifier = Modifier.height(12.dp)) - Card( - shape = RoundedCornerShape(12.dp), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = 0.3f - ) - ) - ) { - ListItem( - headlineContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = - painterResource( - id = R.drawable.ic_mountains - ), - contentDescription = "Ascently Logo", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text("Ascently") - } - }, - supportingContent = { Text("Track your climbing progress") }, - leadingContent = {} - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - Card( shape = RoundedCornerShape(12.dp), colors = diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 37d7eec..8517f13 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.12.3" -kotlin = "2.2.20" +kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -9,17 +9,17 @@ androidxTestCore = "1.7.0" androidxTestExt = "1.3.0" androidxTestRunner = "1.7.0" androidxTestRules = "1.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2025.10.00" -room = "2.8.2" -navigation = "2.9.5" -viewmodel = "2.9.4" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.0" +composeBom = "2025.11.01" +room = "2.8.4" +navigation = "2.9.6" +viewmodel = "2.10.0" kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" ksp = "2.2.20-2.0.3" -exifinterface = "1.3.6" +exifinterface = "1.4.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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 af9392d..56a4c98 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