Compare commits

...

9 Commits

Author SHA1 Message Date
f4f4968431 Sync bug fixes across the board!
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
2026-01-09 22:48:20 -07:00
d002c703d5 Fixed a number of sync issues I noticed
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m30s
2026-01-09 14:39:28 -07:00
afb0456692 more 2026-01-09 00:27:55 -07:00
74db155d93 New intermediate iOS build that trims some fat 2026-01-09 00:27:12 -07:00
ec63d7c58f Improve concurrency model for iOS 2026-01-08 19:18:44 -07:00
1c47dd93b0 Added alternate icon groundwork 2026-01-08 14:27:27 -07:00
ef05727cde Branding overhaul based on Icon Composer 2026-01-08 14:14:12 -07:00
452fd96372 Icon improvements for iOS 2026-01-08 13:59:49 -07:00
d263c6c87e iOS and Android dependency updates and optimizations 2026-01-06 12:27:28 -07:00
143 changed files with 1995 additions and 4030 deletions

View File

@@ -3,7 +3,6 @@
This is the native Android app for Ascently, built with Kotlin and Jetpack Compose. This is the native Android app for Ascently, built with Kotlin and Jetpack Compose.
## Project Structure ## Project Structure
This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/ascently/`. This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/ascently/`.
- `data/`: Handles all the app's data. - `data/`: Handles all the app's data.

View File

