[Android] 2.0.1 - Refactoring & Minor Optimizations
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.ascently"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 40
|
versionCode = 41
|
||||||
versionName = "2.0.0"
|
versionName = "2.0.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,12 +83,25 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enable or disable Health Connect integration */
|
/**
|
||||||
fun setEnabled(enabled: Boolean) {
|
* Enable or disable Health Connect integration and automatically request permissions if
|
||||||
|
* enabling
|
||||||
|
*/
|
||||||
|
suspend fun setEnabled(enabled: Boolean) {
|
||||||
preferences.edit().putBoolean("enabled", enabled).apply()
|
preferences.edit().putBoolean("enabled", enabled).apply()
|
||||||
_isEnabled.value = enabled
|
_isEnabled.value = enabled
|
||||||
|
|
||||||
if (!enabled) {
|
if (enabled && _isCompatible.value) {
|
||||||
|
// Automatically request permissions when enabling
|
||||||
|
try {
|
||||||
|
val alreadyHasPermissions = hasAllPermissions()
|
||||||
|
if (!alreadyHasPermissions) {
|
||||||
|
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error checking permissions when enabling Health Connect", e)
|
||||||
|
}
|
||||||
|
} else if (!enabled) {
|
||||||
setPermissionsGranted(false)
|
setPermissionsGranted(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,63 +160,6 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
return PermissionController.createRequestPermissionResultContract()
|
return PermissionController.createRequestPermissionResultContract()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test Health Connect functionality */
|
|
||||||
fun testHealthConnectSync(): String {
|
|
||||||
val results = mutableListOf<String>()
|
|
||||||
|
|
||||||
results.add("=== Health Connect Debug Test ===")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check availability synchronously
|
|
||||||
val packageManager = context.packageManager
|
|
||||||
val healthConnectPackages =
|
|
||||||
listOf(
|
|
||||||
"com.google.android.apps.healthdata",
|
|
||||||
"com.android.health.connect",
|
|
||||||
"androidx.health.connect"
|
|
||||||
)
|
|
||||||
|
|
||||||
val available =
|
|
||||||
healthConnectPackages.any { packageName ->
|
|
||||||
try {
|
|
||||||
packageManager.getPackageInfo(packageName, 0)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.add("Available: $available")
|
|
||||||
|
|
||||||
// Check enabled state
|
|
||||||
results.add("Enabled in settings: ${_isEnabled.value}")
|
|
||||||
|
|
||||||
// Check permissions (simplified)
|
|
||||||
val hasPerms = _hasPermissions.value
|
|
||||||
results.add("Has permissions: $hasPerms")
|
|
||||||
|
|
||||||
// Check compatibility
|
|
||||||
results.add("API Compatible: ${_isCompatible.value}")
|
|
||||||
|
|
||||||
val ready = _isEnabled.value && _isCompatible.value && available && hasPerms
|
|
||||||
results.add("Ready to sync: $ready")
|
|
||||||
|
|
||||||
if (ready) {
|
|
||||||
results.add("Health Connect is connected!")
|
|
||||||
} else {
|
|
||||||
results.add("❌ Health Connect not ready")
|
|
||||||
if (!available) results.add("- Health Connect not available on device")
|
|
||||||
if (!_isEnabled.value) results.add("- Not enabled in Ascently settings")
|
|
||||||
if (!hasPerms) results.add("- Permissions not granted")
|
|
||||||
if (!_isCompatible.value) results.add("- API compatibility issues")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
results.add("Test failed with error: ${e.message}")
|
|
||||||
Log.e(TAG, "Health Connect test failed", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get required permissions as strings */
|
/** Get required permissions as strings */
|
||||||
fun getRequiredPermissions(): Set<String> {
|
fun getRequiredPermissions(): Set<String> {
|
||||||
return try {
|
return try {
|
||||||
@@ -214,16 +170,18 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync a completed climbing session to Health Connect */
|
/** Sync a completed climbing session to Health Connect (only when auto-sync is enabled) */
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
suspend fun syncClimbingSession(
|
suspend fun syncCompletedSession(
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
gymName: String,
|
gymName: String,
|
||||||
attemptCount: Int = 0
|
attemptCount: Int = 0
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
if (!isReady()) {
|
if (!isReady() || !_autoSync.value) {
|
||||||
return Result.failure(IllegalStateException("Health Connect not ready"))
|
return Result.failure(
|
||||||
|
IllegalStateException("Health Connect not ready or auto-sync disabled")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status != SessionStatus.COMPLETED) {
|
if (session.status != SessionStatus.COMPLETED) {
|
||||||
@@ -320,18 +278,19 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-sync a session if enabled */
|
/** Auto-sync a completed session if enabled - this is the only way to sync sessions */
|
||||||
suspend fun autoSyncSession(
|
suspend fun autoSyncCompletedSession(
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
gymName: String,
|
gymName: String,
|
||||||
attemptCount: Int = 0
|
attemptCount: Int = 0
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return if (_autoSync.value && isReady()) {
|
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
|
||||||
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...")
|
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
|
||||||
syncClimbingSession(session, gymName, attemptCount)
|
syncCompletedSession(session, gymName, attemptCount)
|
||||||
} else {
|
} else {
|
||||||
val reason =
|
val reason =
|
||||||
when {
|
when {
|
||||||
|
session.status != SessionStatus.COMPLETED -> "session not completed"
|
||||||
!_autoSync.value -> "auto-sync disabled"
|
!_autoSync.value -> "auto-sync disabled"
|
||||||
!isReady() -> "Health Connect not ready"
|
!isReady() -> "Health Connect not ready"
|
||||||
else -> "unknown reason"
|
else -> "unknown reason"
|
||||||
|
|||||||
@@ -12,7 +12,19 @@ enum class AttemptResult {
|
|||||||
SUCCESS,
|
SUCCESS,
|
||||||
FALL,
|
FALL,
|
||||||
NO_PROGRESS,
|
NO_PROGRESS,
|
||||||
FLASH,
|
FLASH;
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
SUCCESS -> "Success"
|
||||||
|
FALL -> "Fall"
|
||||||
|
NO_PROGRESS -> "No Progress"
|
||||||
|
FLASH -> "Flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
val isSuccessful: Boolean
|
||||||
|
get() = this == SUCCESS || this == FLASH
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -74,5 +86,4 @@ data class Attempt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ import kotlinx.serialization.Serializable
|
|||||||
enum class SessionStatus {
|
enum class SessionStatus {
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
COMPLETED,
|
COMPLETED,
|
||||||
PAUSED
|
PAUSED;
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
ACTIVE -> "Active"
|
||||||
|
COMPLETED -> "Completed"
|
||||||
|
PAUSED -> "Paused"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import kotlinx.serialization.Serializable
|
|||||||
enum class ClimbType {
|
enum class ClimbType {
|
||||||
ROPE,
|
ROPE,
|
||||||
BOULDER;
|
BOULDER;
|
||||||
|
|
||||||
/**
|
val displayName: String
|
||||||
* Get the display name
|
get() =
|
||||||
*/
|
when (this) {
|
||||||
fun getDisplayName(): String = when (this) {
|
ROPE -> "Rope"
|
||||||
ROPE -> "Rope"
|
BOULDER -> "Bouldering"
|
||||||
BOULDER -> "Bouldering"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,130 +12,129 @@ enum class DifficultySystem {
|
|||||||
YDS,
|
YDS,
|
||||||
CUSTOM;
|
CUSTOM;
|
||||||
|
|
||||||
/** Get the display name for the UI */
|
val displayName: String
|
||||||
fun getDisplayName(): String =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
V_SCALE -> "V Scale"
|
V_SCALE -> "V Scale"
|
||||||
FONT -> "Font Scale"
|
FONT -> "Font Scale"
|
||||||
YDS -> "YDS (Yosemite)"
|
YDS -> "YDS (Yosemite)"
|
||||||
CUSTOM -> "Custom"
|
CUSTOM -> "Custom"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if this system is for bouldering */
|
val isBoulderingSystem: Boolean
|
||||||
fun isBoulderingSystem(): Boolean =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
V_SCALE, FONT -> true
|
V_SCALE, FONT -> true
|
||||||
YDS -> false
|
YDS -> false
|
||||||
CUSTOM -> true
|
CUSTOM -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if this system is for rope climbing */
|
val isRopeSystem: Boolean
|
||||||
fun isRopeSystem(): Boolean =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
YDS -> true
|
YDS -> true
|
||||||
V_SCALE, FONT -> false
|
V_SCALE, FONT -> false
|
||||||
CUSTOM -> true
|
CUSTOM -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get available grades for this system */
|
val availableGrades: List<String>
|
||||||
fun getAvailableGrades(): List<String> =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
V_SCALE ->
|
V_SCALE ->
|
||||||
listOf(
|
listOf(
|
||||||
"VB",
|
"VB",
|
||||||
"V0",
|
"V0",
|
||||||
"V1",
|
"V1",
|
||||||
"V2",
|
"V2",
|
||||||
"V3",
|
"V3",
|
||||||
"V4",
|
"V4",
|
||||||
"V5",
|
"V5",
|
||||||
"V6",
|
"V6",
|
||||||
"V7",
|
"V7",
|
||||||
"V8",
|
"V8",
|
||||||
"V9",
|
"V9",
|
||||||
"V10",
|
"V10",
|
||||||
"V11",
|
"V11",
|
||||||
"V12",
|
"V12",
|
||||||
"V13",
|
"V13",
|
||||||
"V14",
|
"V14",
|
||||||
"V15",
|
"V15",
|
||||||
"V16",
|
"V16",
|
||||||
"V17"
|
"V17"
|
||||||
)
|
)
|
||||||
FONT ->
|
FONT ->
|
||||||
listOf(
|
listOf(
|
||||||
"3",
|
"3",
|
||||||
"4A",
|
"4A",
|
||||||
"4B",
|
"4B",
|
||||||
"4C",
|
"4C",
|
||||||
"5A",
|
"5A",
|
||||||
"5B",
|
"5B",
|
||||||
"5C",
|
"5C",
|
||||||
"6A",
|
"6A",
|
||||||
"6A+",
|
"6A+",
|
||||||
"6B",
|
"6B",
|
||||||
"6B+",
|
"6B+",
|
||||||
"6C",
|
"6C",
|
||||||
"6C+",
|
"6C+",
|
||||||
"7A",
|
"7A",
|
||||||
"7A+",
|
"7A+",
|
||||||
"7B",
|
"7B",
|
||||||
"7B+",
|
"7B+",
|
||||||
"7C",
|
"7C",
|
||||||
"7C+",
|
"7C+",
|
||||||
"8A",
|
"8A",
|
||||||
"8A+",
|
"8A+",
|
||||||
"8B",
|
"8B",
|
||||||
"8B+",
|
"8B+",
|
||||||
"8C",
|
"8C",
|
||||||
"8C+"
|
"8C+"
|
||||||
)
|
)
|
||||||
YDS ->
|
YDS ->
|
||||||
listOf(
|
listOf(
|
||||||
"5.0",
|
"5.0",
|
||||||
"5.1",
|
"5.1",
|
||||||
"5.2",
|
"5.2",
|
||||||
"5.3",
|
"5.3",
|
||||||
"5.4",
|
"5.4",
|
||||||
"5.5",
|
"5.5",
|
||||||
"5.6",
|
"5.6",
|
||||||
"5.7",
|
"5.7",
|
||||||
"5.8",
|
"5.8",
|
||||||
"5.9",
|
"5.9",
|
||||||
"5.10a",
|
"5.10a",
|
||||||
"5.10b",
|
"5.10b",
|
||||||
"5.10c",
|
"5.10c",
|
||||||
"5.10d",
|
"5.10d",
|
||||||
"5.11a",
|
"5.11a",
|
||||||
"5.11b",
|
"5.11b",
|
||||||
"5.11c",
|
"5.11c",
|
||||||
"5.11d",
|
"5.11d",
|
||||||
"5.12a",
|
"5.12a",
|
||||||
"5.12b",
|
"5.12b",
|
||||||
"5.12c",
|
"5.12c",
|
||||||
"5.12d",
|
"5.12d",
|
||||||
"5.13a",
|
"5.13a",
|
||||||
"5.13b",
|
"5.13b",
|
||||||
"5.13c",
|
"5.13c",
|
||||||
"5.13d",
|
"5.13d",
|
||||||
"5.14a",
|
"5.14a",
|
||||||
"5.14b",
|
"5.14b",
|
||||||
"5.14c",
|
"5.14c",
|
||||||
"5.14d",
|
"5.14d",
|
||||||
"5.15a",
|
"5.15a",
|
||||||
"5.15b",
|
"5.15b",
|
||||||
"5.15c",
|
"5.15c",
|
||||||
"5.15d"
|
"5.15d"
|
||||||
)
|
)
|
||||||
CUSTOM -> emptyList()
|
CUSTOM -> emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Get all difficulty systems based on type */
|
fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
|
||||||
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
|
|
||||||
when (climbType) {
|
when (climbType) {
|
||||||
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
|
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
|
||||||
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
|
ClimbType.ROPE -> entries.filter { it.isRopeSystem }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,38 +153,78 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
|||||||
DifficultySystem.V_SCALE -> {
|
DifficultySystem.V_SCALE -> {
|
||||||
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
|
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
|
||||||
}
|
}
|
||||||
DifficultySystem.YDS -> {
|
|
||||||
when {
|
|
||||||
grade.startsWith("5.10") ->
|
|
||||||
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.11") ->
|
|
||||||
14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.12") ->
|
|
||||||
18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.13") ->
|
|
||||||
22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.14") ->
|
|
||||||
26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.15") ->
|
|
||||||
30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
else -> grade.removePrefix("5.").toIntOrNull() ?: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DifficultySystem.FONT -> {
|
DifficultySystem.FONT -> {
|
||||||
when {
|
val fontMapping: Map<String, Int> =
|
||||||
grade.startsWith("6A") -> 6
|
mapOf(
|
||||||
grade.startsWith("6B") -> 7
|
"3" to 3,
|
||||||
grade.startsWith("6C") -> 8
|
"4A" to 4,
|
||||||
grade.startsWith("7A") -> 9
|
"4B" to 5,
|
||||||
grade.startsWith("7B") -> 10
|
"4C" to 6,
|
||||||
grade.startsWith("7C") -> 11
|
"5A" to 7,
|
||||||
grade.startsWith("8A") -> 12
|
"5B" to 8,
|
||||||
grade.startsWith("8B") -> 13
|
"5C" to 9,
|
||||||
grade.startsWith("8C") -> 14
|
"6A" to 10,
|
||||||
else -> grade.toIntOrNull() ?: 0
|
"6A+" to 11,
|
||||||
}
|
"6B" to 12,
|
||||||
|
"6B+" to 13,
|
||||||
|
"6C" to 14,
|
||||||
|
"6C+" to 15,
|
||||||
|
"7A" to 16,
|
||||||
|
"7A+" to 17,
|
||||||
|
"7B" to 18,
|
||||||
|
"7B+" to 19,
|
||||||
|
"7C" to 20,
|
||||||
|
"7C+" to 21,
|
||||||
|
"8A" to 22,
|
||||||
|
"8A+" to 23,
|
||||||
|
"8B" to 24,
|
||||||
|
"8B+" to 25,
|
||||||
|
"8C" to 26,
|
||||||
|
"8C+" to 27
|
||||||
|
)
|
||||||
|
fontMapping[grade] ?: 0
|
||||||
}
|
}
|
||||||
DifficultySystem.CUSTOM -> grade.hashCode().rem(100)
|
DifficultySystem.YDS -> {
|
||||||
|
val ydsMapping: Map<String, Int> =
|
||||||
|
mapOf(
|
||||||
|
"5.0" to 50,
|
||||||
|
"5.1" to 51,
|
||||||
|
"5.2" to 52,
|
||||||
|
"5.3" to 53,
|
||||||
|
"5.4" to 54,
|
||||||
|
"5.5" to 55,
|
||||||
|
"5.6" to 56,
|
||||||
|
"5.7" to 57,
|
||||||
|
"5.8" to 58,
|
||||||
|
"5.9" to 59,
|
||||||
|
"5.10a" to 60,
|
||||||
|
"5.10b" to 61,
|
||||||
|
"5.10c" to 62,
|
||||||
|
"5.10d" to 63,
|
||||||
|
"5.11a" to 64,
|
||||||
|
"5.11b" to 65,
|
||||||
|
"5.11c" to 66,
|
||||||
|
"5.11d" to 67,
|
||||||
|
"5.12a" to 68,
|
||||||
|
"5.12b" to 69,
|
||||||
|
"5.12c" to 70,
|
||||||
|
"5.12d" to 71,
|
||||||
|
"5.13a" to 72,
|
||||||
|
"5.13b" to 73,
|
||||||
|
"5.13c" to 74,
|
||||||
|
"5.13d" to 75,
|
||||||
|
"5.14a" to 76,
|
||||||
|
"5.14b" to 77,
|
||||||
|
"5.14c" to 78,
|
||||||
|
"5.14d" to 79,
|
||||||
|
"5.15a" to 80,
|
||||||
|
"5.15b" to 81,
|
||||||
|
"5.15c" to 82,
|
||||||
|
"5.15d" to 83
|
||||||
|
)
|
||||||
|
ydsMapping[grade] ?: 0
|
||||||
|
}
|
||||||
|
DifficultySystem.CUSTOM -> grade.toIntOrNull() ?: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,17 +130,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy accessor expected by some UI code (kept for compatibility)
|
|
||||||
@Deprecated(
|
|
||||||
message = "Use serverUrl (kebab case) instead",
|
|
||||||
replaceWith = ReplaceWith("serverUrl")
|
|
||||||
)
|
|
||||||
var serverURL: String
|
|
||||||
get() = serverUrl
|
|
||||||
set(value) {
|
|
||||||
serverUrl = value
|
|
||||||
}
|
|
||||||
|
|
||||||
var authToken: String
|
var authToken: String
|
||||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
package com.atridad.ascently.ui.components
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -20,7 +21,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@@ -29,25 +29,29 @@ import kotlinx.coroutines.launch
|
|||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||||
val context = LocalContext.current
|
|
||||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||||
val thumbnailListState = rememberLazyListState()
|
val thumbnailListState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Handle back button press
|
||||||
|
BackHandler { onDismiss() }
|
||||||
|
|
||||||
// Auto-scroll thumbnail list to center current image
|
// Auto-scroll thumbnail list to center current image
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
|
if (imagePaths.size > 1) {
|
||||||
|
thumbnailListState.animateScrollToItem(
|
||||||
|
index = pagerState.currentPage,
|
||||||
|
scrollOffset = -200
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties =
|
properties =
|
||||||
DialogProperties(
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
decorFitsSystemWindows = false
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
|
||||||
// Main image pager
|
// Main image pager
|
||||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
OrientationAwareImage(
|
OrientationAwareImage(
|
||||||
@@ -58,76 +62,96 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close button
|
// Top bar with back button and counter
|
||||||
IconButton(
|
Surface(
|
||||||
onClick = onDismiss,
|
modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
|
||||||
modifier =
|
color = Color.Black.copy(alpha = 0.6f)
|
||||||
Modifier.align(Alignment.TopEnd)
|
) {
|
||||||
.padding(16.dp)
|
Row(
|
||||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
modifier =
|
||||||
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
|
Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
// Image counter
|
|
||||||
if (imagePaths.size > 1) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
// Back button
|
||||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
IconButton(onClick = onDismiss) {
|
||||||
color = Color.White,
|
Icon(
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
)
|
contentDescription = "Close",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Image counter
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumbnail strip (if multiple images)
|
// Thumbnail strip at bottom (if multiple images)
|
||||||
if (imagePaths.size > 1) {
|
if (imagePaths.size > 1) {
|
||||||
Card(
|
Surface(
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
Modifier.align(Alignment.BottomCenter)
|
color = Color.Black.copy(alpha = 0.6f)
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = thumbnailListState,
|
state = thumbnailListState,
|
||||||
modifier = Modifier.padding(8.dp),
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(imagePaths) { index, imagePath ->
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
val isSelected = index == pagerState.currentPage
|
val isSelected = index == pagerState.currentPage
|
||||||
|
|
||||||
OrientationAwareImage(
|
Box(
|
||||||
imagePath = imagePath,
|
|
||||||
contentDescription = "Thumbnail ${index + 1}",
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(60.dp)
|
Modifier.size(48.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable {
|
.clickable {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
pagerState.animateScrollToPage(index)
|
pagerState.animateScrollToPage(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.then(
|
) {
|
||||||
if (isSelected) {
|
OrientationAwareImage(
|
||||||
Modifier.background(
|
imagePath = imagePath,
|
||||||
Color.White.copy(
|
contentDescription = "Thumbnail ${index + 1}",
|
||||||
alpha = 0.3f
|
modifier = Modifier.fillMaxSize(),
|
||||||
),
|
contentScale = ContentScale.Crop
|
||||||
RoundedCornerShape(8.dp)
|
)
|
||||||
)
|
|
||||||
} else Modifier
|
// Selection indicator
|
||||||
),
|
if (isSelected) {
|
||||||
contentScale = ContentScale.Crop
|
Box(
|
||||||
)
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Color.White.copy(alpha = 0.3f),
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Color.Transparent,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
Color.White.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import android.graphics.Matrix
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.atridad.ascently.utils.ImageUtils
|
import com.atridad.ascently.utils.ImageUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -52,7 +55,7 @@ fun OrientationAwareImage(
|
|||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
CircularProgressIndicator(modifier = Modifier.fillMaxSize())
|
CircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.Center))
|
||||||
} else {
|
} else {
|
||||||
imageBitmap?.let { bitmap ->
|
imageBitmap?.let { bitmap ->
|
||||||
Image(
|
Image(
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
// Collect flows
|
// Collect flows
|
||||||
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
|
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
|
||||||
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
|
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
|
||||||
val autoSyncEnabled by healthConnectManager.autoSyncEnabled.collectAsState(initial = true)
|
|
||||||
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
|
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
|
||||||
|
|
||||||
// Permission launcher
|
// Permission launcher
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = healthConnectManager.getPermissionRequestContract()
|
contract = healthConnectManager.getPermissionRequestContract()
|
||||||
) { grantedPermissions ->
|
) { _ ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val allGranted = healthConnectManager.hasAllPermissions()
|
val allGranted = healthConnectManager.hasAllPermissions()
|
||||||
if (!allGranted) {
|
if (!allGranted) {
|
||||||
@@ -86,313 +86,207 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
) {
|
) {
|
||||||
// Header with icon and title
|
// Header with icon and title
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.HealthAndSafety,
|
imageVector = Icons.Default.HealthAndSafety,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint =
|
tint =
|
||||||
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
|
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Health Connect",
|
text = "Health Connect",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
when {
|
when {
|
||||||
isLoading -> "Checking availability..."
|
isLoading -> "Checking availability..."
|
||||||
!isCompatible -> "API Issue"
|
!isCompatible -> "API Issue"
|
||||||
!isHealthConnectAvailable -> "Not available"
|
!isHealthConnectAvailable -> "Not available"
|
||||||
isEnabled && hasPermissions -> "Connected"
|
isEnabled && hasPermissions -> "Connected"
|
||||||
isEnabled && !hasPermissions -> "Needs permissions"
|
isEnabled && !hasPermissions -> "Needs permissions"
|
||||||
else -> "Disabled"
|
else -> "Disabled"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color =
|
color =
|
||||||
when {
|
when {
|
||||||
isLoading ->
|
isLoading ->
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
alpha = 0.7f
|
alpha = 0.7f
|
||||||
)
|
)
|
||||||
!isCompatible -> MaterialTheme.colorScheme.error
|
|
||||||
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
|
!isCompatible -> MaterialTheme.colorScheme.error
|
||||||
isEnabled && hasPermissions ->
|
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
|
||||||
MaterialTheme.colorScheme.primary
|
isEnabled && hasPermissions ->
|
||||||
isEnabled && !hasPermissions ->
|
MaterialTheme.colorScheme.primary
|
||||||
MaterialTheme.colorScheme.tertiary
|
|
||||||
else ->
|
isEnabled && !hasPermissions ->
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
MaterialTheme.colorScheme.tertiary
|
||||||
alpha = 0.7f
|
|
||||||
)
|
else ->
|
||||||
}
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.7f
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main toggle switch
|
// Main toggle switch
|
||||||
Switch(
|
Switch(
|
||||||
checked = isEnabled,
|
checked = isEnabled,
|
||||||
onCheckedChange = { enabled ->
|
onCheckedChange = { enabled ->
|
||||||
|
coroutineScope.launch {
|
||||||
if (enabled && isHealthConnectAvailable) {
|
if (enabled && isHealthConnectAvailable) {
|
||||||
healthConnectManager.setEnabled(true)
|
healthConnectManager.setEnabled(true)
|
||||||
coroutineScope.launch {
|
try {
|
||||||
try {
|
val permissionSet =
|
||||||
val permissionSet =
|
healthConnectManager.getRequiredPermissions()
|
||||||
healthConnectManager.getRequiredPermissions()
|
if (permissionSet.isNotEmpty()) {
|
||||||
if (permissionSet.isNotEmpty()) {
|
permissionLauncher.launch(permissionSet)
|
||||||
permissionLauncher.launch(permissionSet)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage = "Error requesting permissions: ${e.message}"
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorMessage = "Error requesting permissions: ${e.message}"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
healthConnectManager.setEnabled(false)
|
healthConnectManager.setEnabled(false)
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
enabled = isHealthConnectAvailable && !isLoading && isCompatible
|
},
|
||||||
|
enabled = isHealthConnectAvailable && !isLoading && isCompatible
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Card(
|
|
||||||
|
Text(
|
||||||
|
text = "Climbing sessions will be automatically added to Health Connect when completed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasPermissions) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Card(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
if (hasPermissions) {
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(
|
alpha = 0.3f
|
||||||
alpha = 0.3f
|
)
|
||||||
)
|
)
|
||||||
} else {
|
) {
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
alpha = 0.3f
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
)
|
Icon(
|
||||||
}
|
imageVector = Icons.Default.Warning,
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (hasPermissions) Icons.Default.CheckCircle
|
|
||||||
else Icons.Default.Warning,
|
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint =
|
tint = MaterialTheme.colorScheme.error
|
||||||
if (hasPermissions) {
|
)
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text = "Permissions needed",
|
||||||
if (hasPermissions) "Ready to sync"
|
|
||||||
else "Permissions needed",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermissions) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"Grant Health Connect permissions to sync your climbing sessions",
|
"Grant Health Connect permissions to sync your climbing sessions",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color =
|
color =
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
alpha = 0.8f
|
alpha = 0.8f
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
val permissionSet =
|
val permissionSet =
|
||||||
healthConnectManager
|
healthConnectManager
|
||||||
.getRequiredPermissions()
|
.getRequiredPermissions()
|
||||||
if (permissionSet.isNotEmpty()) {
|
if (permissionSet.isNotEmpty()) {
|
||||||
permissionLauncher.launch(permissionSet)
|
permissionLauncher.launch(permissionSet)
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errorMessage =
|
|
||||||
"Error requesting permissions: ${e.message}"
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorMessage =
|
||||||
|
"Error requesting permissions: ${e.message}"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
modifier = Modifier.fillMaxWidth()
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) { Text("Grant Permissions") }
|
) { Text("Grant Permissions") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
if (hasPermissions) {
|
Text(
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Auto-sync sessions",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Automatically sync completed climbing sessions",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color =
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
|
||||||
alpha = 0.7f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Switch(
|
|
||||||
checked = autoSyncEnabled,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
healthConnectManager.setAutoSyncEnabled(enabled)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
text =
|
||||||
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
|
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
errorMessage?.let { error ->
|
errorMessage?.let { error ->
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
alpha = 0.5f
|
alpha = 0.5f
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Warning,
|
imageVector = Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isEnabled) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
var testResult by remember { mutableStateOf<String?>(null) }
|
|
||||||
var isTestRunning by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
isTestRunning = true
|
|
||||||
coroutineScope.launch {
|
|
||||||
try {
|
|
||||||
testResult = healthConnectManager.testHealthConnectSync()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
testResult = "Test failed: ${e.message}"
|
|
||||||
} finally {
|
|
||||||
isTestRunning = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isTestRunning,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
if (isTestRunning) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Text(if (isTestRunning) "Testing..." else "Test Connection")
|
|
||||||
}
|
|
||||||
testResult?.let { result ->
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
|
||||||
alpha = 0.5f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Debug Results:",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = result,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,40 +295,3 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
if (isConnected) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(
|
|
||||||
alpha = 0.5f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CloudDone,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Health Connect active - sessions will sync automatically",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
selectedClimbTypes
|
selectedClimbTypes
|
||||||
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
|
.flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
|
||||||
.distinct()
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onCheckedChange = null
|
onCheckedChange = null
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(climbType.getDisplayName())
|
Text(climbType.displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onCheckedChange = null
|
onCheckedChange = null
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(system.getDisplayName())
|
Text(system.displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,6 @@ fun AddEditProblemScreen(
|
|||||||
) {
|
) {
|
||||||
val isEditing = problemId != null
|
val isEditing = problemId != null
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// Problem form state
|
// Problem form state
|
||||||
var selectedGym by remember {
|
var selectedGym by remember {
|
||||||
@@ -295,7 +294,7 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
||||||
val availableDifficultySystems =
|
val availableDifficultySystems =
|
||||||
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
|
||||||
selectedGym?.difficultySystems?.contains(system) != false
|
selectedGym?.difficultySystems?.contains(system) != false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +323,7 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
|
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
|
||||||
LaunchedEffect(selectedDifficultySystem) {
|
LaunchedEffect(selectedDifficultySystem) {
|
||||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
|
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
|
||||||
difficultyGrade = ""
|
difficultyGrade = ""
|
||||||
}
|
}
|
||||||
@@ -386,13 +385,12 @@ fun AddEditProblemScreen(
|
|||||||
notes = notes.ifBlank { null }
|
notes = notes.ifBlank { null }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing && problemId != null) {
|
||||||
viewModel.updateProblem(
|
viewModel.updateProblem(
|
||||||
problem.copy(id = problemId),
|
problem.copy(id = problemId)
|
||||||
context
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
viewModel.addProblem(problem, context)
|
viewModel.addProblem(problem)
|
||||||
}
|
}
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
@@ -505,7 +503,7 @@ fun AddEditProblemScreen(
|
|||||||
availableClimbTypes.forEach { climbType ->
|
availableClimbTypes.forEach { climbType ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.getDisplayName()) },
|
label = { Text(climbType.displayName) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -538,7 +536,7 @@ fun AddEditProblemScreen(
|
|||||||
items(availableDifficultySystems) { system ->
|
items(availableDifficultySystems) { system ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedDifficultySystem = system },
|
onClick = { selectedDifficultySystem = system },
|
||||||
label = { Text(system.getDisplayName()) },
|
label = { Text(system.displayName) },
|
||||||
selected = selectedDifficultySystem == system
|
selected = selectedDifficultySystem == system
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -570,7 +568,7 @@ fun AddEditProblemScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import com.atridad.ascently.ui.components.BarChart
|
|||||||
import com.atridad.ascently.ui.components.BarChartDataPoint
|
import com.atridad.ascently.ui.components.BarChartDataPoint
|
||||||
import com.atridad.ascently.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
||||||
@@ -253,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
systemFiltered.filter { dataPoint ->
|
systemFiltered.filter { dataPoint ->
|
||||||
try {
|
try {
|
||||||
val attemptDate =
|
val attemptDate =
|
||||||
LocalDateTime.parse(
|
DateFormatUtils.parseToLocalDateTime(dataPoint.date)
|
||||||
dataPoint.date,
|
attemptDate?.isAfter(sevenDaysAgo) == true
|
||||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
|
||||||
)
|
|
||||||
attemptDate.isAfter(sevenDaysAgo)
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// If date parsing fails, include the data point
|
// If date parsing fails, include the data point
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.HealthAndSafety
|
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -30,16 +28,13 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.atridad.ascently.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
||||||
import com.atridad.ascently.ui.components.ImageDisplaySection
|
import com.atridad.ascently.ui.components.ImageDisplaySection
|
||||||
import com.atridad.ascently.ui.theme.CustomIcons
|
import com.atridad.ascently.ui.theme.CustomIcons
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -221,7 +216,6 @@ fun SessionDetailScreen(
|
|||||||
val problems by viewModel.problems.collectAsState()
|
val problems by viewModel.problems.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
var isGeneratingShare by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
||||||
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
|
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
|
||||||
@@ -234,7 +228,7 @@ fun SessionDetailScreen(
|
|||||||
val successfulAttempts =
|
val successfulAttempts =
|
||||||
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 = attempts.map { it.problemId }.distinct()
|
||||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
|
||||||
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
|
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||||
|
|
||||||
val attemptsWithProblems =
|
val attemptsWithProblems =
|
||||||
@@ -261,64 +255,8 @@ fun SessionDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (session?.duration != null) {
|
// No manual actions needed - Health Connect syncs automatically when
|
||||||
val healthConnectManager = viewModel.getHealthConnectManager()
|
// sessions complete
|
||||||
val isHealthConnectEnabled by
|
|
||||||
healthConnectManager.isEnabled.collectAsState(
|
|
||||||
initial = false
|
|
||||||
)
|
|
||||||
val hasPermissions by
|
|
||||||
healthConnectManager.hasPermissions.collectAsState(
|
|
||||||
initial = false
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isHealthConnectEnabled && hasPermissions) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.manualSyncToHealthConnect(sessionId)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.HealthAndSafety,
|
|
||||||
contentDescription = "Sync to Health Connect",
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share button
|
|
||||||
if (session?.duration != null) { // Only show for completed sessions
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
isGeneratingShare = true
|
|
||||||
viewModel.viewModelScope.launch {
|
|
||||||
val shareFile =
|
|
||||||
viewModel.generateSessionShareCard(
|
|
||||||
context,
|
|
||||||
sessionId
|
|
||||||
)
|
|
||||||
isGeneratingShare = false
|
|
||||||
shareFile?.let { file ->
|
|
||||||
viewModel.shareSessionCard(context, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isGeneratingShare
|
|
||||||
) {
|
|
||||||
if (isGeneratingShare) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Share,
|
|
||||||
contentDescription = "Share Session"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show stop icon for active sessions, delete icon for completed
|
// Show stop icon for active sessions, delete icon for completed
|
||||||
// sessions
|
// sessions
|
||||||
@@ -564,7 +502,7 @@ fun SessionDetailScreen(
|
|||||||
viewModel.addAttempt(attempt)
|
viewModel.addAttempt(attempt)
|
||||||
showAddAttemptDialog = false
|
showAddAttemptDialog = false
|
||||||
},
|
},
|
||||||
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
|
onProblemCreated = { problem -> viewModel.addProblem(problem) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +528,7 @@ fun ProblemDetailScreen(
|
|||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onNavigateToEdit: (String) -> Unit
|
onNavigateToEdit: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||||
@@ -665,7 +603,7 @@ fun ProblemDetailScreen(
|
|||||||
problem?.let { p ->
|
problem?.let { p ->
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
|
"${p.difficulty.system.displayName}: ${p.difficulty.grade}",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
@@ -674,7 +612,7 @@ fun ProblemDetailScreen(
|
|||||||
|
|
||||||
problem?.let { p ->
|
problem?.let { p ->
|
||||||
Text(
|
Text(
|
||||||
text = p.climbType.getDisplayName(),
|
text = p.climbType.displayName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -854,7 +792,7 @@ fun ProblemDetailScreen(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
problem?.let { p ->
|
problem?.let { p ->
|
||||||
viewModel.deleteProblem(p, context)
|
viewModel.deleteProblem(p)
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
showDeleteDialog = false
|
showDeleteDialog = false
|
||||||
@@ -1236,19 +1174,10 @@ fun GymDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
val dateTime =
|
|
||||||
try {
|
|
||||||
LocalDateTime.parse(session.date)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val formattedDate =
|
val formattedDate =
|
||||||
dateTime?.format(
|
DateFormatUtils.formatDateForDisplay(
|
||||||
DateTimeFormatter.ofPattern(
|
session.date
|
||||||
"MMM dd, yyyy"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
?: session.date
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"$formattedDate • ${sessionAttempts.size} attempts"
|
"$formattedDate • ${sessionAttempts.size} attempts"
|
||||||
@@ -1463,7 +1392,7 @@ fun SessionAttemptCard(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -1538,14 +1467,7 @@ fun SessionAttemptCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDate(dateString: String): String {
|
private fun formatDate(dateString: String): String {
|
||||||
return try {
|
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
|
||||||
val date = LocalDateTime.parse(dateString, formatter)
|
|
||||||
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
|
|
||||||
date.format(displayFormatter)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
dateString.take(10) // Fallback to just the date part
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -1584,7 +1506,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
// Auto-select difficulty system if there's only one available for the selected climb type
|
// Auto-select difficulty system if there's only one available for the selected climb type
|
||||||
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
|
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
|
||||||
val availableSystems =
|
val availableSystems =
|
||||||
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
|
||||||
gym.difficultySystems.contains(system)
|
gym.difficultySystems.contains(system)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1604,7 +1526,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
|
|
||||||
// Reset grade when difficulty system changes
|
// Reset grade when difficulty system changes
|
||||||
LaunchedEffect(selectedDifficultySystem) {
|
LaunchedEffect(selectedDifficultySystem) {
|
||||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
|
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
|
||||||
newProblemGrade = ""
|
newProblemGrade = ""
|
||||||
}
|
}
|
||||||
@@ -1721,7 +1643,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography
|
MaterialTheme.typography
|
||||||
.bodyMedium,
|
.bodyMedium,
|
||||||
@@ -1730,7 +1652,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
MaterialTheme
|
MaterialTheme
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface.copy(
|
.onSurface.copy(
|
||||||
alpha = 0.8f
|
alpha = 0.9f
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
MaterialTheme
|
MaterialTheme
|
||||||
@@ -1807,7 +1729,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
climbType.getDisplayName(),
|
climbType.displayName,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1838,7 +1760,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
)
|
)
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
val availableSystems =
|
val availableSystems =
|
||||||
DifficultySystem.getSystemsForClimbType(
|
DifficultySystem.systemsForClimbType(
|
||||||
selectedClimbType
|
selectedClimbType
|
||||||
)
|
)
|
||||||
.filter { system ->
|
.filter { system ->
|
||||||
@@ -1849,7 +1771,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
onClick = { selectedDifficultySystem = system },
|
onClick = { selectedDifficultySystem = system },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
system.getDisplayName(),
|
system.displayName,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1926,8 +1848,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val availableGrades =
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
selectedDifficultySystem.getAvailableGrades()
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
gym.supportedClimbTypes.forEach { climbType ->
|
gym.supportedClimbTypes.forEach { climbType ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = { Text(climbType.getDisplayName()) },
|
label = { Text(climbType.displayName) },
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
items(ClimbType.entries) { climbType ->
|
items(ClimbType.entries) { climbType ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.getDisplayName()) },
|
label = { Text(climbType.displayName) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
onClick = { onNavigateToProblemDetail(problem.id) },
|
onClick = { onNavigateToProblemDetail(problem.id) },
|
||||||
onToggleActive = {
|
onToggleActive = {
|
||||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||||
viewModel.updateProblem(updatedProblem, context)
|
viewModel.updateProblem(updatedProblem)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -268,7 +268,7 @@ fun ProblemCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = problem.climbType.getDisplayName(),
|
text = problem.climbType.displayName,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import com.atridad.ascently.data.model.SessionStatus
|
|||||||
import com.atridad.ascently.ui.components.ActiveSessionBanner
|
import com.atridad.ascently.ui.components.ActiveSessionBanner
|
||||||
import com.atridad.ascently.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -247,10 +246,5 @@ fun EmptyStateMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDate(dateString: String): String {
|
private fun formatDate(dateString: String): String {
|
||||||
return try {
|
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||||
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
|
||||||
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
dateString
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
var isDeletingImages by remember { mutableStateOf(false) }
|
var isDeletingImages by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Sync configuration state
|
// Sync configuration state
|
||||||
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
|
||||||
var authToken by remember { mutableStateOf(syncService.authToken) }
|
var authToken by remember { mutableStateOf(syncService.authToken) }
|
||||||
|
|
||||||
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
|
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
|
||||||
val appVersion = packageInfo.versionName
|
val appVersion = packageInfo.versionName
|
||||||
|
|
||||||
// Update local state when sync service configuration changes
|
// Update local state when sync service configuration changes
|
||||||
LaunchedEffect(syncService.serverURL, syncService.authToken) {
|
LaunchedEffect(syncService.serverUrl, syncService.authToken) {
|
||||||
serverUrl = syncService.serverURL
|
serverUrl = syncService.serverUrl
|
||||||
authToken = syncService.authToken
|
authToken = syncService.authToken
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Column {
|
Column {
|
||||||
Text("Server: ${syncService.serverURL}")
|
Text("Server: ${syncService.serverUrl}")
|
||||||
lastSyncTime?.let { time ->
|
lastSyncTime?.let { time ->
|
||||||
Text(
|
Text(
|
||||||
"Last sync: ${
|
"Last sync: ${
|
||||||
@@ -863,7 +863,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
syncService.serverURL = serverUrl.trim()
|
syncService.serverUrl = serverUrl.trim()
|
||||||
syncService.authToken = authToken.trim()
|
syncService.authToken = authToken.trim()
|
||||||
viewModel.testSyncConnection()
|
viewModel.testSyncConnection()
|
||||||
while (syncService.isTesting.value) {
|
while (syncService.isTesting.value) {
|
||||||
@@ -905,7 +905,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
syncService.serverURL = serverUrl.trim()
|
syncService.serverUrl = serverUrl.trim()
|
||||||
syncService.authToken = authToken.trim()
|
syncService.authToken = authToken.trim()
|
||||||
viewModel.testSyncConnection()
|
viewModel.testSyncConnection()
|
||||||
while (syncService.isTesting.value) {
|
while (syncService.isTesting.value) {
|
||||||
@@ -932,7 +932,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
serverUrl = syncService.serverURL
|
serverUrl = syncService.serverUrl
|
||||||
authToken = syncService.authToken
|
authToken = syncService.authToken
|
||||||
showSyncConfigDialog = false
|
showSyncConfigDialog = false
|
||||||
}
|
}
|
||||||
@@ -981,7 +981,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
isDeletingImages = true
|
isDeletingImages = true
|
||||||
showDeleteImagesDialog = false
|
showDeleteImagesDialog = false
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
viewModel.deleteAllImages(context)
|
viewModel.deleteAllImages()
|
||||||
isDeletingImages = false
|
isDeletingImages = false
|
||||||
viewModel.setMessage("All images deleted successfully!")
|
viewModel.setMessage("All images deleted successfully!")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ import com.atridad.ascently.data.model.*
|
|||||||
import com.atridad.ascently.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import com.atridad.ascently.data.sync.SyncService
|
import com.atridad.ascently.data.sync.SyncService
|
||||||
import com.atridad.ascently.service.SessionTrackingService
|
import com.atridad.ascently.service.SessionTrackingService
|
||||||
import com.atridad.ascently.utils.ImageNamingUtils
|
|
||||||
import com.atridad.ascently.utils.ImageUtils
|
import com.atridad.ascently.utils.ImageUtils
|
||||||
import com.atridad.ascently.utils.SessionShareUtils
|
|
||||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class ClimbViewModel(
|
class ClimbViewModel(
|
||||||
private val repository: ClimbRepository,
|
private val repository: ClimbRepository,
|
||||||
@@ -78,64 +74,57 @@ class ClimbViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Gym operations
|
// Gym operations
|
||||||
fun addGym(gym: Gym) {
|
fun addGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertGym(gym) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addGym(gym: Gym, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertGym(gym)
|
repository.insertGym(gym)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateGym(gym: Gym) {
|
fun updateGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.updateGym(gym) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateGym(gym: Gym, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateGym(gym)
|
repository.updateGym(gym)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteGym(gym: Gym) {
|
fun deleteGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.deleteGym(gym) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteGym(gym: Gym, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteGym(gym)
|
repository.deleteGym(gym)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||||
|
|
||||||
// Problem operations
|
// Problem operations
|
||||||
fun addProblem(problem: Problem, context: Context) {
|
fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val finalProblem = renameTemporaryImages(problem, context)
|
val finalProblem = renameTemporaryImages(problem)
|
||||||
repository.insertProblem(finalProblem)
|
repository.insertProblem(finalProblem)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
// Auto-sync now happens automatically via repository callback
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
|
private suspend fun renameTemporaryImages(problem: Problem): Problem {
|
||||||
if (problem.imagePaths.isEmpty()) {
|
if (problem.imagePaths.isEmpty()) {
|
||||||
return problem
|
return problem
|
||||||
}
|
}
|
||||||
|
|
||||||
val appContext = context ?: return problem
|
|
||||||
val finalImagePaths = mutableListOf<String>()
|
val finalImagePaths = mutableListOf<String>()
|
||||||
|
|
||||||
problem.imagePaths.forEachIndexed { index, tempPath ->
|
problem.imagePaths.forEachIndexed { index, tempPath ->
|
||||||
if (tempPath.startsWith("temp_")) {
|
if (tempPath.startsWith("temp_")) {
|
||||||
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
|
|
||||||
val finalPath =
|
val finalPath =
|
||||||
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
|
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
|
||||||
finalImagePaths.add(finalPath ?: tempPath)
|
finalImagePaths.add(finalPath ?: tempPath)
|
||||||
} else {
|
} else {
|
||||||
finalImagePaths.add(tempPath)
|
finalImagePaths.add(tempPath)
|
||||||
@@ -145,34 +134,34 @@ class ClimbViewModel(
|
|||||||
return problem.copy(imagePaths = finalImagePaths)
|
return problem.copy(imagePaths = finalImagePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProblem(problem: Problem, context: Context) {
|
fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val finalProblem = renameTemporaryImages(problem, context)
|
val finalProblem = renameTemporaryImages(problem)
|
||||||
repository.updateProblem(finalProblem)
|
repository.updateProblem(finalProblem)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProblem(problem: Problem, context: Context) {
|
fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Delete associated images
|
|
||||||
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
||||||
|
|
||||||
repository.deleteProblem(problem)
|
repository.deleteProblem(problem)
|
||||||
|
cleanupOrphanedImages()
|
||||||
cleanupOrphanedImages(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun cleanupOrphanedImages(context: Context) {
|
private suspend fun cleanupOrphanedImages() {
|
||||||
val allProblems = repository.getAllProblems().first()
|
val allProblems = repository.getAllProblems().first()
|
||||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAllImages(context: Context) {
|
fun deleteAllImages() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val imagesDir = ImageUtils.getImagesDirectory(context)
|
val imagesDir = ImageUtils.getImagesDirectory(context)
|
||||||
var deletedCount = 0
|
var deletedCount = 0
|
||||||
@@ -212,36 +201,30 @@ class ClimbViewModel(
|
|||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
fun addSession(session: ClimbSession) {
|
fun addSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addSession(session: ClimbSession, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertSession(session)
|
repository.insertSession(session)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSession(session: ClimbSession) {
|
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.updateSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSession(session: ClimbSession, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateSession(session)
|
repository.updateSession(session)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSession(session: ClimbSession) {
|
fun deleteSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.deleteSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteSession(session: ClimbSession, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteSession(session)
|
repository.deleteSession(session)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,36 +328,30 @@ class ClimbViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attempt operations
|
// Attempt operations
|
||||||
fun addAttempt(attempt: Attempt) {
|
fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertAttempt(attempt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addAttempt(attempt: Attempt, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertAttempt(attempt)
|
repository.insertAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAttempt(attempt: Attempt) {
|
fun deleteAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.deleteAttempt(attempt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteAttempt(attempt: Attempt, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteAttempt(attempt)
|
repository.deleteAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateAttempt(attempt: Attempt) {
|
fun updateAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.updateAttempt(attempt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateAttempt(attempt: Attempt, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateAttempt(attempt)
|
repository.updateAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,107 +476,30 @@ class ClimbViewModel(
|
|||||||
val attempts = repository.getAttemptsBySession(session.id).first()
|
val attempts = repository.getAttemptsBySession(session.id).first()
|
||||||
val attemptCount = attempts.size
|
val attemptCount = attempts.size
|
||||||
|
|
||||||
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
|
val result =
|
||||||
|
healthConnectManager.autoSyncCompletedSession(
|
||||||
|
session,
|
||||||
|
gymName,
|
||||||
|
attemptCount
|
||||||
|
)
|
||||||
|
|
||||||
result
|
result.onFailure { error ->
|
||||||
.onSuccess {
|
if (healthConnectManager.isReadySync()) {
|
||||||
_uiState.value =
|
android.util.Log.w(
|
||||||
_uiState.value.copy(
|
"ClimbViewModel",
|
||||||
message =
|
"Health Connect sync failed: ${error.message}"
|
||||||
"Session synced to Health Connect successfully!"
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
|
||||||
if (healthConnectManager.isReadySync()) {
|
|
||||||
_uiState.value =
|
|
||||||
_uiState.value.copy(
|
|
||||||
error =
|
|
||||||
"Failed to sync to Health Connect: ${error.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (healthConnectManager.isReadySync()) {
|
if (healthConnectManager.isReadySync()) {
|
||||||
_uiState.value =
|
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
|
||||||
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun manualSyncToHealthConnect(sessionId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val session = repository.getSessionById(sessionId)
|
|
||||||
if (session == null) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = "Session not found")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.status != SessionStatus.COMPLETED) {
|
|
||||||
_uiState.value =
|
|
||||||
_uiState.value.copy(error = "Only completed sessions can be synced")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val gym = repository.getGymById(session.gymId)
|
|
||||||
val gymName = gym?.name ?: "Unknown Gym"
|
|
||||||
val attempts = repository.getAttemptsBySession(session.id).first()
|
|
||||||
val attemptCount = attempts.size
|
|
||||||
|
|
||||||
val result =
|
|
||||||
healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
|
|
||||||
|
|
||||||
result
|
|
||||||
.onSuccess {
|
|
||||||
_uiState.value =
|
|
||||||
_uiState.value.copy(
|
|
||||||
message =
|
|
||||||
"Session synced to Health Connect successfully!"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_uiState.value =
|
|
||||||
_uiState.value.copy(
|
|
||||||
error =
|
|
||||||
"Failed to sync to Health Connect: ${error.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value =
|
|
||||||
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
|
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
|
||||||
|
|
||||||
// Share operations
|
|
||||||
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val session = repository.getSessionById(sessionId) ?: return@withContext null
|
|
||||||
val attempts = repository.getAttemptsBySession(sessionId).first()
|
|
||||||
val problems =
|
|
||||||
repository.getAllProblems().first().filter { problem ->
|
|
||||||
attempts.any { it.problemId == problem.id }
|
|
||||||
}
|
|
||||||
val gym = repository.getGymById(session.gymId) ?: return@withContext null
|
|
||||||
|
|
||||||
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
|
|
||||||
SessionShareUtils.generateShareCard(context, session, gym, stats)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value =
|
|
||||||
_uiState.value.copy(
|
|
||||||
error = "Failed to generate share card: ${e.message}"
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareSessionCard(context: Context, imageFile: File) {
|
|
||||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ClimbUiState(
|
data class ClimbUiState(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.atridad.ascently.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@@ -43,4 +45,33 @@ object DateFormatUtils {
|
|||||||
fun millisToISO8601(millis: Long): String {
|
fun millisToISO8601(millis: Long): String {
|
||||||
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a UTC ISO 8601 date string for display in local timezone This fixes the timezone
|
||||||
|
* display issue where UTC dates were shown as local dates
|
||||||
|
*/
|
||||||
|
fun formatDateForDisplay(dateString: String, pattern: String = "MMM dd, yyyy"): String {
|
||||||
|
return try {
|
||||||
|
val instant = parseISO8601(dateString)
|
||||||
|
if (instant != null) {
|
||||||
|
val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
|
||||||
|
localDateTime.format(DateTimeFormatter.ofPattern(pattern))
|
||||||
|
} else {
|
||||||
|
// Fallback for malformed dates
|
||||||
|
dateString.take(10)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dateString.take(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a UTC ISO 8601 date string to LocalDateTime in system timezone */
|
||||||
|
fun parseToLocalDateTime(dateString: String): LocalDateTime? {
|
||||||
|
return try {
|
||||||
|
val instant = parseISO8601(dateString)
|
||||||
|
instant?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ object ImageNamingUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy method for backward compatibility */
|
/** Legacy method for backward compatibility */
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||||
return generateImageFilename(problemId, imageIndex)
|
return generateImageFilename(problemId, imageIndex)
|
||||||
}
|
}
|
||||||
@@ -97,6 +98,26 @@ object ImageNamingUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a mapping of existing server filenames to canonical filenames */
|
/** Creates a mapping of existing server filenames to canonical filenames */
|
||||||
|
/** Validates that a collection of filenames follow our naming convention */
|
||||||
|
fun validateFilenames(filenames: List<String>): ImageValidationResult {
|
||||||
|
val validImages = mutableListOf<String>()
|
||||||
|
val invalidImages = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (filename in filenames) {
|
||||||
|
if (isValidImageFilename(filename)) {
|
||||||
|
validImages.add(filename)
|
||||||
|
} else {
|
||||||
|
invalidImages.add(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageValidationResult(
|
||||||
|
totalImages = filenames.size,
|
||||||
|
validImages = validImages,
|
||||||
|
invalidImages = invalidImages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun createServerMigrationMap(
|
fun createServerMigrationMap(
|
||||||
problemId: String,
|
problemId: String,
|
||||||
serverImageFilenames: List<String>,
|
serverImageFilenames: List<String>,
|
||||||
@@ -124,3 +145,16 @@ object ImageNamingUtils {
|
|||||||
return migrationMap
|
return migrationMap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of image filename validation */
|
||||||
|
data class ImageValidationResult(
|
||||||
|
val totalImages: Int,
|
||||||
|
val validImages: List<String>,
|
||||||
|
val invalidImages: List<String>
|
||||||
|
) {
|
||||||
|
val isAllValid: Boolean
|
||||||
|
get() = invalidImages.isEmpty()
|
||||||
|
|
||||||
|
val validPercentage: Double
|
||||||
|
get() = if (totalImages > 0) (validImages.size.toDouble() / totalImages) * 100.0 else 100.0
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import androidx.core.graphics.toColorInt
|
|||||||
import com.atridad.ascently.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
object SessionShareUtils {
|
object SessionShareUtils {
|
||||||
@@ -457,14 +455,7 @@ object SessionShareUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatSessionDate(dateString: String): String {
|
private fun formatSessionDate(dateString: String): String {
|
||||||
return try {
|
return DateFormatUtils.formatDateForDisplay(dateString, "MMMM dd, yyyy")
|
||||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
|
||||||
val date = LocalDateTime.parse(dateString, formatter)
|
|
||||||
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
|
||||||
date.format(displayFormatter)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
dateString.take(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shareSessionCard(context: Context, imageFile: File) {
|
fun shareSessionCard(context: Context, imageFile: File) {
|
||||||
@@ -512,7 +503,7 @@ object SessionShareUtils {
|
|||||||
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
||||||
}
|
}
|
||||||
DifficultySystem.FONT -> {
|
DifficultySystem.FONT -> {
|
||||||
val list = DifficultySystem.FONT.getAvailableGrades()
|
val list = DifficultySystem.FONT.availableGrades
|
||||||
val idx = list.indexOf(grade.uppercase())
|
val idx = list.indexOf(grade.uppercase())
|
||||||
if (idx >= 0) idx.toDouble()
|
if (idx >= 0) idx.toDouble()
|
||||||
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class DataModelTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testClimbTypeDisplayNames() {
|
fun testClimbTypeDisplayNames() {
|
||||||
assertEquals("Rope", ClimbType.ROPE.getDisplayName())
|
assertEquals("Rope", ClimbType.ROPE.displayName)
|
||||||
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName())
|
assertEquals("Bouldering", ClimbType.BOULDER.displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -34,58 +34,58 @@ class DataModelTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemDisplayNames() {
|
fun testDifficultySystemDisplayNames() {
|
||||||
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName())
|
assertEquals("V Scale", DifficultySystem.V_SCALE.displayName)
|
||||||
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName())
|
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.displayName)
|
||||||
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName())
|
assertEquals("Font Scale", DifficultySystem.FONT.displayName)
|
||||||
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName())
|
assertEquals("Custom", DifficultySystem.CUSTOM.displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemClimbTypeCompatibility() {
|
fun testDifficultySystemClimbTypeCompatibility() {
|
||||||
// Test bouldering systems
|
// Test bouldering systems
|
||||||
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem())
|
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem)
|
||||||
assertTrue(DifficultySystem.FONT.isBoulderingSystem())
|
assertTrue(DifficultySystem.FONT.isBoulderingSystem)
|
||||||
assertFalse(DifficultySystem.YDS.isBoulderingSystem())
|
assertFalse(DifficultySystem.YDS.isBoulderingSystem)
|
||||||
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem())
|
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem)
|
||||||
|
|
||||||
// Test rope systems
|
// Test rope systems
|
||||||
assertTrue(DifficultySystem.YDS.isRopeSystem())
|
assertTrue(DifficultySystem.YDS.isRopeSystem)
|
||||||
assertFalse(DifficultySystem.V_SCALE.isRopeSystem())
|
assertFalse(DifficultySystem.V_SCALE.isRopeSystem)
|
||||||
assertFalse(DifficultySystem.FONT.isRopeSystem())
|
assertFalse(DifficultySystem.FONT.isRopeSystem)
|
||||||
assertTrue(DifficultySystem.CUSTOM.isRopeSystem())
|
assertTrue(DifficultySystem.CUSTOM.isRopeSystem)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemAvailableGrades() {
|
fun testDifficultySystemAvailableGrades() {
|
||||||
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades()
|
val vScaleGrades = DifficultySystem.V_SCALE.availableGrades
|
||||||
assertTrue(vScaleGrades.contains("VB"))
|
assertTrue(vScaleGrades.contains("VB"))
|
||||||
assertTrue(vScaleGrades.contains("V0"))
|
assertTrue(vScaleGrades.contains("V0"))
|
||||||
assertTrue(vScaleGrades.contains("V17"))
|
assertTrue(vScaleGrades.contains("V17"))
|
||||||
assertEquals("VB", vScaleGrades.first())
|
assertEquals("VB", vScaleGrades.first())
|
||||||
|
|
||||||
val ydsGrades = DifficultySystem.YDS.getAvailableGrades()
|
val ydsGrades = DifficultySystem.YDS.availableGrades
|
||||||
assertTrue(ydsGrades.contains("5.0"))
|
assertTrue(ydsGrades.contains("5.0"))
|
||||||
assertTrue(ydsGrades.contains("5.15d"))
|
assertTrue(ydsGrades.contains("5.15d"))
|
||||||
assertTrue(ydsGrades.contains("5.10a"))
|
assertTrue(ydsGrades.contains("5.10a"))
|
||||||
|
|
||||||
val fontGrades = DifficultySystem.FONT.getAvailableGrades()
|
val fontGrades = DifficultySystem.FONT.availableGrades
|
||||||
assertTrue(fontGrades.contains("3"))
|
assertTrue(fontGrades.contains("3"))
|
||||||
assertTrue(fontGrades.contains("8C+"))
|
assertTrue(fontGrades.contains("8C+"))
|
||||||
assertTrue(fontGrades.contains("6A"))
|
assertTrue(fontGrades.contains("6A"))
|
||||||
|
|
||||||
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades()
|
val customGrades = DifficultySystem.CUSTOM.availableGrades
|
||||||
assertTrue(customGrades.isEmpty())
|
assertTrue(customGrades.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemsForClimbType() {
|
fun testDifficultySystemsForClimbType() {
|
||||||
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER)
|
val boulderSystems = DifficultySystem.systemsForClimbType(ClimbType.BOULDER)
|
||||||
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
|
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
|
||||||
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
|
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
|
||||||
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
|
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
|
||||||
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
|
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
|
||||||
|
|
||||||
val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE)
|
val ropeSystems = DifficultySystem.systemsForClimbType(ClimbType.ROPE)
|
||||||
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
|
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
|
||||||
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
|
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
|
||||||
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
|
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
|
||||||
|
|||||||
Reference in New Issue
Block a user