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