@@ -18,8 +18,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 49 versionCode = 51
versionName = "2.4.1" versionName = "2.5.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -13,7 +13,6 @@ data class ClimbDataBackup(
val problems: List<BackupProblem>, val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>, val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>, val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem> = emptyList(),
) )
@Serializable @Serializable
@@ -34,6 +33,7 @@ data class BackupGym(
@kotlinx.serialization.SerialName("customDifficultyGrades") @kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String>? = null, val customDifficultyGrades: List<String>? = null,
val notes: String? = null, val notes: String? = null,
val isDeleted: Boolean = false,
val createdAt: String, val createdAt: String,
val updatedAt: String, val updatedAt: String,
) { ) {
@@ -47,10 +47,26 @@ data class BackupGym(
difficultySystems = gym.difficultySystems, difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null }, customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null },
notes = gym.notes, notes = gym.notes,
isDeleted = false,
createdAt = gym.createdAt, createdAt = gym.createdAt,
updatedAt = gym.updatedAt, updatedAt = gym.updatedAt,
) )
} }
fun createTombstone(id: String, deletedAt: String): BackupGym {
return BackupGym(
id = id,
name = "DELETED",
location = null,
supportedClimbTypes = emptyList(),
difficultySystems = emptyList(),
customDifficultyGrades = null,
notes = null,
isDeleted = true,
createdAt = deletedAt,
updatedAt = deletedAt,
)
}
} }
fun toGym(): Gym { fun toGym(): Gym {
@@ -83,6 +99,7 @@ data class BackupProblem(
val isActive: Boolean = true, val isActive: Boolean = true,
val dateSet: String? = null, val dateSet: String? = null,
val notes: String? = null, val notes: String? = null,
val isDeleted: Boolean = false,
val createdAt: String, val createdAt: String,
val updatedAt: String, val updatedAt: String,
) { ) {
@@ -106,10 +123,31 @@ data class BackupProblem(
isActive = problem.isActive, isActive = problem.isActive,
dateSet = problem.dateSet, dateSet = problem.dateSet,
notes = problem.notes, notes = problem.notes,
isDeleted = false,
createdAt = problem.createdAt, createdAt = problem.createdAt,
updatedAt = problem.updatedAt, updatedAt = problem.updatedAt,
) )
} }
fun createTombstone(id: String, deletedAt: String): BackupProblem {
return BackupProblem(
id = id,
gymId = "00000000-0000-0000-0000-000000000000",
name = "DELETED",
description = null,
climbType = ClimbType.values().first(),
difficulty = DifficultyGrade(DifficultySystem.values().first(), "0"),
tags = null,
location = null,
imagePaths = null,
isActive = false,
dateSet = null,
notes = null,
isDeleted = true,
createdAt = deletedAt,
updatedAt = deletedAt,
)
}
} }
fun toProblem(): Problem { fun toProblem(): Problem {
@@ -147,6 +185,7 @@ data class BackupClimbSession(
val duration: Long? = null, val duration: Long? = null,
val status: SessionStatus, val status: SessionStatus,
val notes: String? = null, val notes: String? = null,
val isDeleted: Boolean = false,
val createdAt: String, val createdAt: String,
val updatedAt: String, val updatedAt: String,
) { ) {
@@ -161,10 +200,27 @@ data class BackupClimbSession(
duration = session.duration, duration = session.duration,
status = session.status, status = session.status,
notes = session.notes, notes = session.notes,
isDeleted = false,
createdAt = session.createdAt, createdAt = session.createdAt,
updatedAt = session.updatedAt, updatedAt = session.updatedAt,
) )
} }
fun createTombstone(id: String, deletedAt: String): BackupClimbSession {
return BackupClimbSession(
id = id,
gymId = "00000000-0000-0000-0000-000000000000",
date = deletedAt,
startTime = null,
endTime = null,
duration = null,
status = SessionStatus.values().first(),
notes = null,
isDeleted = true,
createdAt = deletedAt,
updatedAt = deletedAt,
)
}
} }
fun toClimbSession(): ClimbSession { fun toClimbSession(): ClimbSession {
@@ -195,6 +251,7 @@ data class BackupAttempt(
val duration: Long? = null, val duration: Long? = null,
val restTime: Long? = null, val restTime: Long? = null,
val timestamp: String, val timestamp: String,
val isDeleted: Boolean = false,
val createdAt: String, val createdAt: String,
val updatedAt: String? = null, val updatedAt: String? = null,
) { ) {
@@ -210,10 +267,28 @@ data class BackupAttempt(
duration = attempt.duration, duration = attempt.duration,
restTime = attempt.restTime, restTime = attempt.restTime,
timestamp = attempt.timestamp, timestamp = attempt.timestamp,
isDeleted = false,
createdAt = attempt.createdAt, createdAt = attempt.createdAt,
updatedAt = attempt.updatedAt, updatedAt = attempt.updatedAt,
) )
} }
fun createTombstone(id: String, deletedAt: String): BackupAttempt {
return BackupAttempt(
id = id,
sessionId = "00000000-0000-0000-0000-000000000000",
problemId = "00000000-0000-0000-0000-000000000000",
result = AttemptResult.values().first(),
highestHold = null,
notes = null,
duration = null,
restTime = null,
timestamp = deletedAt,
isDeleted = true,
createdAt = deletedAt,
updatedAt = deletedAt,
)
}
} }
fun toAttempt(): Attempt { fun toAttempt(): Attempt {

View File

@@ -10,6 +10,7 @@ import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ExerciseSessionRecord import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.units.Energy import androidx.health.connect.client.units.Energy
import com.atridad.ascently.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.SessionStatus import com.atridad.ascently.data.model.SessionStatus
@@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.flow
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import androidx.core.content.edit
/** /**
* Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit, * Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit,
@@ -197,6 +199,7 @@ class HealthConnectManager(private val context: Context) {
exerciseType = exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName", title = "Rock Climbing at $gymName",
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
) )
records.add(exerciseSession) records.add(exerciseSession)
} catch (e: Exception) { } catch (e: Exception) {
@@ -217,6 +220,7 @@ class HealthConnectManager(private val context: Context) {
endZoneOffset = endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime), ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories), energy = Energy.calories(estimatedCalories),
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
) )
records.add(caloriesRecord) records.add(caloriesRecord)
} }
@@ -239,9 +243,9 @@ class HealthConnectManager(private val context: Context) {
} }
preferences preferences
.edit() .edit {
.putString("last_sync_success", DateFormatUtils.nowISO8601()) putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply() }
} else { } else {
val reason = val reason =
when { when {
@@ -326,6 +330,7 @@ class HealthConnectManager(private val context: Context) {
endTime = endTime, endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples, samples = samples,
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
) )
} catch (e: Exception) { } catch (e: Exception) {
AppLogger.e(TAG, e) { "Error creating heart rate record" } AppLogger.e(TAG, e) { "Error creating heart rate record" }

View File

@@ -1,5 +1,6 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
@@ -34,6 +35,7 @@ enum class AttemptResult {
], ],
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])], indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])],
) )
@Immutable
@Serializable @Serializable
data class Attempt( data class Attempt(
@PrimaryKey val id: String, @PrimaryKey val id: String,

View File

@@ -1,5 +1,6 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
@@ -27,6 +28,7 @@ enum class SessionStatus {
], ],
indices = [Index(value = ["gymId"])], indices = [Index(value = ["gymId"])],
) )
@Immutable
@Serializable @Serializable
data class ClimbSession( data class ClimbSession(
@PrimaryKey val id: String, @PrimaryKey val id: String,

View File

@@ -1,10 +1,12 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Immutable
@Entity(tableName = "gyms") @Entity(tableName = "gyms")
@Serializable @Serializable
data class Gym( data class Gym(

View File

@@ -1,5 +1,6 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
@@ -7,6 +8,7 @@ import androidx.room.PrimaryKey
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Immutable
@Entity( @Entity(
tableName = "problems", tableName = "problems",
foreignKeys = foreignKeys =

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.Instant
class ClimbRepository(database: AscentlyDatabase, private val context: Context) { class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
private val gymDao = database.gymDao() private val gymDao = database.gymDao()
@@ -38,6 +39,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Gym operations // Gym operations
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms() fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
suspend fun getAllGymsSync(): List<Gym> = gymDao.getAllGyms().first()
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
suspend fun insertGym(gym: Gym) { suspend fun insertGym(gym: Gym) {
gymDao.insertGym(gym) gymDao.insertGym(gym)
@@ -60,6 +62,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Problem operations // Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems() fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getAllProblemsSync(): List<Problem> = problemDao.getAllProblems().first()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId) fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
suspend fun insertProblem(problem: Problem) { suspend fun insertProblem(problem: Problem) {
@@ -80,6 +83,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Session operations // Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getAllSessionsSync(): List<ClimbSession> = sessionDao.getAllSessions().first()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId) sessionDao.getSessionsByGym(gymId)
@@ -122,6 +126,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Attempt operations // Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
suspend fun getAllAttemptsSync(): List<Attempt> = attemptDao.getAllAttempts().first()
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId) attemptDao.getAttemptsBySession(sessionId)
@@ -273,10 +279,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
fun trackDeletion(itemId: String, itemType: String) { fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList() cleanupOldDeletions()
val newDeletion = val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion) val json = json.encodeToString(newDeletion)
deletionPreferences.edit { putString("deleted_$itemId", json) } deletionPreferences.edit { putString("deleted_$itemId", json) }
@@ -304,6 +309,27 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
deletionPreferences.edit { clear() } deletionPreferences.edit { clear() }
} }
private fun cleanupOldDeletions() {
val allPrefs = deletionPreferences.all
val cutoff = Instant.now().minusSeconds(90L * 24 * 60 * 60)
deletionPreferences.edit {
for ((key, value) in allPrefs) {
if (key.startsWith("deleted_") && value is String) {
try {
val deletion = json.decodeFromString<DeletedItem>(value)
val deletedAt = Instant.parse(deletion.deletedAt)
if (deletedAt.isBefore(cutoff)) {
remove(key)
}
} catch (_: Exception) {
// Ignore
}
}
}
}
}
private fun validateDataIntegrity( private fun validateDataIntegrity(
gyms: List<Gym>, gyms: List<Gym>,
problems: List<Problem>, problems: List<Problem>,

View File

@@ -4,7 +4,6 @@ import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.DeletedItem
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** Request structure for delta sync - sends only changes since last sync */ /** Request structure for delta sync - sends only changes since last sync */
@@ -15,16 +14,15 @@ data class DeltaSyncRequest(
val problems: List<BackupProblem>, val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>, val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>, val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem>,
) )
/** Response structure for delta sync - receives only changes from server */ /** Response structure for delta sync - receives only changes from server */
@Serializable @Serializable
data class DeltaSyncResponse( data class DeltaSyncResponse(
val serverTime: String, val serverTime: String,
val requestFullSync: Boolean = false,
val gyms: List<BackupGym>, val gyms: List<BackupGym>,
val problems: List<BackupProblem>, val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>, val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>, val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem>,
) )

View File

@@ -18,4 +18,6 @@ sealed class SyncException(message: String) : IOException(message), Serializable
SyncException("Invalid server response: $details") SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details") data class NetworkError(val details: String) : SyncException("Network error: $details")
data class General(val details: String) : SyncException(details)
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.AppLogger
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -27,7 +28,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
// Currently we only support one provider, but this allows for future expansion // Currently we only support one provider, but this allows for future expansion
private val provider: SyncProvider = AscentlySyncProvider(context, repository) private val provider: SyncProvider = AscentlySyncProvider(context, repository, DataStateManager(context))
// State // State
private val _isSyncing = MutableStateFlow(false) private val _isSyncing = MutableStateFlow(false)

View File

@@ -15,6 +15,7 @@ import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@@ -237,9 +238,8 @@ class SessionTrackingService : Service() {
val startTimeMillis = val startTimeMillis =
session.startTime?.let { startTime -> session.startTime?.let { startTime ->
try { try {
val start = LocalDateTime.parse(startTime) DateFormatUtils.parseISO8601(startTime)?.toEpochMilli()
val zoneId = ZoneId.systemDefault() ?: System.currentTimeMillis()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) { } catch (_: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }
@@ -263,9 +263,9 @@ class SessionTrackingService : Service() {
val duration = val duration =
session.startTime?.let { startTime -> session.startTime?.let { startTime ->
try { try {
val start = LocalDateTime.parse(startTime) val start = DateFormatUtils.parseISO8601(startTime)
val now = LocalDateTime.now() val now = java.time.Instant.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now) val totalSeconds = if (start != null) ChronoUnit.SECONDS.between(start, now) else 0L
val hours = totalSeconds / 3600 val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60

View File

@@ -13,8 +13,9 @@ import androidx.compose.ui.unit.dp
import com.atridad.ascently.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.Gym import com.atridad.ascently.data.model.Gym
import com.atridad.ascently.ui.theme.CustomIcons import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.time.LocalDateTime import java.time.Instant
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@Composable @Composable
@@ -23,20 +24,31 @@ fun ActiveSessionBanner(
gym: Gym?, gym: Gym?,
onSessionClick: () -> Unit, onSessionClick: () -> Unit,
onEndSession: () -> Unit, onEndSession: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
if (activeSession != null) { if (activeSession == null) return
// Add a timer that updates every second for real-time duration counting
var currentTime by remember { mutableStateOf(LocalDateTime.now()) } val sessionId = activeSession.id
val startTimeString = activeSession.startTime
val gymName = gym?.name ?: "Unknown Gym"
var elapsedSeconds by remember(sessionId) { mutableLongStateOf(0L) }
LaunchedEffect(sessionId, startTimeString) {
if (startTimeString == null) return@LaunchedEffect
LaunchedEffect(Unit) {
while (true) { while (true) {
delay(1000) // Update every second elapsedSeconds = calculateElapsedSeconds(startTimeString)
currentTime = LocalDateTime.now() delay(1000)
} }
} }
val durationText = remember(elapsedSeconds) {
formatDuration(elapsedSeconds)
}
Card( Card(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onSessionClick() }, .clickable { onSessionClick() },
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -51,12 +63,10 @@ fun ActiveSessionBanner(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row( Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically,
) {
Icon( Icon(
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
contentDescription = "Active session", contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
@@ -72,24 +82,23 @@ fun ActiveSessionBanner(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = gym?.name ?: "Unknown Gym", text = gymName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
activeSession.startTime?.let { startTime -> if (startTimeString != null) {
val duration = calculateDuration(startTime, currentTime)
Text( Text(
text = duration, text = durationText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
) )
} }
} }
IconButton( FilledIconButton(
onClick = onEndSession, onClick = onEndSession,
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError, contentColor = MaterialTheme.colorScheme.onError,
), ),
@@ -101,23 +110,26 @@ fun ActiveSessionBanner(
} }
} }
} }
}
private fun calculateElapsedSeconds(startTimeString: String): Long {
return try {
val startTime = DateFormatUtils.parseISO8601(startTimeString) ?: return 0L
val now = Instant.now()
ChronoUnit.SECONDS.between(startTime, now).coerceAtLeast(0)
} catch (_: Exception) {
0L
} }
} }
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String { private fun formatDuration(totalSeconds: Long): String {
return try {
val startTime = LocalDateTime.parse(startTimeString)
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
val hours = totalSeconds / 3600 val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60
when { return when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s" else -> "${seconds}s"
}
} catch (_: Exception) {
"Active"
} }
} }

View File

@@ -27,6 +27,16 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
val attempts by viewModel.attempts.collectAsState() val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val gradeDistributionData = remember(sessions, problems, attempts) {
calculateGradeDistribution(sessions, problems, attempts)
}
val favoriteGym = remember(sessions, gyms) {
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let { (gymId, sessionList) ->
gyms.find { it.id == gymId }?.name to sessionList.size
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -65,18 +75,11 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
// Grade Distribution Chart // Grade Distribution Chart
item { item {
val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData) GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
} }
// Favorite Gym // Favorite Gym
item { item {
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, sessions) ->
gyms.find { it.id == gymId }?.name to sessions.size
}
FavoriteGymCard( FavoriteGymCard(
gymName = favoriteGym?.first ?: "No sessions yet", gymName = favoriteGym?.first ?: "No sessions yet",
sessionCount = favoriteGym?.second ?: 0, sessionCount = favoriteGym?.second ?: 0,

View File

@@ -225,25 +225,23 @@ fun SessionDetailScreen(
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) } var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
// Get session details // Get session details
val session = sessions.find { it.id == sessionId } val session = remember(sessions, sessionId) { sessions.find { it.id == sessionId } }
val gym = session?.let { s -> gyms.find { it.id == s.gymId } } val gym = remember(session, gyms) { session?.let { s -> gyms.find { it.id == s.gymId } } }
// Calculate stats // Calculate stats
val successfulAttempts = val successfulAttempts = remember(attempts) {
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblems = attempts.map { it.problemId }.distinct() }
val uniqueProblems = remember(attempts) { attempts.map { it.problemId }.distinct() }
val completedProblems = remember(successfulAttempts) { successfulAttempts.map { it.problemId }.distinct() }
val completedProblems = successfulAttempts.map { it.problemId }.distinct() val attemptsWithProblems = remember(attempts, problems) {
val attemptsWithProblems =
attempts attempts
.mapNotNull { attempt -> .mapNotNull { attempt ->
val problem = problems.find { it.id == attempt.problemId } val problem = problems.find { it.id == attempt.problemId }
if (problem != null) attempt to problem else null if (problem != null) attempt to problem else null
} }
.sortedBy { attempt -> .sortedBy { it.first.timestamp }
// Sort by timestamp (when attempt was logged)
attempt.first.timestamp
} }
Scaffold( Scaffold(
@@ -267,7 +265,7 @@ fun SessionDetailScreen(
if (session?.status == SessionStatus.ACTIVE) { if (session?.status == SessionStatus.ACTIVE) {
IconButton( IconButton(
onClick = { onClick = {
session.let { s -> session?.let { s ->
viewModel.endSession(context, s.id) viewModel.endSession(context, s.id)
onNavigateBack() onNavigateBack()
} }
@@ -313,8 +311,11 @@ fun SessionDetailScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val formattedDate = remember(session?.date) {
DateFormatUtils.formatDateForDisplay(session?.date ?: "")
}
Text( Text(
text = formatDate(session?.date ?: ""), text = formattedDate,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -503,11 +504,13 @@ fun SessionDetailScreen(
) )
} }
if (showAddAttemptDialog && session != null && gym != null) { val currentSession = session
val currentGym = gym
if (showAddAttemptDialog && currentSession != null && currentGym != null) {
EnhancedAddAttemptDialog( EnhancedAddAttemptDialog(
session = session, session = currentSession,
gym = gym, gym = currentGym,
problems = problems.filter { it.gymId == gym.id && it.isActive }, problems = problems.filter { it.gymId == currentGym.id && it.isActive },
onDismiss = { showAddAttemptDialog = false }, onDismiss = { showAddAttemptDialog = false },
onAttemptAdded = { attempt -> onAttemptAdded = { attempt ->
viewModel.addAttempt(attempt) viewModel.addAttempt(attempt)
@@ -728,9 +731,12 @@ fun ProblemDetailScreen(
} }
firstSuccess?.let { attempt -> firstSuccess?.let { attempt ->
val session = sessions.find { it.id == attempt.sessionId } val session = sessions.find { it.id == attempt.sessionId }
val firstSuccessDate = remember(session?.date) {
DateFormatUtils.formatDateForDisplay(session?.date ?: "")
}
Text( Text(
text = text =
"First success: ${formatDate(session?.date ?: "")} (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})", "First success: $firstSuccessDate (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -1309,6 +1315,10 @@ fun StatItem(label: String, value: String) {
@Composable @Composable
fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) { fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) {
val formattedDate = remember(session.date) {
DateFormatUtils.formatDateForDisplay(session.date)
}
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
@@ -1318,7 +1328,7 @@ fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) {
) { ) {
Column { Column {
Text( Text(
text = formatDate(session.date), text = formattedDate,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@@ -1478,9 +1488,7 @@ fun SessionAttemptCard(
} }
} }
private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@@ -52,7 +52,7 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
) )
} else { } else {
LazyColumn { LazyColumn {
items(gyms) { gym -> items(gyms, key = { it.id }) { gym ->
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) }) GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }

View File

@@ -35,17 +35,18 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
var selectedGym by remember { mutableStateOf<Gym?>(null) } var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters // Apply filters
val filteredProblems = val filteredProblems = remember(problems, selectedClimbType, selectedGym) {
problems.filter { problem -> problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch climbTypeMatch && gymMatch
} }
}
// Separate active and inactive problems // Separate active and inactive problems
val activeProblems = filteredProblems.filter { it.isActive } val activeProblems = remember(filteredProblems) { filteredProblems.filter { it.isActive } }
val inactiveProblems = filteredProblems.filter { !it.isActive } val inactiveProblems = remember(filteredProblems) { filteredProblems.filter { !it.isActive } }
val sortedProblems = activeProblems + inactiveProblems val sortedProblems = remember(activeProblems, inactiveProblems) { activeProblems + inactiveProblems }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
@@ -175,7 +176,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
) )
} else { } else {
LazyColumn { LazyColumn {
items(sortedProblems) { problem -> items(sortedProblems, key = { it.id }) { problem ->
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",

View File

@@ -61,8 +61,12 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
var selectedMonth by remember { mutableStateOf(YearMonth.now()) } var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) } var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val completedSessions = remember(sessions) {
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } sessions.filter { it.status == SessionStatus.COMPLETED }
}
val activeSessionGym = remember(activeSession, gyms) {
activeSession?.let { session -> gyms.find { it.id == session.gymId } }
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
@@ -136,7 +140,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
when (viewMode) { when (viewMode) {
ViewMode.LIST -> { ViewMode.LIST -> {
LazyColumn { LazyColumn {
items(completedSessions) { session -> items(completedSessions, key = { it.id }) { session ->
SessionCard( SessionCard(
session = session, session = session,
gymName = gyms.find { it.id == session.gymId }?.name gymName = gyms.find { it.id == session.gymId }?.name
@@ -232,6 +236,10 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) { fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
val formattedDate = remember(session.date) {
DateFormatUtils.formatDateForDisplay(session.date)
}
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row( Row(
@@ -244,7 +252,7 @@ fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = formatDate(session.date), text = formattedDate,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -539,7 +547,3 @@ fun CalendarDay(
} }
} }
} }
private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString)
}

View File

@@ -1,11 +1,74 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector
android:width="108dp"
android:height="108dp" android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
<!-- Clean white background -->
<path android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/> android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector> </vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.12.3" agp = "8.12.3"
kotlin = "2.2.21" kotlin = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
@@ -10,8 +10,8 @@ androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0" activityCompose = "1.12.2"
composeBom = "2025.11.01" composeBom = "2025.12.01"
room = "2.8.4" room = "2.8.4"
navigation = "2.9.6" navigation = "2.9.6"
viewmodel = "2.10.0" viewmodel = "2.10.0"
@@ -19,10 +19,10 @@ 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.4.1" exifinterface = "1.4.2"
healthConnect = "1.1.0-alpha07" healthConnect = "1.1.0"
detekt = "1.23.7" detekt = "1.23.8"
spotless = "6.25.0" spotless = "8.1.0"
[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" }

3
branding/.gitignore vendored
View File

@@ -1,3 +0,0 @@
*.tmp
.DS_Store
*.log

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,67 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscentlyRedBall.png",
"name" : "AscentlyRedBall",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
90.60312499999992,
127.86484375000009
]
}
},
{
"image-name" : "AscentlyYellowBall.png",
"name" : "AscentlyYellowBall",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
90.50312500000001,
-177.66484375
]
}
},
{
"image-name" : "AscentlyBlueBall.png",
"name" : "AscentlyBlueBall",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
-138.20312500000006,
177.3648437500001
]
}
},
{
"image-name" : "AscentlyGreenBall.png",
"name" : "AscentlyGreenBall",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
-138.30312499999997,
-43.08515625000001
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,45 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscetlyTriangle2.png",
"name" : "AscetlyTriangle2",
"position" : {
"scale" : 0.75,
"translation-in-points" : [
108,
-53.8125
]
}
},
{
"image-name" : "AscetlyTriangle1.png",
"name" : "AscetlyTriangle1",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
-215,
39.9375
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,394 +0,0 @@
#!/usr/bin/env python3
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Callable, TypedDict
from PIL import Image, ImageDraw
class Polygon(TypedDict):
coords: list[tuple[float, float]]
fill: str
SCRIPT_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPT_DIR.parent
SOURCE_DIR = SCRIPT_DIR / "source"
LOGOS_DIR = SCRIPT_DIR / "logos"
def parse_svg_polygons(svg_path: Path) -> list[Polygon]:
tree = ET.parse(svg_path)
root = tree.getroot()
ns = {"svg": "http://www.w3.org/2000/svg"}
polygons = root.findall(".//svg:polygon", ns)
if not polygons:
polygons = root.findall(".//polygon")
result: list[Polygon] = []
for poly in polygons:
points_str = poly.get("points", "").strip()
fill = poly.get("fill", "#000000")
coords: list[tuple[float, float]] = []
for pair in points_str.split():
x, y = pair.split(",")
coords.append((float(x), float(y)))
result.append({"coords": coords, "fill": fill})
return result
def get_bbox(polygons: list[Polygon]) -> dict[str, float]:
all_coords: list[tuple[float, float]] = []
for poly in polygons:
all_coords.extend(poly["coords"])
xs = [c[0] for c in all_coords]
ys = [c[1] for c in all_coords]
return {
"min_x": min(xs),
"max_x": max(xs),
"min_y": min(ys),
"max_y": max(ys),
"width": max(xs) - min(xs),
"height": max(ys) - min(ys),
}
def scale_and_center(
polygons: list[Polygon], viewbox_size: float, target_width: float
) -> list[Polygon]:
bbox = get_bbox(polygons)
scale = target_width / bbox["width"]
center = viewbox_size / 2
scaled_polys: list[Polygon] = []
for poly in polygons:
scaled_coords = [(x * scale, y * scale) for x, y in poly["coords"]]
scaled_polys.append({"coords": scaled_coords, "fill": poly["fill"]})
scaled_bbox = get_bbox(scaled_polys)
current_center_x = (scaled_bbox["min_x"] + scaled_bbox["max_x"]) / 2
current_center_y = (scaled_bbox["min_y"] + scaled_bbox["max_y"]) / 2
offset_x = center - current_center_x
offset_y = center - current_center_y
final_polys: list[Polygon] = []
for poly in scaled_polys:
final_coords = [(x + offset_x, y + offset_y) for x, y in poly["coords"]]
final_polys.append({"coords": final_coords, "fill": poly["fill"]})
return final_polys
def format_svg_points(coords: list[tuple[float, float]]) -> str:
return " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
def format_android_path(coords: list[tuple[float, float]]) -> str:
points = " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
pairs = points.split()
return f"M{pairs[0]} L{pairs[1]} L{pairs[2]} Z"
def generate_svg(polygons: list[Polygon], width: int, height: int) -> str:
lines = [
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">'
]
for poly in polygons:
points = format_svg_points(poly["coords"])
lines.append(f' <polygon points="{points}" fill="{poly["fill"]}"/>')
lines.append("</svg>")
return "\n".join(lines)
def generate_android_vector(
polygons: list[Polygon], width: int, height: int, viewbox: int
) -> str:
lines = [
'<?xml version="1.0" encoding="utf-8"?>',
'<vector xmlns:android="http://schemas.android.com/apk/res/android"',
f' android:width="{width}dp"',
f' android:height="{height}dp"',
f' android:viewportWidth="{viewbox}"',
f' android:viewportHeight="{viewbox}">',
]
for poly in polygons:
path = format_android_path(poly["coords"])
lines.append(
f' <path android:fillColor="{poly["fill"]}" android:pathData="{path}" />'
)
lines.append("</vector>")
return "\n".join(lines)
def rasterize_svg(
svg_path: Path,
output_path: Path,
size: int,
bg_color: tuple[int, int, int, int] | None = None,
circular: bool = False,
) -> None:
from xml.dom import minidom
doc = minidom.parse(str(svg_path))
img = Image.new(
"RGBA", (size, size), (255, 255, 255, 0) if bg_color is None else bg_color
)
draw = ImageDraw.Draw(img)
svg_elem = doc.getElementsByTagName("svg")[0]
viewbox = svg_elem.getAttribute("viewBox").split()
if viewbox:
vb_width = float(viewbox[2])
vb_height = float(viewbox[3])
scale_x = size / vb_width
scale_y = size / vb_height
else:
scale_x = scale_y = 1
def parse_transform(
transform_str: str,
) -> Callable[[float, float], tuple[float, float]]:
import re
if not transform_str:
return lambda x, y: (x, y)
transforms: list[tuple[str, list[float]]] = []
for match in re.finditer(r"(\w+)\(([^)]+)\)", transform_str):
func, args_str = match.groups()
args = [float(x) for x in args_str.replace(",", " ").split()]
transforms.append((func, args))
def apply_transforms(x: float, y: float) -> tuple[float, float]:
for func, args in transforms:
if func == "translate":
x += args[0]
y += args[1] if len(args) > 1 else args[0]
elif func == "scale":
x *= args[0]
y *= args[1] if len(args) > 1 else args[0]
return x, y
return apply_transforms
for g in doc.getElementsByTagName("g"):
transform = parse_transform(g.getAttribute("transform"))
for poly in g.getElementsByTagName("polygon"):
points_str = poly.getAttribute("points").strip()
fill = poly.getAttribute("fill")
if not fill:
fill = "#000000"
coords: list[tuple[float, float]] = []
for pair in points_str.split():
x, y = pair.split(",")
x, y = float(x), float(y)
x, y = transform(x, y)
coords.append((x * scale_x, y * scale_y))
draw.polygon(coords, fill=fill)
for poly in doc.getElementsByTagName("polygon"):
if poly.parentNode and getattr(poly.parentNode, "tagName", None) == "g":
continue
points_str = poly.getAttribute("points").strip()
fill = poly.getAttribute("fill")
if not fill:
fill = "#000000"
coords = []
for pair in points_str.split():
x, y = pair.split(",")
coords.append((float(x) * scale_x, float(y) * scale_y))
draw.polygon(coords, fill=fill)
if circular:
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
img.putalpha(mask)
img.save(output_path)
def main() -> None:
print("Generating branding assets...")
logo_svg = SOURCE_DIR / "logo.svg"
icon_light = SOURCE_DIR / "icon-light.svg"
icon_dark = SOURCE_DIR / "icon-dark.svg"
icon_tinted = SOURCE_DIR / "icon-tinted.svg"
polygons = parse_svg_polygons(logo_svg)
print(" iOS...")
ios_assets = PROJECT_ROOT / "ios/Ascently/Assets.xcassets/AppIcon.appiconset"
for src, dst in [
(icon_light, ios_assets / "app_icon_light_template.svg"),
(icon_dark, ios_assets / "app_icon_dark_template.svg"),
(icon_tinted, ios_assets / "app_icon_tinted_template.svg"),
]:
with open(src) as f:
content = f.read()
with open(dst, "w") as f:
f.write(content)
img_light = Image.new("RGB", (1024, 1024), (255, 255, 255))
draw_light = ImageDraw.Draw(img_light)
scaled = scale_and_center(polygons, 1024, int(1024 * 0.7))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw_light.polygon(coords, fill=poly["fill"])
img_light.save(ios_assets / "app_icon_1024.png")
img_dark = Image.new("RGB", (1024, 1024), (26, 26, 26))
draw_dark = ImageDraw.Draw(img_dark)
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw_dark.polygon(coords, fill=poly["fill"])
img_dark.save(ios_assets / "app_icon_1024_dark.png")
img_tinted = Image.new("RGB", (1024, 1024), (0, 0, 0))
draw_tinted = ImageDraw.Draw(img_tinted)
for i, poly in enumerate(scaled):
coords = [(x, y) for x, y in poly["coords"]]
draw_tinted.polygon(coords, fill=(0, 0, 0))
img_tinted.save(ios_assets / "app_icon_1024_tinted.png")
print(" Android...")
polys_108 = scale_and_center(polygons, 108, 60)
android_xml = generate_android_vector(polys_108, 108, 108, 108)
(
PROJECT_ROOT / "android/app/src/main/res/drawable/ic_launcher_foreground.xml"
).write_text(android_xml)
polys_24 = scale_and_center(polygons, 24, 20)
mountains_xml = generate_android_vector(polys_24, 24, 24, 24)
(PROJECT_ROOT / "android/app/src/main/res/drawable/ic_mountains.xml").write_text(
mountains_xml
)
for density, size in [
("mdpi", 48),
("hdpi", 72),
("xhdpi", 96),
("xxhdpi", 144),
("xxxhdpi", 192),
]:
mipmap_dir = PROJECT_ROOT / f"android/app/src/main/res/mipmap-{density}"
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.6))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(mipmap_dir / "ic_launcher.webp")
img_round = Image.new("RGBA", (size, size), (255, 255, 255, 255))
draw_round = ImageDraw.Draw(img_round)
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw_round.polygon(coords, fill=poly["fill"])
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
img_round.putalpha(mask)
img_round.save(mipmap_dir / "ic_launcher_round.webp")
print(" Docs...")
polys_32 = scale_and_center(polygons, 32, 26)
logo_svg_32 = generate_svg(polys_32, 32, 32)
(PROJECT_ROOT / "docs/src/assets/logo.svg").write_text(logo_svg_32)
(PROJECT_ROOT / "docs/src/assets/logo-dark.svg").write_text(logo_svg_32)
polys_256 = scale_and_center(polygons, 256, 208)
logo_svg_256 = generate_svg(polys_256, 256, 256)
(PROJECT_ROOT / "docs/src/assets/logo-highres.svg").write_text(logo_svg_256)
logo_32_path = PROJECT_ROOT / "docs/src/assets/logo.svg"
rasterize_svg(logo_32_path, PROJECT_ROOT / "docs/public/favicon.png", 32)
sizes = [16, 32, 48]
imgs = []
for size in sizes:
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
imgs.append(img)
imgs[0].save(
PROJECT_ROOT / "docs/public/favicon.ico",
format="ICO",
sizes=[(s, s) for s in sizes],
append_images=imgs[1:],
)
print(" Logos...")
LOGOS_DIR.mkdir(exist_ok=True)
sizes = [64, 128, 256, 512, 1024, 2048]
for size in sizes:
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(LOGOS_DIR / f"logo-{size}.png")
for size in sizes:
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(LOGOS_DIR / f"logo-{size}-white.png")
for size in sizes:
img = Image.new("RGBA", (size, size), (26, 26, 26, 255))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(LOGOS_DIR / f"logo-{size}-dark.png")
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v python3 &> /dev/null; then
echo "Error: Python 3 required"
exit 1
fi
python3 "$SCRIPT_DIR/generate.py"

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 443 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="108" height="108" viewBox="0 0 108 108" xmlns="http://www.w3.org/2000/svg">
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@@ -460,12 +460,13 @@
D24C19742E75002A0045894C /* Debug */ = { D24C19742E75002A0045894C /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = Balls;
ASSETCATALOG_COMPILER_APPICON_NAME = Icon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
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 = 39; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -474,20 +475,23 @@
INFOPLIST_KEY_CFBundleDisplayName = Ascently; INFOPLIST_KEY_CFBundleDisplayName = Ascently;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Ascently needs camera access to take photos of climbing problems."; INFOPLIST_KEY_NSAppleMusicUsageDescription = "This app (optionally) needs access to your music library to play your selected playlist during climbing sessions.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Ascently needs access to your photo library to save and display climbing problem images."; INFOPLIST_KEY_NSCameraUsageDescription = "This app needs access to your camera to take photos of climbing problems.";
INFOPLIST_KEY_NSHealthShareUsageDescription = "This app needs access to save your climbing workouts to Apple Health.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "This app needs access to save your climbing workouts to Apple Health.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to add photos to climbing problems.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 18.6;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.1;
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 = "";
@@ -498,7 +502,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 6.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
TVOS_DEPLOYMENT_TARGET = 18.6; TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6; WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6; XROS_DEPLOYMENT_TARGET = 2.6;
@@ -508,12 +512,13 @@
D24C19752E75002A0045894C /* Release */ = { D24C19752E75002A0045894C /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = Balls;
ASSETCATALOG_COMPILER_APPICON_NAME = Icon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
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 = 39; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -522,20 +527,23 @@
INFOPLIST_KEY_CFBundleDisplayName = Ascently; INFOPLIST_KEY_CFBundleDisplayName = Ascently;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Ascently needs camera access to take photos of climbing problems."; INFOPLIST_KEY_NSAppleMusicUsageDescription = "This app (optionally) needs access to your music library to play your selected playlist during climbing sessions.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Ascently needs access to your photo library to save and display climbing problem images."; INFOPLIST_KEY_NSCameraUsageDescription = "This app needs access to your camera to take photos of climbing problems.";
INFOPLIST_KEY_NSHealthShareUsageDescription = "This app needs access to save your climbing workouts to Apple Health.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "This app needs access to save your climbing workouts to Apple Health.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to add photos to climbing problems.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 18.6;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.1;
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 = "";
@@ -546,7 +554,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 6.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
TVOS_DEPLOYMENT_TARGET = 18.6; TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6; WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6; XROS_DEPLOYMENT_TARGET = 2.6;
@@ -562,7 +570,7 @@
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.AscentlyTests; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.AscentlyTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -583,7 +591,7 @@
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.AscentlyTests; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.AscentlyTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -602,18 +610,19 @@
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 = 39; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive; INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.1;
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,18 +641,19 @@
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 = 39; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive; INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.1;
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;

View File

@@ -41,7 +41,7 @@ final class SessionIntentController {
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary { func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Wait for data to load // Wait for data to load
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
} }
guard let lastGym = dataManager.getLastUsedGym() else { guard let lastGym = dataManager.getLastUsedGym() else {
@@ -49,7 +49,7 @@ final class SessionIntentController {
throw SessionIntentError.noRecentGym throw SessionIntentError.noRecentGym
} }
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else { guard let startedSession = await dataManager.startSession(gymId: lastGym.id) else {
logFailure(.failedToStartSession, context: "Data manager failed to create new session") logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession throw SessionIntentError.failedToStartSession
} }
@@ -68,7 +68,7 @@ final class SessionIntentController {
throw SessionIntentError.noActiveSession throw SessionIntentError.noActiveSession
} }
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else { guard let completedSession = await dataManager.endSession(activeSession.id) else {
logFailure( logFailure(
.failedToEndSession, context: "Data manager failed to complete active session") .failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession throw SessionIntentError.failedToEndSession
@@ -97,7 +97,7 @@ final class SessionIntentController {
func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) { func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) {
// Wait for data to load // Wait for data to load
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
} }
if dataManager.activeSession != nil { if dataManager.activeSession != nil {

View File

@@ -20,14 +20,14 @@ struct ToggleSessionIntent: AppIntent {
func perform() async throws -> some IntentResult & ProvidesDialog { func perform() async throws -> some IntentResult & ProvidesDialog {
// Wait for app initialization // Wait for app initialization
try? await Task.sleep(nanoseconds: 1_000_000_000) try? await Task.sleep(for: .seconds(1))
let controller = await SessionIntentController() let controller = await SessionIntentController()
let (summary, wasStarted) = try await controller.toggleSession() let (summary, wasStarted) = try await controller.toggleSession()
if wasStarted { if wasStarted {
// Wait for Live Activity // Wait for Live Activity
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!")) return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!"))
} else { } else {
return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!")) return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!"))

View File

@@ -1,38 +0,0 @@
{
"images": [
{
"filename": "app_icon_1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_icon_1024_dark.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"filename": "app_icon_1024_tinted.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Some files were not shown because too many files have changed in this diff Show More