[Android] 2.0.1 - Refactoring & Minor Optimizations
This commit is contained in:
@@ -28,8 +28,8 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
val MIGRATION_4_5 =
|
val MIGRATION_4_5 =
|
||||||
object : Migration(4, 5) {
|
object : Migration(4, 5) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
val cursor = database.query("PRAGMA table_info(climb_sessions)")
|
val cursor = db.query("PRAGMA table_info(climb_sessions)")
|
||||||
val existingColumns = mutableSetOf<String>()
|
val existingColumns = mutableSetOf<String>()
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
@@ -39,21 +39,21 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
if (!existingColumns.contains("startTime")) {
|
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")) {
|
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")) {
|
if (!existingColumns.contains("status")) {
|
||||||
database.execSQL(
|
db.execSQL(
|
||||||
"ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'"
|
"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"
|
"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 = ''"
|
"UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,9 +61,9 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
val MIGRATION_5_6 =
|
val MIGRATION_5_6 =
|
||||||
object : Migration(5, 6) {
|
object : Migration(5, 6) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
// Add updatedAt column to attempts table
|
// 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>()
|
val existingColumns = mutableSetOf<String>()
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
@@ -73,11 +73,11 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
if (!existingColumns.contains("updatedAt")) {
|
if (!existingColumns.contains("updatedAt")) {
|
||||||
database.execSQL(
|
db.execSQL(
|
||||||
"ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''"
|
"ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''"
|
||||||
)
|
)
|
||||||
// Set updatedAt to createdAt for existing records
|
// Set updatedAt to createdAt for existing records
|
||||||
database.execSQL(
|
db.execSQL(
|
||||||
"UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''"
|
"UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,14 +88,14 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
|||||||
return INSTANCE
|
return INSTANCE
|
||||||
?: synchronized(this) {
|
?: synchronized(this) {
|
||||||
val instance =
|
val instance =
|
||||||
Room.databaseBuilder(
|
Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AscentlyDatabase::class.java,
|
AscentlyDatabase::class.java,
|
||||||
"ascently_database"
|
"ascently_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.enableMultiInstanceInvalidation()
|
.enableMultiInstanceInvalidation()
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
|
|||||||
@@ -12,19 +12,7 @@ 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(
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ 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(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fun ImageDisplay(
|
|||||||
imageSize: Int = 120,
|
imageSize: Int = 120,
|
||||||
onImageClick: ((Int) -> Unit)? = null
|
onImageClick: ((Int) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
LocalContext.current
|
||||||
|
|
||||||
if (imagePaths.isNotEmpty()) {
|
if (imagePaths.isNotEmpty()) {
|
||||||
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ private fun createImageFile(context: android.content.Context): File {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
|
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
Box(modifier = modifier.size(80.dp)) {
|
Box(modifier = modifier.size(80.dp)) {
|
||||||
OrientationAwareImage(
|
OrientationAwareImage(
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import kotlinx.coroutines.withContext
|
|||||||
@Composable
|
@Composable
|
||||||
fun OrientationAwareImage(
|
fun OrientationAwareImage(
|
||||||
imagePath: String,
|
imagePath: String,
|
||||||
contentDescription: String? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
contentScale: ContentScale = ContentScale.Fit
|
contentScale: ContentScale = ContentScale.Fit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -116,15 +116,7 @@ private fun correctImageOrientation(
|
|||||||
needsTransform = true
|
needsTransform = true
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
if (orientation == ExifInterface.ORIENTATION_UNDEFINED || orientation == 0) {
|
// Default case - no transformation needed
|
||||||
if (imageFile.name.startsWith("problem_") &&
|
|
||||||
imageFile.name.contains("_") &&
|
|
||||||
imageFile.name.endsWith(".jpg")
|
|
||||||
) {
|
|
||||||
matrix.postRotate(90f)
|
|
||||||
needsTransform = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +138,7 @@ private fun correctImageOrientation(
|
|||||||
}
|
}
|
||||||
rotatedBitmap
|
rotatedBitmap
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
bitmap
|
bitmap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,8 +590,7 @@ fun AddEditProblemScreen(
|
|||||||
.outlinedTextFieldColors(),
|
.outlinedTextFieldColors(),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.menuAnchor(
|
Modifier.menuAnchor(
|
||||||
androidx.compose.material3
|
ExposedDropdownMenuAnchorType
|
||||||
.MenuAnchorType
|
|
||||||
.PrimaryNotEditable,
|
.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1870,8 +1870,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
.outlinedTextFieldColors(),
|
.outlinedTextFieldColors(),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.menuAnchor(
|
Modifier.menuAnchor(
|
||||||
androidx.compose.material3
|
ExposedDropdownMenuAnchorType
|
||||||
.MenuAnchorType
|
|
||||||
.PrimaryNotEditable,
|
.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -30,7 +29,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
val problems by viewModel.problems.collectAsState()
|
val problems by viewModel.problems.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
val attempts by viewModel.attempts.collectAsState()
|
val attempts by viewModel.attempts.collectAsState()
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
|
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ object DateFormatUtils {
|
|||||||
return ISO_FORMATTER.format(Instant.now())
|
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 */
|
/** Parse an iOS-compatible ISO 8601 date string back to Instant */
|
||||||
fun parseISO8601(dateString: String): Instant? {
|
fun parseISO8601(dateString: String): Instant? {
|
||||||
return try {
|
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
|
* 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
|
* 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 {
|
return try {
|
||||||
val instant = parseISO8601(dateString)
|
val instant = parseISO8601(dateString)
|
||||||
if (instant != null) {
|
if (instant != null) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.atridad.ascently.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility for creating consistent image filenames across iOS and Android platforms. Uses
|
* Utility for creating consistent image filenames across iOS and Android platforms. Uses
|
||||||
@@ -26,47 +25,6 @@ object ImageNamingUtils {
|
|||||||
return generateImageFilename(problemId, imageIndex)
|
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 */
|
/** Creates a deterministic hash from input string */
|
||||||
private fun createHash(input: String): String {
|
private fun createHash(input: String): String {
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
@@ -75,86 +33,5 @@ object ImageNamingUtils {
|
|||||||
return hashHex.take(HASH_LENGTH)
|
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 */
|
/** 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
@@ -118,35 +120,35 @@ object ImageUtils {
|
|||||||
return try {
|
return try {
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
inputStream?.use { input ->
|
inputStream?.use { input ->
|
||||||
val exif = androidx.exifinterface.media.ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
val orientation =
|
val orientation =
|
||||||
exif.getAttributeInt(
|
exif.getAttributeInt(
|
||||||
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
|
ExifInterface.TAG_ORIENTATION,
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
)
|
)
|
||||||
|
|
||||||
val matrix = android.graphics.Matrix()
|
val matrix = android.graphics.Matrix()
|
||||||
when (orientation) {
|
when (orientation) {
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||||
matrix.postRotate(90f)
|
matrix.postRotate(90f)
|
||||||
}
|
}
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||||
matrix.postRotate(180f)
|
matrix.postRotate(180f)
|
||||||
}
|
}
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||||
matrix.postRotate(270f)
|
matrix.postRotate(270f)
|
||||||
}
|
}
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||||
matrix.postScale(1f, -1f)
|
matrix.postScale(1f, -1f)
|
||||||
}
|
}
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
matrix.postRotate(90f)
|
matrix.postRotate(90f)
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
matrix.postRotate(-90f)
|
matrix.postRotate(-90f)
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
@@ -155,15 +157,7 @@ object ImageUtils {
|
|||||||
if (matrix.isIdentity) {
|
if (matrix.isIdentity) {
|
||||||
bitmap
|
bitmap
|
||||||
} else {
|
} else {
|
||||||
android.graphics.Bitmap.createBitmap(
|
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
bitmap,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
bitmap.width,
|
|
||||||
bitmap.height,
|
|
||||||
matrix,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?: bitmap
|
?: bitmap
|
||||||
@@ -260,9 +254,8 @@ object ImageUtils {
|
|||||||
/** Temporarily saves an image during selection process */
|
/** Temporarily saves an image during selection process */
|
||||||
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
|
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
|
||||||
return try {
|
return try {
|
||||||
val originalBitmap =
|
val source = ImageDecoder.createSource(context.contentResolver, imageUri)
|
||||||
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
|
val originalBitmap = ImageDecoder.decodeBitmap(source)
|
||||||
?: return null
|
|
||||||
|
|
||||||
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
|
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
|
||||||
val imageFile = File(getImagesDirectory(context), tempFilename)
|
val imageFile = File(getImagesDirectory(context), tempFilename)
|
||||||
@@ -401,7 +394,7 @@ object ImageUtils {
|
|||||||
|
|
||||||
currentImagePaths.forEachIndexed { index, oldPath ->
|
currentImagePaths.forEachIndexed { index, oldPath ->
|
||||||
val oldFilename = oldPath.substringAfterLast('/')
|
val oldFilename = oldPath.substringAfterLast('/')
|
||||||
val newFilename = ImageNamingUtils.migrateFilename(oldFilename, problemId, index)
|
val newFilename = ImageNamingUtils.generateImageFilename(problemId, index)
|
||||||
|
|
||||||
if (oldFilename != newFilename) {
|
if (oldFilename != newFilename) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
val problems = repository.getAllProblems().first()
|
val problems = repository.getAllProblems().first()
|
||||||
val attempts = repository.getAllAttempts().first()
|
val attempts = repository.getAllAttempts().first()
|
||||||
val gyms = repository.getAllGyms().first()
|
val gyms = repository.getAllGyms().first()
|
||||||
val activeSession = repository.getActiveSession()
|
repository.getActiveSession()
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats
|
||||||
val completedSessions = sessions.filter { it.endTime != null }
|
val completedSessions = sessions.filter { it.endTime != null }
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ class DataModelTests {
|
|||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
assertTrue(currentTime > 0)
|
assertTrue(currentTime > 0)
|
||||||
|
|
||||||
val timeString = java.time.Instant.ofEpochMilli(currentTime).toString()
|
val timeString = Instant.ofEpochMilli(currentTime).toString()
|
||||||
assertTrue(timeString.isNotEmpty())
|
assertTrue(timeString.isNotEmpty())
|
||||||
assertTrue(timeString.contains("T"))
|
assertTrue(timeString.contains("T"))
|
||||||
assertTrue(timeString.endsWith("Z"))
|
assertTrue(timeString.endsWith("Z"))
|
||||||
|
|||||||
Reference in New Issue
Block a user