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"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 46
|
||||
versionName = "2.2.1"
|
||||
versionCode = 47
|
||||
versionName = "2.3.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -38,7 +38,10 @@ android {
|
||||
|
||||
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
|
||||
|
||||
buildFeatures { compose = true }
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<!-- Permissions for notifications and foreground service -->
|
||||
<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_SPECIAL_USE" />
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<LocalDate?>(null) }
|
||||
var selectedDate by remember { mutableStateOf<LocalDate?>(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<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
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -3,11 +3,10 @@ package com.atridad.ascently.utils
|
||||
import android.util.Log
|
||||
import com.atridad.ascently.BuildConfig
|
||||
|
||||
/**
|
||||
* Centralized logging utility to ensure all mobile logging happens only in debug builds.
|
||||
*/
|
||||
object AppLogger {
|
||||
|
||||
private const val DEFAULT_TAG = "Ascently"
|
||||
|
||||
enum class Level(val androidLevel: Int) {
|
||||
DEBUG(Log.DEBUG),
|
||||
INFO(Log.INFO),
|
||||
@@ -46,6 +45,4 @@ object AppLogger {
|
||||
Log.println(level.androidLevel, tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_TAG = "Ascently"
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.5.0",
|
||||
"@astrojs/starlight": "^0.36.1",
|
||||
"astro": "^5.14.6",
|
||||
"@astrojs/node": "^9.5.1",
|
||||
"@astrojs/starlight": "^0.36.2",
|
||||
"astro": "^5.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.4"
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"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**
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Contact Us
|
||||
## Contact
|
||||
|
||||
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_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -487,7 +487,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -513,7 +513,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -535,7 +535,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -602,7 +602,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -613,7 +613,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -643,7 +643,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
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
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct AscentlyApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var dataManager = ClimbingDataManager()
|
||||
@StateObject private var dataManager = ClimbingDataManager.shared
|
||||
@State private var selectedTab = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var notificationObservers: [NSObjectProtocol] = []
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
|
||||
@@ -15,6 +15,8 @@ import UniformTypeIdentifiers
|
||||
@MainActor
|
||||
class ClimbingDataManager: ObservableObject {
|
||||
|
||||
static let shared = ClimbingDataManager()
|
||||
|
||||
@Published var gyms: [Gym] = []
|
||||
@Published var problems: [Problem] = []
|
||||
@Published var sessions: [ClimbSession] = []
|
||||
@@ -78,7 +80,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
let name: String
|
||||
}
|
||||
|
||||
init() {
|
||||
fileprivate init() {
|
||||
_ = ImageManager.shared
|
||||
migrateFromOpenClimbIfNeeded()
|
||||
loadAllData()
|
||||
@@ -415,9 +417,16 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
|
||||
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 {
|
||||
endSession(currentActive.id)
|
||||
await endSessionAsync(currentActive.id)
|
||||
}
|
||||
|
||||
let newSession = ClimbSession(gymId: gymId, notes: notes)
|
||||
@@ -430,64 +439,70 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// MARK: - Start Live Activity for new session
|
||||
if let gym = gym(withId: gymId) {
|
||||
Task {
|
||||
await LiveActivityManager.shared.startLiveActivity(
|
||||
for: newSession, gymName: gym.name)
|
||||
}
|
||||
await LiveActivityManager.shared.startLiveActivity(
|
||||
for: newSession,
|
||||
gymName: gym.name)
|
||||
}
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
Task {
|
||||
do {
|
||||
try await healthKitService.startWorkout(
|
||||
startDate: newSession.startTime ?? Date(),
|
||||
sessionId: newSession.id)
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to start HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
do {
|
||||
try await healthKitService.startWorkout(
|
||||
startDate: newSession.startTime ?? Date(),
|
||||
sessionId: newSession.id)
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to start HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
}
|
||||
|
||||
return newSession
|
||||
}
|
||||
|
||||
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 })
|
||||
{
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let completedSession = session.completed()
|
||||
sessions[index] = completedSession
|
||||
let completedSession = session.completed()
|
||||
sessions[index] = completedSession
|
||||
|
||||
if activeSession?.id == sessionId {
|
||||
activeSession = nil
|
||||
}
|
||||
if activeSession?.id == sessionId {
|
||||
activeSession = nil
|
||||
}
|
||||
|
||||
saveActiveSession()
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
saveActiveSession()
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
|
||||
// MARK: - End Live Activity after session ends
|
||||
Task {
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
}
|
||||
// MARK: - End Live Activity after session ends
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
Task {
|
||||
do {
|
||||
try await healthKitService.endWorkout(
|
||||
endDate: completedSession.endTime ?? Date())
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to end HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
}
|
||||
if healthKitService.isEnabled {
|
||||
do {
|
||||
try await healthKitService.endWorkout(
|
||||
endDate: completedSession.endTime ?? Date())
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to end HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
}
|
||||
|
||||
return completedSession
|
||||
}
|
||||
|
||||
func updateSession(_ session: ClimbSession) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import WidgetKit
|
||||
struct SessionStatusLiveBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SessionStatusLive()
|
||||
SessionStatusLiveControl()
|
||||
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"
|
||||
)
|
||||
|
||||
const VERSION = "2.2.0"
|
||||
const VERSION = "2.3.0"
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
|
||||
Reference in New Issue
Block a user