diff --git a/android/app/src/main/java/com/atridad/ascently/data/database/OpenClimbDatabase.kt b/android/app/src/main/java/com/atridad/ascently/data/database/OpenClimbDatabase.kt index 6f5fa36..abd30ac 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/database/OpenClimbDatabase.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/database/OpenClimbDatabase.kt @@ -28,8 +28,8 @@ abstract class AscentlyDatabase : RoomDatabase() { val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(database: SupportSQLiteDatabase) { - val cursor = database.query("PRAGMA table_info(climb_sessions)") + override fun migrate(db: SupportSQLiteDatabase) { + val cursor = db.query("PRAGMA table_info(climb_sessions)") val existingColumns = mutableSetOf() while (cursor.moveToNext()) { @@ -39,21 +39,21 @@ abstract class AscentlyDatabase : RoomDatabase() { cursor.close() if (!existingColumns.contains("startTime")) { - database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT") + db.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT") } if (!existingColumns.contains("endTime")) { - database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") + db.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") } if (!existingColumns.contains("status")) { - database.execSQL( + db.execSQL( "ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'" ) } - database.execSQL( + db.execSQL( "UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL" ) - database.execSQL( + db.execSQL( "UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''" ) } @@ -61,9 +61,9 @@ abstract class AscentlyDatabase : RoomDatabase() { val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // Add updatedAt column to attempts table - val cursor = database.query("PRAGMA table_info(attempts)") + val cursor = db.query("PRAGMA table_info(attempts)") val existingColumns = mutableSetOf() while (cursor.moveToNext()) { @@ -73,11 +73,11 @@ abstract class AscentlyDatabase : RoomDatabase() { cursor.close() if (!existingColumns.contains("updatedAt")) { - database.execSQL( + db.execSQL( "ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''" ) // Set updatedAt to createdAt for existing records - database.execSQL( + db.execSQL( "UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''" ) } @@ -88,14 +88,14 @@ abstract class AscentlyDatabase : RoomDatabase() { return INSTANCE ?: synchronized(this) { val instance = - Room.databaseBuilder( - context.applicationContext, - AscentlyDatabase::class.java, - "ascently_database" - ) - .addMigrations(MIGRATION_4_5, MIGRATION_5_6) - .enableMultiInstanceInvalidation() - .fallbackToDestructiveMigration() + Room.databaseBuilder( + context.applicationContext, + AscentlyDatabase::class.java, + "ascently_database" + ) + .addMigrations(MIGRATION_4_5, MIGRATION_5_6) + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration(false) .build() INSTANCE = instance instance diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt index 4681e7c..4eb6174 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt @@ -12,19 +12,7 @@ enum class AttemptResult { SUCCESS, FALL, NO_PROGRESS, - 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 + FLASH } @Entity( diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt index 50b794e..af1e026 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt @@ -11,15 +11,7 @@ import kotlinx.serialization.Serializable enum class SessionStatus { ACTIVE, COMPLETED, - PAUSED; - - val displayName: String - get() = - when (this) { - ACTIVE -> "Active" - COMPLETED -> "Completed" - PAUSED -> "Paused" - } + PAUSED } @Entity( diff --git a/android/app/src/main/java/com/atridad/ascently/ui/components/ImageDisplay.kt b/android/app/src/main/java/com/atridad/ascently/ui/components/ImageDisplay.kt index 0d983e0..36d18e1 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/components/ImageDisplay.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/components/ImageDisplay.kt @@ -20,7 +20,7 @@ fun ImageDisplay( imageSize: Int = 120, onImageClick: ((Int) -> Unit)? = null ) { - val context = LocalContext.current + LocalContext.current if (imagePaths.isNotEmpty()) { LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/android/app/src/main/java/com/atridad/ascently/ui/components/ImagePicker.kt b/android/app/src/main/java/com/atridad/ascently/ui/components/ImagePicker.kt index 3ff28ef..0422d96 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/components/ImagePicker.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/components/ImagePicker.kt @@ -255,7 +255,7 @@ private fun createImageFile(context: android.content.Context): File { @Composable private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) { val context = LocalContext.current - val imageFile = ImageUtils.getImageFile(context, imagePath) + ImageUtils.getImageFile(context, imagePath) Box(modifier = modifier.size(80.dp)) { OrientationAwareImage( diff --git a/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt b/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt index 8c8b32e..be95029 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt @@ -23,8 +23,8 @@ import kotlinx.coroutines.withContext @Composable fun OrientationAwareImage( imagePath: String, - contentDescription: String? = null, modifier: Modifier = Modifier, + contentDescription: String? = null, contentScale: ContentScale = ContentScale.Fit ) { val context = LocalContext.current @@ -116,15 +116,7 @@ private fun correctImageOrientation( needsTransform = true } else -> { - if (orientation == ExifInterface.ORIENTATION_UNDEFINED || orientation == 0) { - if (imageFile.name.startsWith("problem_") && - imageFile.name.contains("_") && - imageFile.name.endsWith(".jpg") - ) { - matrix.postRotate(90f) - needsTransform = true - } - } + // Default case - no transformation needed } } @@ -146,7 +138,7 @@ private fun correctImageOrientation( } rotatedBitmap } - } catch (e: Exception) { + } catch (_: Exception) { bitmap } } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt index e60ce8f..fcece32 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt @@ -590,8 +590,7 @@ fun AddEditProblemScreen( .outlinedTextFieldColors(), modifier = Modifier.menuAnchor( - androidx.compose.material3 - .MenuAnchorType + ExposedDropdownMenuAnchorType .PrimaryNotEditable, enabled = true ) diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt index 31dfc74..411c147 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt @@ -1870,8 +1870,7 @@ fun EnhancedAddAttemptDialog( .outlinedTextFieldColors(), modifier = Modifier.menuAnchor( - androidx.compose.material3 - .MenuAnchorType + ExposedDropdownMenuAnchorType .PrimaryNotEditable, enabled = true ) diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt index bfb6842..4799996 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -30,7 +29,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String val problems by viewModel.problems.collectAsState() val gyms by viewModel.gyms.collectAsState() val attempts by viewModel.attempts.collectAsState() - val context = LocalContext.current // Filter state var selectedClimbType by remember { mutableStateOf(null) } diff --git a/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt index 6fe7d7b..cfa73ab 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt @@ -17,11 +17,6 @@ object DateFormatUtils { return ISO_FORMATTER.format(Instant.now()) } - /** Format an Instant to iOS-compatible ISO 8601 format */ - fun formatISO8601(instant: Instant): String { - return ISO_FORMATTER.format(instant) - } - /** Parse an iOS-compatible ISO 8601 date string back to Instant */ fun parseISO8601(dateString: String): Instant? { return try { @@ -36,21 +31,12 @@ object DateFormatUtils { } } - /** Validate that a date string matches the expected iOS format */ - fun isValidISO8601(dateString: String): Boolean { - return parseISO8601(dateString) != null - } - - /** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */ - fun millisToISO8601(millis: Long): String { - 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 { + fun formatDateForDisplay(dateString: String): String { + val pattern = "MMM dd, yyyy" return try { val instant = parseISO8601(dateString) if (instant != null) { diff --git a/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt index 371d664..954c4d1 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt @@ -1,7 +1,6 @@ package com.atridad.ascently.utils import java.security.MessageDigest -import java.util.* /** * Utility for creating consistent image filenames across iOS and Android platforms. Uses @@ -26,47 +25,6 @@ object ImageNamingUtils { return generateImageFilename(problemId, imageIndex) } - /** Extracts problem ID from an image filename */ - fun extractProblemIdFromFilename(filename: String): String? { - if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { - return null - } - - // Format: problem_{hash}_{index}.jpg - val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length) - val parts = nameWithoutExtension.split("_") - - if (parts.size != 3 || parts[0] != "problem") { - return null - } - - return parts[1] - } - - /** Validates if a filename follows our naming convention */ - fun isValidImageFilename(filename: String): Boolean { - if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { - return false - } - - val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length) - val parts = nameWithoutExtension.split("_") - - return parts.size == 3 && - parts[0] == "problem" && - parts[1].length == HASH_LENGTH && - parts[2].toIntOrNull() != null - } - - /** Migrates an existing filename to our naming convention */ - fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String { - if (isValidImageFilename(oldFilename)) { - return oldFilename - } - - return generateImageFilename(problemId, imageIndex) - } - /** Creates a deterministic hash from input string */ private fun createHash(input: String): String { val digest = MessageDigest.getInstance("SHA-256") @@ -75,86 +33,5 @@ object ImageNamingUtils { return hashHex.take(HASH_LENGTH) } - /** Batch renames images for a problem to use our naming convention */ - fun batchRenameForProblem( - problemId: String, - existingFilenames: List - ): Map { - val renameMap = mutableMapOf() - - existingFilenames.forEachIndexed { index, oldFilename -> - val newFilename = generateImageFilename(problemId, index) - if (newFilename != oldFilename) { - renameMap[oldFilename] = newFilename - } - } - - return renameMap - } - - /** Generates the canonical filename for a problem image */ - fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String { - return generateImageFilename(problemId, imageIndex) - } - /** Creates a mapping of existing server filenames to canonical filenames */ - /** Validates that a collection of filenames follow our naming convention */ - fun validateFilenames(filenames: List): ImageValidationResult { - val validImages = mutableListOf() - val invalidImages = mutableListOf() - - 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( - problemId: String, - serverImageFilenames: List, - localImageCount: Int - ): Map { - val migrationMap = mutableMapOf() - - for (imageIndex in 0 until localImageCount) { - val canonicalName = getCanonicalImageFilename(problemId, imageIndex) - - if (serverImageFilenames.contains(canonicalName)) { - continue - } - - for (serverFilename in serverImageFilenames) { - if (isValidImageFilename(serverFilename) && - !migrationMap.values.contains(serverFilename) - ) { - migrationMap[serverFilename] = canonicalName - break - } - } - } - - return migrationMap - } -} - -/** Result of image filename validation */ -data class ImageValidationResult( - val totalImages: Int, - val validImages: List, - val invalidImages: List -) { - val isAllValid: Boolean - get() = invalidImages.isEmpty() - - val validPercentage: Double - get() = if (totalImages > 0) (validImages.size.toDouble() / totalImages) * 100.0 else 100.0 } diff --git a/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt index 2a2d74f..a481278 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt @@ -4,7 +4,9 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.ImageDecoder import android.net.Uri +import android.os.Build import android.provider.MediaStore import android.util.Log import androidx.core.graphics.scale @@ -118,35 +120,35 @@ object ImageUtils { return try { val inputStream = context.contentResolver.openInputStream(imageUri) inputStream?.use { input -> - val exif = androidx.exifinterface.media.ExifInterface(input) + val exif = ExifInterface(input) val orientation = exif.getAttributeInt( - androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION, - androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL ) val matrix = android.graphics.Matrix() when (orientation) { - androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> { + ExifInterface.ORIENTATION_ROTATE_90 -> { matrix.postRotate(90f) } - androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> { + ExifInterface.ORIENTATION_ROTATE_180 -> { matrix.postRotate(180f) } - androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> { + ExifInterface.ORIENTATION_ROTATE_270 -> { matrix.postRotate(270f) } - androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { matrix.postScale(-1f, 1f) } - androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { matrix.postScale(1f, -1f) } - androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> { + ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f) matrix.postScale(-1f, 1f) } - androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> { + ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(-90f) matrix.postScale(-1f, 1f) } @@ -155,15 +157,7 @@ object ImageUtils { if (matrix.isIdentity) { bitmap } else { - android.graphics.Bitmap.createBitmap( - bitmap, - 0, - 0, - bitmap.width, - bitmap.height, - matrix, - true - ) + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } } ?: bitmap @@ -260,9 +254,8 @@ object ImageUtils { /** Temporarily saves an image during selection process */ fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? { return try { - val originalBitmap = - MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri) - ?: return null + val source = ImageDecoder.createSource(context.contentResolver, imageUri) + val originalBitmap = ImageDecoder.decodeBitmap(source) val tempFilename = "temp_${UUID.randomUUID()}.jpg" val imageFile = File(getImagesDirectory(context), tempFilename) @@ -401,7 +394,7 @@ object ImageUtils { currentImagePaths.forEachIndexed { index, oldPath -> val oldFilename = oldPath.substringAfterLast('/') - val newFilename = ImageNamingUtils.migrateFilename(oldFilename, problemId, index) + val newFilename = ImageNamingUtils.generateImageFilename(problemId, index) if (oldFilename != newFilename) { try { diff --git a/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt deleted file mode 100644 index b027f69..0000000 --- a/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt +++ /dev/null @@ -1,534 +0,0 @@ -package com.atridad.ascently.utils - -import android.content.Context -import android.content.Intent -import android.graphics.* -import android.graphics.drawable.GradientDrawable -import androidx.core.content.FileProvider -import androidx.core.graphics.createBitmap -import androidx.core.graphics.toColorInt -import com.atridad.ascently.data.model.* -import java.io.File -import java.io.FileOutputStream -import kotlin.math.roundToInt - -object SessionShareUtils { - - data class SessionStats( - val totalAttempts: Int, - val successfulAttempts: Int, - val problems: List, - val uniqueProblemsAttempted: Int, - val uniqueProblemsCompleted: Int, - val averageGrade: String?, - val sessionDuration: String, - val topResult: AttemptResult?, - val topGrade: String? - ) - - fun calculateSessionStats( - session: ClimbSession, - attempts: List, - problems: List - ): SessionStats { - val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) - - val successfulAttempts = attempts.filter { it.result in successfulResults } - val uniqueProblems = attempts.map { it.problemId }.distinct() - val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct() - - val attemptedProblems = problems.filter { it.id in uniqueProblems } - - // Calculate separate averages for different climbing types and difficulty systems - val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } - val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } - - val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder") - val ropeAverage = calculateAverageGrade(ropeProblems, "Rope") - - // Combine averages for display - val averageGrade = - when { - boulderAverage != null && ropeAverage != null -> - "$boulderAverage / $ropeAverage" - boulderAverage != null -> boulderAverage - ropeAverage != null -> ropeAverage - else -> null - } - - // Determine highest achieved grade (only from completed problems: SUCCESS or FLASH) - val completedProblems = problems.filter { it.id in uniqueCompletedProblems } - val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER } - val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE } - val topBoulder = highestGradeForProblems(completedBoulder) - val topRope = highestGradeForProblems(completedRope) - val topGrade = - when { - topBoulder != null && topRope != null -> "$topBoulder / $topRope" - topBoulder != null -> topBoulder - topRope != null -> topRope - else -> null - } - - val duration = if (session.duration != null) "${session.duration}m" else "Unknown" - val topResult = - attempts - .maxByOrNull { - when (it.result) { - AttemptResult.FLASH -> 3 - AttemptResult.SUCCESS -> 2 - AttemptResult.FALL -> 1 - else -> 0 - } - } - ?.result - - return SessionStats( - totalAttempts = attempts.size, - successfulAttempts = successfulAttempts.size, - problems = attemptedProblems, - uniqueProblemsAttempted = uniqueProblems.size, - uniqueProblemsCompleted = uniqueCompletedProblems.size, - averageGrade = averageGrade, - sessionDuration = duration, - topResult = topResult, - topGrade = topGrade - ) - } - - /** - * Calculate average grade for a specific set of problems, respecting their difficulty systems - */ - private fun calculateAverageGrade(problems: List, climbingType: String): String? { - if (problems.isEmpty()) return null - - // Group problems by difficulty system - val problemsBySystem = problems.groupBy { it.difficulty.system } - - val averages = mutableListOf() - - problemsBySystem.forEach { (system, systemProblems) -> - when (system) { - DifficultySystem.V_SCALE -> { - val gradeValues = - systemProblems.mapNotNull { problem -> - when { - problem.difficulty.grade == "VB" -> 0 - else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() - } - } - if (gradeValues.isNotEmpty()) { - val avg = gradeValues.average().roundToInt() - averages.add(if (avg == 0) "VB" else "V$avg") - } - } - DifficultySystem.FONT -> { - val gradeValues = - systemProblems.mapNotNull { problem -> - // Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> - // 7) - problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() - } - if (gradeValues.isNotEmpty()) { - val avg = gradeValues.average().roundToInt() - averages.add("$avg") - } - } - DifficultySystem.YDS -> { - val gradeValues = - systemProblems.mapNotNull { problem -> - // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) - val grade = problem.difficulty.grade - if (grade.startsWith("5.")) { - grade.substring(2).toDoubleOrNull() - } else null - } - if (gradeValues.isNotEmpty()) { - val avg = gradeValues.average() - averages.add("5.${String.format("%.1f", avg)}") - } - } - DifficultySystem.CUSTOM -> { - // For custom systems, try to extract numeric values - val gradeValues = - systemProblems.mapNotNull { problem -> - problem.difficulty - .grade - .filter { it.isDigit() || it == '.' || it == '-' } - .toDoubleOrNull() - } - if (gradeValues.isNotEmpty()) { - val avg = gradeValues.average() - averages.add(String.format("%.1f", avg)) - } - } - } - } - - return if (averages.isNotEmpty()) { - if (averages.size == 1) { - averages.first() - } else { - averages.joinToString(" / ") - } - } else null - } - - fun generateShareCard( - context: Context, - session: ClimbSession, - gym: Gym, - stats: SessionStats - ): File? { - return try { - val width = 1242 // 3:4 aspect at higher resolution for better fit - val height = 1656 - - val bitmap = createBitmap(width, height) - val canvas = Canvas(bitmap) - - val gradientDrawable = - GradientDrawable( - GradientDrawable.Orientation.TOP_BOTTOM, - intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt()) - ) - gradientDrawable.setBounds(0, 0, width, height) - gradientDrawable.draw(canvas) - - // Setup paint objects - val titlePaint = - Paint().apply { - color = Color.WHITE - textSize = 72f - typeface = Typeface.DEFAULT_BOLD - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val subtitlePaint = - Paint().apply { - color = "#E8E8E8".toColorInt() - textSize = 48f - typeface = Typeface.DEFAULT - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val statLabelPaint = - Paint().apply { - color = "#B8B8B8".toColorInt() - textSize = 36f - typeface = Typeface.DEFAULT - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val statValuePaint = - Paint().apply { - color = Color.WHITE - textSize = 64f - typeface = Typeface.DEFAULT_BOLD - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val cardPaint = - Paint().apply { - color = "#40FFFFFF".toColorInt() - isAntiAlias = true - } - - // Draw main card background - val cardRect = RectF(60f, 200f, width - 60f, height - 120f) - canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint) - - // Draw content - var yPosition = 300f - - // Title - canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint) - yPosition += 80f - - // Gym and date - canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint) - yPosition += 60f - - val dateText = formatSessionDate(session.date) - canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint) - yPosition += 120f - - // Stats grid - val statsStartY = yPosition - val columnWidth = width / 2f - val columnMaxTextWidth = columnWidth - 120f - - // Left column stats - var leftY = statsStartY - drawStatItemFitting( - canvas, - columnWidth / 2f, - leftY, - "Attempts", - stats.totalAttempts.toString(), - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - leftY += 120f - drawStatItemFitting( - canvas, - columnWidth / 2f, - leftY, - "Problems", - stats.uniqueProblemsAttempted.toString(), - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - leftY += 120f - drawStatItemFitting( - canvas, - columnWidth / 2f, - leftY, - "Duration", - stats.sessionDuration, - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - - // Right column stats - var rightY = statsStartY - drawStatItemFitting( - canvas, - width - columnWidth / 2f, - rightY, - "Completed", - stats.uniqueProblemsCompleted.toString(), - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - rightY += 120f - - var rightYAfter = rightY - stats.topGrade?.let { grade -> - drawStatItemFitting( - canvas, - width - columnWidth / 2f, - rightY, - "Top Grade", - grade, - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - rightYAfter += 120f - } - - // Grade range(s) - val boulderRange = - gradeRangeForProblems( - stats.problems.filter { it.climbType == ClimbType.BOULDER } - ) - val ropeRange = - gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE }) - val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f - if (boulderRange != null && ropeRange != null) { - // Two evenly spaced items - drawStatItemFitting( - canvas, - columnWidth / 2f, - rangesY, - "Boulder Range", - boulderRange, - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - drawStatItemFitting( - canvas, - width - columnWidth / 2f, - rangesY, - "Rope Range", - ropeRange, - statLabelPaint, - statValuePaint, - columnMaxTextWidth - ) - } else if (boulderRange != null || ropeRange != null) { - // Single centered item - val singleRange = boulderRange ?: ropeRange ?: "" - drawStatItemFitting( - canvas, - width / 2f, - rangesY, - "Grade Range", - singleRange, - statLabelPaint, - statValuePaint, - width - 200f - ) - } - - // App branding - val brandingPaint = - Paint().apply { - color = "#80FFFFFF".toColorInt() - textSize = 32f - typeface = Typeface.DEFAULT - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - canvas.drawText("Ascently", width / 2f, height - 40f, brandingPaint) - - // Save to file - val shareDir = File(context.cacheDir, "shares") - if (!shareDir.exists()) { - shareDir.mkdirs() - } - - val filename = "session_${session.id}_${System.currentTimeMillis()}.png" - val file = File(shareDir, filename) - - val outputStream = FileOutputStream(file) - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - outputStream.flush() - outputStream.close() - - bitmap.recycle() - - file - } catch (e: Exception) { - e.printStackTrace() - null - } - } - - private fun drawStatItem( - canvas: Canvas, - x: Float, - y: Float, - label: String, - value: String, - labelPaint: Paint, - valuePaint: Paint - ) { - canvas.drawText(value, x, y, valuePaint) - canvas.drawText(label, x, y + 50f, labelPaint) - } - - /** - * Draws a stat item while fitting the value text to a max width by reducing text size if - * needed. - */ - private fun drawStatItemFitting( - canvas: Canvas, - x: Float, - y: Float, - label: String, - value: String, - labelPaint: Paint, - valuePaint: Paint, - maxTextWidth: Float - ) { - val tempPaint = Paint(valuePaint) - var textSize = tempPaint.textSize - var textWidth = tempPaint.measureText(value) - while (textWidth > maxTextWidth && textSize > 36f) { - textSize -= 2f - tempPaint.textSize = textSize - textWidth = tempPaint.measureText(value) - } - canvas.drawText(value, x, y, tempPaint) - canvas.drawText(label, x, y + 50f, labelPaint) - } - - /** - * Returns a range string like "X - Y" for the given problems, based on their difficulty grades. - */ - private fun gradeRangeForProblems(problems: List): String? { - if (problems.isEmpty()) return null - val grades = problems.map { it.difficulty } - val sorted = grades.sortedWith { a, b -> a.compareTo(b) } - return "${sorted.first().grade} - ${sorted.last().grade}" - } - - private fun formatSessionDate(dateString: String): String { - return DateFormatUtils.formatDateForDisplay(dateString, "MMMM dd, yyyy") - } - - fun shareSessionCard(context: Context, imageFile: File) { - try { - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - imageFile - ) - - val shareIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #Ascently") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - val chooser = Intent.createChooser(shareIntent, "Share Session") - chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(chooser) - } catch (e: Exception) { - e.printStackTrace() - } - } - - /** - * Returns the highest grade string among the given problems, respecting their difficulty - * system. - */ - private fun highestGradeForProblems(problems: List): String? { - if (problems.isEmpty()) return null - return problems - .maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) } - ?.difficulty - ?.grade - } - - /** Produces a comparable numeric rank for grades across supported systems. */ - private fun gradeRank(system: DifficultySystem, grade: String): Double { - return when (system) { - DifficultySystem.V_SCALE -> { - if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0 - } - DifficultySystem.FONT -> { - val list = DifficultySystem.FONT.availableGrades - val idx = list.indexOf(grade.uppercase()) - if (idx >= 0) idx.toDouble() - else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0 - } - DifficultySystem.YDS -> { - // Parse 5.X with optional letter a-d - val s = grade.lowercase() - if (!s.startsWith("5.")) return -1.0 - val tail = s.removePrefix("5.") - val numberPart = tail.takeWhile { it.isDigit() || it == '.' } - val letterPart = tail.drop(numberPart.length).firstOrNull() - val base = numberPart.toDoubleOrNull() ?: return -1.0 - val letterWeight = - when (letterPart) { - 'a' -> 0.0 - 'b' -> 0.1 - 'c' -> 0.2 - 'd' -> 0.3 - else -> 0.0 - } - base + letterWeight - } - DifficultySystem.CUSTOM -> { - grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0 - } - } - } -} diff --git a/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt b/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt index 52e27d0..885a16d 100644 --- a/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt +++ b/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt @@ -53,7 +53,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() { val problems = repository.getAllProblems().first() val attempts = repository.getAllAttempts().first() val gyms = repository.getAllGyms().first() - val activeSession = repository.getActiveSession() + repository.getActiveSession() // Calculate stats val completedSessions = sessions.filter { it.endTime != null } diff --git a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt index 23512cb..8286423 100644 --- a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt +++ b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt @@ -387,7 +387,7 @@ class DataModelTests { val currentTime = System.currentTimeMillis() assertTrue(currentTime > 0) - val timeString = java.time.Instant.ofEpochMilli(currentTime).toString() + val timeString = Instant.ofEpochMilli(currentTime).toString() assertTrue(timeString.isNotEmpty()) assertTrue(timeString.contains("T")) assertTrue(timeString.endsWith("Z"))