[Android] 2.0.1 - Refactoring & Minor Optimizations

This commit is contained in:
2025-10-14 23:47:02 -06:00
parent 5a49b9f0b2
commit 3c7290f7e7
15 changed files with 48 additions and 758 deletions

View File

@@ -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<String>()
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<String>()
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

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)) {

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -590,8 +590,7 @@ fun AddEditProblemScreen(
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)

View File

@@ -1870,8 +1870,7 @@ fun EnhancedAddAttemptDialog(
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)

View File

@@ -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<ClimbType?>(null) }

View File

@@ -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) {

View File

@@ -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<String>
): Map<String, String> {
val renameMap = mutableMapOf<String, String>()
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<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(
problemId: String,
serverImageFilenames: List<String>,
localImageCount: Int
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
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<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
}

View File

@@ -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 {

View File

@@ -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<Problem>,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageGrade: String?,
val sessionDuration: String,
val topResult: AttemptResult?,
val topGrade: String?
)
fun calculateSessionStats(
session: ClimbSession,
attempts: List<Attempt>,
problems: List<Problem>
): 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<Problem>, climbingType: String): String? {
if (problems.isEmpty()) return null
// Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>()
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<Problem>): 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<Problem>): 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
}
}
}
}

View File

@@ -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 }

View File

@@ -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"))