Compare commits
7 Commits
93fb7a41fb
...
ANDROID_2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
6342bfed5c
|
|||
| 869ca0fc0d | |||
| 33562e9d16 | |||
|
a212f3f3b5
|
|||
|
a99196b9ca
|
|||
| 071e47f95e | |||
| c6c3e6084b |
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.ascently"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 46
|
versionCode = 47
|
||||||
versionName = "2.2.1"
|
versionName = "2.3.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,10 @@ android {
|
|||||||
|
|
||||||
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
|
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
|
||||||
|
|
||||||
buildFeatures { compose = true }
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
|
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
<!-- Permissions for notifications and foreground service -->
|
<!-- Permissions for notifications and foreground service -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.atridad.ascently.MainActivity
|
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.utils.AppLogger
|
||||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
@@ -209,33 +212,8 @@ class SessionTrackingService : Service() {
|
|||||||
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val duration =
|
val notificationBuilder =
|
||||||
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 =
|
|
||||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Climbing Session Active")
|
|
||||||
.setContentText(
|
|
||||||
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts"
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.drawable.ic_mountains)
|
.setSmallIcon(R.drawable.ic_mountains)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
@@ -253,7 +231,64 @@ class SessionTrackingService : Service() {
|
|||||||
"End Session",
|
"End Session",
|
||||||
createStopPendingIntent(sessionId)
|
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)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -38,6 +36,7 @@ import java.time.YearMonth
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.TextStyle
|
import java.time.format.TextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
enum class ViewMode {
|
enum class ViewMode {
|
||||||
LIST,
|
LIST,
|
||||||
@@ -60,7 +59,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
|
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
|
||||||
}
|
}
|
||||||
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||||
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
|
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
|
||||||
|
|
||||||
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 } }
|
||||||
@@ -89,7 +88,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
viewMode =
|
viewMode =
|
||||||
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
||||||
selectedDate = null
|
selectedDate = null
|
||||||
sharedPreferences.edit().putString("view_mode", viewMode.name).apply()
|
sharedPreferences.edit { putString("view_mode", viewMode.name) }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -147,16 +146,11 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
CalendarView(
|
CalendarView(
|
||||||
sessions = completedSessions,
|
sessions = completedSessions,
|
||||||
gyms = gyms,
|
gyms = gyms,
|
||||||
activeSession = activeSession,
|
selectedMonth = selectedMonth,
|
||||||
activeSessionGym = activeSessionGym,
|
|
||||||
selectedMonth = selectedMonth,
|
|
||||||
onMonthChange = { selectedMonth = it },
|
onMonthChange = { selectedMonth = it },
|
||||||
selectedDate = selectedDate,
|
selectedDate = selectedDate,
|
||||||
onDateSelected = { selectedDate = it },
|
onDateSelected = { selectedDate = it },
|
||||||
onNavigateToSessionDetail = onNavigateToSessionDetail,
|
onNavigateToSessionDetail = onNavigateToSessionDetail
|
||||||
onEndSession = {
|
|
||||||
activeSession?.let { viewModel.endSession(context, it.id) }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,14 +309,11 @@ fun EmptyStateMessage(
|
|||||||
fun CalendarView(
|
fun CalendarView(
|
||||||
sessions: List<ClimbSession>,
|
sessions: List<ClimbSession>,
|
||||||
gyms: List<com.atridad.ascently.data.model.Gym>,
|
gyms: List<com.atridad.ascently.data.model.Gym>,
|
||||||
activeSession: ClimbSession?,
|
|
||||||
activeSessionGym: com.atridad.ascently.data.model.Gym?,
|
|
||||||
selectedMonth: YearMonth,
|
selectedMonth: YearMonth,
|
||||||
onMonthChange: (YearMonth) -> Unit,
|
onMonthChange: (YearMonth) -> Unit,
|
||||||
selectedDate: LocalDate?,
|
selectedDate: LocalDate?,
|
||||||
onDateSelected: (LocalDate?) -> Unit,
|
onDateSelected: (LocalDate?) -> Unit,
|
||||||
onNavigateToSessionDetail: (String) -> Unit,
|
onNavigateToSessionDetail: (String) -> Unit
|
||||||
onEndSession: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val sessionsByDate =
|
val sessionsByDate =
|
||||||
remember(sessions) {
|
remember(sessions) {
|
||||||
@@ -331,144 +322,155 @@ fun CalendarView(
|
|||||||
java.time.Instant.parse(it.date)
|
java.time.Instant.parse(it.date)
|
||||||
.atZone(java.time.ZoneId.systemDefault())
|
.atZone(java.time.ZoneId.systemDefault())
|
||||||
.toLocalDate()
|
.toLocalDate()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
|
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
val firstDayOfMonth = selectedMonth.atDay(1)
|
||||||
Card(
|
val daysInMonth = selectedMonth.lengthOfMonth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
|
||||||
colors =
|
val totalCells =
|
||||||
CardDefaults.cardColors(
|
((firstDayOfWeek + daysInMonth) / 7.0).let {
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
|
||||||
)
|
}
|
||||||
) {
|
val numRows = totalCells / 7
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
Row(
|
||||||
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
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(
|
||||||
text =
|
text = day,
|
||||||
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text("›", style = MaterialTheme.typography.headlineMedium)
|
}
|
||||||
|
|
||||||
|
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) {
|
if (selectedDate != null) {
|
||||||
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
|
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -583,41 +583,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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(
|
Card(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors =
|
colors =
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ package com.atridad.ascently.utils
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.atridad.ascently.BuildConfig
|
import com.atridad.ascently.BuildConfig
|
||||||
|
|
||||||
/**
|
|
||||||
* Centralized logging utility to ensure all mobile logging happens only in debug builds.
|
|
||||||
*/
|
|
||||||
object AppLogger {
|
object AppLogger {
|
||||||
|
|
||||||
|
private const val DEFAULT_TAG = "Ascently"
|
||||||
|
|
||||||
enum class Level(val androidLevel: Int) {
|
enum class Level(val androidLevel: Int) {
|
||||||
DEBUG(Log.DEBUG),
|
DEBUG(Log.DEBUG),
|
||||||
INFO(Log.INFO),
|
INFO(Log.INFO),
|
||||||
@@ -46,6 +45,4 @@ object AppLogger {
|
|||||||
Log.println(level.androidLevel, tag, message)
|
Log.println(level.androidLevel, tag, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val DEFAULT_TAG = "Ascently"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.12.3"
|
agp = "8.12.3"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.21"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
@@ -9,17 +9,17 @@ androidxTestCore = "1.7.0"
|
|||||||
androidxTestExt = "1.3.0"
|
androidxTestExt = "1.3.0"
|
||||||
androidxTestRunner = "1.7.0"
|
androidxTestRunner = "1.7.0"
|
||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
lifecycleRuntimeKtx = "2.9.4"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.12.0"
|
||||||
composeBom = "2025.10.00"
|
composeBom = "2025.11.01"
|
||||||
room = "2.8.2"
|
room = "2.8.4"
|
||||||
navigation = "2.9.5"
|
navigation = "2.9.6"
|
||||||
viewmodel = "2.9.4"
|
viewmodel = "2.10.0"
|
||||||
kotlinxSerialization = "1.9.0"
|
kotlinxSerialization = "1.9.0"
|
||||||
kotlinxCoroutines = "1.10.2"
|
kotlinxCoroutines = "1.10.2"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
ksp = "2.2.20-2.0.3"
|
ksp = "2.2.20-2.0.3"
|
||||||
exifinterface = "1.3.6"
|
exifinterface = "1.4.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
|||||||
@@ -25,13 +25,13 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.0",
|
"@astrojs/node": "^9.5.1",
|
||||||
"@astrojs/starlight": "^0.36.1",
|
"@astrojs/starlight": "^0.36.2",
|
||||||
"astro": "^5.14.6",
|
"astro": "^5.16.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.4"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/qrcode": "^1.5.5"
|
"@types/qrcode": "^1.5.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1145
docs/pnpm-lock.yaml
generated
1145
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ description: Ascently's Privacy Policy
|
|||||||
|
|
||||||
**Last updated: September 29, 2025**
|
**Last updated: September 29, 2025**
|
||||||
|
|
||||||
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
|
This Privacy Policy describes my policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
|
||||||
|
|
||||||
## No Data Collection
|
## No Data Collection
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ You may optionally integrate with Apple Health or Android Health Connect to impo
|
|||||||
|
|
||||||
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
|
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
|
||||||
|
|
||||||
## Contact Us
|
## Contact
|
||||||
|
|
||||||
If you have any questions about this Privacy Policy, you can contact me:
|
If you have any questions about this Privacy Policy, you can contact me:
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.2.1;
|
MARKETING_VERSION = 2.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.2.1;
|
MARKETING_VERSION = 2.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -613,7 +613,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.2.1;
|
MARKETING_VERSION = 2.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.2.1;
|
MARKETING_VERSION = 2.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
Binary file not shown.
33
ios/Ascently/AppIntents/AscentlyShortcuts.swift
Normal file
33
ios/Ascently/AppIntents/AscentlyShortcuts.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app.
|
||||||
|
/// Surfaces intents that users can trigger hands-free to manage their climbing sessions.
|
||||||
|
struct AscentlyShortcuts: AppShortcutsProvider {
|
||||||
|
|
||||||
|
static var shortcutTileColor: ShortcutTileColor {
|
||||||
|
.teal
|
||||||
|
}
|
||||||
|
|
||||||
|
static var appShortcuts: [AppShortcut] {
|
||||||
|
return [
|
||||||
|
AppShortcut(
|
||||||
|
intent: StartLastGymSessionIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Start my climb in \(.applicationName)",
|
||||||
|
"Begin my last gym session in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Start Climb",
|
||||||
|
systemImageName: "figure.climbing"
|
||||||
|
),
|
||||||
|
AppShortcut(
|
||||||
|
intent: EndActiveSessionIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Finish my climb in \(.applicationName)",
|
||||||
|
"End my session in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "End Climb",
|
||||||
|
systemImageName: "flag.checkered"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ios/Ascently/AppIntents/EndActiveSessionIntent.swift
Normal file
40
ios/Ascently/AppIntents/EndActiveSessionIntent.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ends the currently active climbing session so logging stays in sync across devices.
|
||||||
|
/// Exposed to Shortcuts so users can wrap up a session without opening the app.
|
||||||
|
struct EndActiveSessionIntent: AppIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource {
|
||||||
|
"End Active Session"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var description: IntentDescription {
|
||||||
|
IntentDescription(
|
||||||
|
"Stop the active climbing session and save its progress in Ascently."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var openAppWhenRun: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||||
|
do {
|
||||||
|
let summary = try await SessionIntentController().endActiveSession()
|
||||||
|
let dialog = IntentDialog("Session at \(summary.gymName) ended. Nice work!")
|
||||||
|
return .result(dialog: dialog)
|
||||||
|
} catch SessionIntentError.noActiveSession {
|
||||||
|
// No active session is fine - just return a friendly message
|
||||||
|
let dialog = IntentDialog("No active session to end.")
|
||||||
|
return .result(dialog: dialog)
|
||||||
|
} catch {
|
||||||
|
// Re-throw other errors
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("End my current climbing session")
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ios/Ascently/AppIntents/SessionIntentSupport.swift
Normal file
95
ios/Ascently/AppIntents/SessionIntentSupport.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// User-visible errors that can arise while handling session-related intents.
|
||||||
|
enum SessionIntentError: LocalizedError {
|
||||||
|
case noRecentGym
|
||||||
|
case noActiveSession
|
||||||
|
case failedToStartSession
|
||||||
|
case failedToEndSession
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noRecentGym:
|
||||||
|
return "There's no recent gym to start a session with."
|
||||||
|
case .noActiveSession:
|
||||||
|
return "There isn't an active session to end right now."
|
||||||
|
case .failedToStartSession:
|
||||||
|
return "Ascently couldn't start a new session."
|
||||||
|
case .failedToEndSession:
|
||||||
|
return "Ascently couldn't finish the active session."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionIntentSummary: Sendable {
|
||||||
|
let sessionId: UUID
|
||||||
|
let gymName: String
|
||||||
|
let status: SessionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts.
|
||||||
|
@MainActor
|
||||||
|
final class SessionIntentController {
|
||||||
|
|
||||||
|
private let dataManager: ClimbingDataManager
|
||||||
|
|
||||||
|
init(dataManager: ClimbingDataManager = .shared) {
|
||||||
|
self.dataManager = dataManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a new session using the most recently visited gym.
|
||||||
|
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
|
||||||
|
// Give a moment for data to be ready if app just launched
|
||||||
|
if dataManager.gyms.isEmpty {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let lastGym = dataManager.getLastUsedGym() else {
|
||||||
|
logFailure(.noRecentGym, context: "No recorded sessions available")
|
||||||
|
throw SessionIntentError.noRecentGym
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
|
||||||
|
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
|
||||||
|
throw SessionIntentError.failedToStartSession
|
||||||
|
}
|
||||||
|
|
||||||
|
return SessionIntentSummary(
|
||||||
|
sessionId: startedSession.id,
|
||||||
|
gymName: lastGym.name,
|
||||||
|
status: startedSession.status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the currently active climbing session, if one exists.
|
||||||
|
func endActiveSession() async throws -> SessionIntentSummary {
|
||||||
|
guard let activeSession = dataManager.activeSession else {
|
||||||
|
logFailure(.noActiveSession, context: "No active session stored in data manager")
|
||||||
|
throw SessionIntentError.noActiveSession
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
|
||||||
|
logFailure(
|
||||||
|
.failedToEndSession, context: "Data manager failed to complete active session")
|
||||||
|
throw SessionIntentError.failedToEndSession
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let gym = dataManager.gym(withId: completedSession.gymId) else {
|
||||||
|
logFailure(
|
||||||
|
.failedToEndSession,
|
||||||
|
context: "Gym missing for completed session \(completedSession.id)")
|
||||||
|
throw SessionIntentError.failedToEndSession
|
||||||
|
}
|
||||||
|
|
||||||
|
return SessionIntentSummary(
|
||||||
|
sessionId: completedSession.id,
|
||||||
|
gymName: gym.name,
|
||||||
|
status: completedSession.status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logFailure(_ error: SessionIntentError, context: String) {
|
||||||
|
// Logging from intent context - errors are visible to user via dialog
|
||||||
|
print("SessionIntentError: \(error). Context: \(context)")
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ios/Ascently/AppIntents/StartLastGymSessionIntent.swift
Normal file
43
ios/Ascently/AppIntents/StartLastGymSessionIntent.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Starts a climbing session at the most recently visited gym.
|
||||||
|
/// Exposed to Shortcuts so users can begin logging without opening the app.
|
||||||
|
struct StartLastGymSessionIntent: AppIntent {
|
||||||
|
|
||||||
|
static var title: LocalizedStringResource {
|
||||||
|
"Start Last Gym Session"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var description: IntentDescription {
|
||||||
|
IntentDescription(
|
||||||
|
"Begin a new climbing session using the most recent gym you visited in Ascently."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var openAppWhenRun: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||||
|
// Delay to ensure app has time to fully initialize if just launched
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||||||
|
|
||||||
|
let summary = try await SessionIntentController().startSessionWithLastUsedGym()
|
||||||
|
|
||||||
|
// Give Live Activity extra time to start
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||||
|
|
||||||
|
return .result(
|
||||||
|
dialog: Self.successDialog(for: summary.gymName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func successDialog(for gymName: String) -> IntentDialog {
|
||||||
|
IntentDialog("Session started at \(gymName). Have an awesome climb!")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("Start a session at my last gym")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct AscentlyApp: App {
|
struct AscentlyApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var dataManager = ClimbingDataManager()
|
@StateObject private var dataManager = ClimbingDataManager.shared
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var notificationObservers: [NSObjectProtocol] = []
|
@State private var notificationObservers: [NSObjectProtocol] = []
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import UniformTypeIdentifiers
|
|||||||
@MainActor
|
@MainActor
|
||||||
class ClimbingDataManager: ObservableObject {
|
class ClimbingDataManager: ObservableObject {
|
||||||
|
|
||||||
|
static let shared = ClimbingDataManager()
|
||||||
|
|
||||||
@Published var gyms: [Gym] = []
|
@Published var gyms: [Gym] = []
|
||||||
@Published var problems: [Problem] = []
|
@Published var problems: [Problem] = []
|
||||||
@Published var sessions: [ClimbSession] = []
|
@Published var sessions: [ClimbSession] = []
|
||||||
@@ -78,7 +80,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
let name: String
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
fileprivate init() {
|
||||||
_ = ImageManager.shared
|
_ = ImageManager.shared
|
||||||
migrateFromOpenClimbIfNeeded()
|
migrateFromOpenClimbIfNeeded()
|
||||||
loadAllData()
|
loadAllData()
|
||||||
@@ -415,9 +417,16 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startSession(gymId: UUID, notes: String? = nil) {
|
func startSession(gymId: UUID, notes: String? = nil) {
|
||||||
// End any currently active session
|
Task { @MainActor in
|
||||||
|
await startSessionAsync(gymId: gymId, notes: notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
|
||||||
|
// End any currently active session before starting a new one
|
||||||
if let currentActive = activeSession {
|
if let currentActive = activeSession {
|
||||||
endSession(currentActive.id)
|
await endSessionAsync(currentActive.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSession = ClimbSession(gymId: gymId, notes: notes)
|
let newSession = ClimbSession(gymId: gymId, notes: notes)
|
||||||
@@ -430,64 +439,70 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Start Live Activity for new session
|
// MARK: - Start Live Activity for new session
|
||||||
if let gym = gym(withId: gymId) {
|
if let gym = gym(withId: gymId) {
|
||||||
Task {
|
await LiveActivityManager.shared.startLiveActivity(
|
||||||
await LiveActivityManager.shared.startLiveActivity(
|
for: newSession,
|
||||||
for: newSession, gymName: gym.name)
|
gymName: gym.name)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
Task {
|
do {
|
||||||
do {
|
try await healthKitService.startWorkout(
|
||||||
try await healthKitService.startWorkout(
|
startDate: newSession.startTime ?? Date(),
|
||||||
startDate: newSession.startTime ?? Date(),
|
sessionId: newSession.id)
|
||||||
sessionId: newSession.id)
|
} catch {
|
||||||
} catch {
|
AppLogger.error(
|
||||||
AppLogger.error(
|
"Failed to start HealthKit workout: \(error.localizedDescription)",
|
||||||
"Failed to start HealthKit workout: \(error.localizedDescription)",
|
tag: LogTag.climbingData)
|
||||||
tag: LogTag.climbingData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return newSession
|
||||||
}
|
}
|
||||||
|
|
||||||
func endSession(_ sessionId: UUID) {
|
func endSession(_ sessionId: UUID) {
|
||||||
if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
|
Task { @MainActor in
|
||||||
|
await endSessionAsync(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? {
|
||||||
|
guard
|
||||||
|
let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
|
||||||
let index = sessions.firstIndex(where: { $0.id == sessionId })
|
let index = sessions.firstIndex(where: { $0.id == sessionId })
|
||||||
{
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
let completedSession = session.completed()
|
let completedSession = session.completed()
|
||||||
sessions[index] = completedSession
|
sessions[index] = completedSession
|
||||||
|
|
||||||
if activeSession?.id == sessionId {
|
if activeSession?.id == sessionId {
|
||||||
activeSession = nil
|
activeSession = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
saveActiveSession()
|
saveActiveSession()
|
||||||
saveSessions()
|
saveSessions()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
// Trigger auto-sync if enabled
|
// Trigger auto-sync if enabled
|
||||||
syncService.triggerAutoSync(dataManager: self)
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
|
|
||||||
// MARK: - End Live Activity after session ends
|
// MARK: - End Live Activity after session ends
|
||||||
Task {
|
await LiveActivityManager.shared.endLiveActivity()
|
||||||
await LiveActivityManager.shared.endLiveActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
Task {
|
do {
|
||||||
do {
|
try await healthKitService.endWorkout(
|
||||||
try await healthKitService.endWorkout(
|
endDate: completedSession.endTime ?? Date())
|
||||||
endDate: completedSession.endTime ?? Date())
|
} catch {
|
||||||
} catch {
|
AppLogger.error(
|
||||||
AppLogger.error(
|
"Failed to end HealthKit workout: \(error.localizedDescription)",
|
||||||
"Failed to end HealthKit workout: \(error.localizedDescription)",
|
tag: LogTag.climbingData)
|
||||||
tag: LogTag.climbingData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return completedSession
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSession(_ session: ClimbSession) {
|
func updateSession(_ session: ClimbSession) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import WidgetKit
|
|||||||
struct SessionStatusLiveBundle: WidgetBundle {
|
struct SessionStatusLiveBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
SessionStatusLive()
|
SessionStatusLive()
|
||||||
SessionStatusLiveControl()
|
|
||||||
SessionStatusLiveLiveActivity()
|
SessionStatusLiveLiveActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
//
|
|
||||||
// SessionStatusLiveControl.swift
|
|
||||||
|
|
||||||
import AppIntents
|
|
||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
struct SessionStatusLiveControl: ControlWidget {
|
|
||||||
static let kind: String = "com.atridad.Ascently.SessionStatusLive"
|
|
||||||
|
|
||||||
var body: some ControlWidgetConfiguration {
|
|
||||||
AppIntentControlConfiguration(
|
|
||||||
kind: Self.kind,
|
|
||||||
provider: Provider()
|
|
||||||
) { value in
|
|
||||||
ControlWidgetToggle(
|
|
||||||
"Start Timer",
|
|
||||||
isOn: value.isRunning,
|
|
||||||
action: StartTimerIntent(value.name)
|
|
||||||
) { isRunning in
|
|
||||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.displayName("Timer")
|
|
||||||
.description("A an example control that runs a timer.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SessionStatusLiveControl {
|
|
||||||
struct Value {
|
|
||||||
var isRunning: Bool
|
|
||||||
var name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Provider: AppIntentControlValueProvider {
|
|
||||||
func previewValue(configuration: TimerConfiguration) -> Value {
|
|
||||||
SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
|
||||||
let isRunning = true // Check if the timer is running
|
|
||||||
return SessionStatusLiveControl.Value(
|
|
||||||
isRunning: isRunning, name: configuration.timerName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimerConfiguration: ControlConfigurationIntent {
|
|
||||||
static let title: LocalizedStringResource = "Timer Name Configuration"
|
|
||||||
|
|
||||||
@Parameter(title: "Timer Name", default: "Timer")
|
|
||||||
var timerName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StartTimerIntent: SetValueIntent {
|
|
||||||
static let title: LocalizedStringResource = "Start a timer"
|
|
||||||
|
|
||||||
@Parameter(title: "Timer Name")
|
|
||||||
var name: String
|
|
||||||
|
|
||||||
@Parameter(title: "Timer is running")
|
|
||||||
var value: Bool
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(_ name: String) {
|
|
||||||
self.name = name
|
|
||||||
}
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
// Start the timer…
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "2.2.0"
|
const VERSION = "2.3.0"
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
|
|||||||
Reference in New Issue
Block a user