[Android] 2.0.1 - Refactoring & Minor Optimizations
This commit is contained in:
@@ -354,14 +354,6 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset all preferences */
|
|
||||||
fun reset() {
|
|
||||||
preferences.edit().clear().apply()
|
|
||||||
_isEnabled.value = false
|
|
||||||
_hasPermissions.value = false
|
|
||||||
_autoSync.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if ready for use */
|
/** Check if ready for use */
|
||||||
fun isReadySync(): Boolean {
|
fun isReadySync(): Boolean {
|
||||||
return _isEnabled.value && _hasPermissions.value
|
return _isEnabled.value && _hasPermissions.value
|
||||||
@@ -371,15 +363,4 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
fun getLastSyncSuccess(): String? {
|
fun getLastSyncSuccess(): String? {
|
||||||
return preferences.getString("last_sync_success", null)
|
return preferences.getString("last_sync_success", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get detailed status */
|
|
||||||
fun getDetailedStatus(): Map<String, String> {
|
|
||||||
return mapOf(
|
|
||||||
"enabled" to _isEnabled.value.toString(),
|
|
||||||
"hasPermissions" to _hasPermissions.value.toString(),
|
|
||||||
"autoSync" to _autoSync.value.toString(),
|
|
||||||
"compatible" to _isCompatible.value.toString(),
|
|
||||||
"lastSyncSuccess" to (getLastSyncSuccess() ?: "never")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -266,8 +267,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
|
|
||||||
private suspend fun uploadData(backup: ClimbDataBackup) {
|
private suspend fun uploadData(backup: ClimbDataBackup) {
|
||||||
val requestBody =
|
val requestBody =
|
||||||
json.encodeToString(ClimbDataBackup.serializer(), backup)
|
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
|
||||||
.toRequestBody("application/json".toMediaType())
|
|
||||||
|
|
||||||
val request =
|
val request =
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -429,9 +429,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
backup.problems.map { backupProblem ->
|
backup.problems.map { backupProblem ->
|
||||||
val imagePaths = backupProblem.imagePaths
|
val imagePaths = backupProblem.imagePaths
|
||||||
val updatedImagePaths =
|
val updatedImagePaths =
|
||||||
imagePaths?.map { oldPath ->
|
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
|
||||||
imagePathMapping[oldPath] ?: oldPath
|
|
||||||
}
|
|
||||||
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
|
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
|
||||||
}
|
}
|
||||||
val sessions = backup.sessions.map { it.toClimbSession() }
|
val sessions = backup.sessions.map { it.toClimbSession() }
|
||||||
@@ -533,26 +531,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
|
|
||||||
sealed class SyncException(message: String) : IOException(message), Serializable {
|
sealed class SyncException(message: String) : IOException(message), Serializable {
|
||||||
object NotConfigured :
|
object NotConfigured :
|
||||||
SyncException("Sync is not configured. Please set server URL and auth token.") {
|
SyncException("Sync is not configured. Please set server URL and auth token.")
|
||||||
@JvmStatic private fun readResolve(): Any = NotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
object NotConnected : SyncException("Not connected to server. Please test connection first.") {
|
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
||||||
@JvmStatic private fun readResolve(): Any = NotConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
object Unauthorized : SyncException("Unauthorized. Please check your auth token.") {
|
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
||||||
@JvmStatic private fun readResolve(): Any = Unauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
object ImageNotFound : SyncException("Image not found on server") {
|
object ImageNotFound : SyncException("Image not found on server")
|
||||||
@JvmStatic private fun readResolve(): Any = ImageNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
||||||
data class InvalidResponse(val details: String) :
|
data class InvalidResponse(val details: String) :
|
||||||
SyncException("Invalid server response: $details")
|
SyncException("Invalid server response: $details")
|
||||||
data class DecodingError(val details: String) :
|
|
||||||
SyncException("Failed to decode server response: $details")
|
|
||||||
data class NetworkError(val details: String) : SyncException("Network error: $details")
|
data class NetworkError(val details: String) : SyncException("Network error: $details")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ fun OrientationAwareImage(
|
|||||||
?: return@withContext null
|
?: return@withContext null
|
||||||
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
|
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
|
||||||
correctedBitmap.asImageBitmap()
|
correctedBitmap.asImageBitmap()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
notes = notes
|
notes = notes
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing && gymId != null) {
|
||||||
viewModel.updateGym(gym.copy(id = gymId!!))
|
viewModel.updateGym(gym.copy(id = gymId))
|
||||||
} else {
|
} else {
|
||||||
viewModel.addGym(gym)
|
viewModel.addGym(gym)
|
||||||
}
|
}
|
||||||
@@ -385,10 +385,10 @@ fun AddEditProblemScreen(
|
|||||||
notes = notes.ifBlank { null }
|
notes = notes.ifBlank { null }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing && problemId != null) {
|
if (isEditing) {
|
||||||
viewModel.updateProblem(
|
problemId?.let { id ->
|
||||||
problem.copy(id = problemId)
|
viewModel.updateProblem(problem.copy(id = id))
|
||||||
)
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.addProblem(problem)
|
viewModel.addProblem(problem)
|
||||||
}
|
}
|
||||||
@@ -762,9 +762,9 @@ fun AddEditSessionScreen(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
viewModel.updateSession(
|
sessionId?.let { id ->
|
||||||
session.copy(id = sessionId!!)
|
viewModel.updateSession(session.copy(id = id))
|
||||||
)
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.startSession(
|
viewModel.startSession(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -201,14 +201,6 @@ class ClimbViewModel(
|
|||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
fun addSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.insertSession(session)
|
|
||||||
if (updateWidgets) {
|
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -498,8 +490,6 @@ class ClimbViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ClimbUiState(
|
data class ClimbUiState(
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ object DateFormatUtils {
|
|||||||
fun parseISO8601(dateString: String): Instant? {
|
fun parseISO8601(dateString: String): Instant? {
|
||||||
return try {
|
return try {
|
||||||
Instant.from(ISO_FORMATTER.parse(dateString))
|
Instant.from(ISO_FORMATTER.parse(dateString))
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Instant.parse(dateString)
|
Instant.parse(dateString)
|
||||||
} catch (e2: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ object DateFormatUtils {
|
|||||||
// Fallback for malformed dates
|
// Fallback for malformed dates
|
||||||
dateString.take(10)
|
dateString.take(10)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
dateString.take(10)
|
dateString.take(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ object DateFormatUtils {
|
|||||||
return try {
|
return try {
|
||||||
val instant = parseISO8601(dateString)
|
val instant = parseISO8601(dateString)
|
||||||
instant?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }
|
instant?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
@@ -80,93 +78,6 @@ object ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves an image from a URI with compression */
|
|
||||||
fun saveImageFromUri(
|
|
||||||
context: Context,
|
|
||||||
imageUri: Uri,
|
|
||||||
problemId: String? = null,
|
|
||||||
imageIndex: Int? = null
|
|
||||||
): String? {
|
|
||||||
return try {
|
|
||||||
|
|
||||||
val originalBitmap =
|
|
||||||
context.contentResolver.openInputStream(imageUri)?.use { input ->
|
|
||||||
BitmapFactory.decodeStream(input)
|
|
||||||
}
|
|
||||||
?: return null
|
|
||||||
|
|
||||||
// Always require deterministic naming
|
|
||||||
require(problemId != null && imageIndex != null) {
|
|
||||||
"Problem ID and image index are required for deterministic image naming"
|
|
||||||
}
|
|
||||||
|
|
||||||
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
|
||||||
|
|
||||||
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
|
||||||
originalBitmap.recycle()
|
|
||||||
|
|
||||||
if (!success) return null
|
|
||||||
|
|
||||||
"$IMAGES_DIR/$filename"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Corrects image orientation based on EXIF data */
|
|
||||||
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
|
|
||||||
return try {
|
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
|
||||||
inputStream?.use { input ->
|
|
||||||
val exif = ExifInterface(input)
|
|
||||||
val orientation =
|
|
||||||
exif.getAttributeInt(
|
|
||||||
ExifInterface.TAG_ORIENTATION,
|
|
||||||
ExifInterface.ORIENTATION_NORMAL
|
|
||||||
)
|
|
||||||
|
|
||||||
val matrix = android.graphics.Matrix()
|
|
||||||
when (orientation) {
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> {
|
|
||||||
matrix.postRotate(90f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> {
|
|
||||||
matrix.postRotate(180f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_270 -> {
|
|
||||||
matrix.postRotate(270f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
|
||||||
matrix.postScale(1f, -1f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
||||||
matrix.postRotate(90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
||||||
matrix.postRotate(-90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matrix.isIdentity) {
|
|
||||||
bitmap
|
|
||||||
} else {
|
|
||||||
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?: bitmap
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
bitmap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compresses and resizes an image bitmap */
|
/** Compresses and resizes an image bitmap */
|
||||||
@SuppressLint("UseKtx")
|
@SuppressLint("UseKtx")
|
||||||
private fun compressImage(original: Bitmap): Bitmap {
|
private fun compressImage(original: Bitmap): Bitmap {
|
||||||
@@ -306,34 +217,6 @@ object ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves an image from byte array to app's private storage */
|
|
||||||
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
|
||||||
return try {
|
|
||||||
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
|
|
||||||
|
|
||||||
val compressedBitmap = compressImage(bitmap)
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
val filename = "${UUID.randomUUID()}.jpg"
|
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
|
||||||
|
|
||||||
// Save compressed image
|
|
||||||
FileOutputStream(imageFile).use { output ->
|
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up bitmaps
|
|
||||||
bitmap.recycle()
|
|
||||||
compressedBitmap.recycle()
|
|
||||||
|
|
||||||
// Return relative path
|
|
||||||
"$IMAGES_DIR/$filename"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Saves image data with a specific filename */
|
/** Saves image data with a specific filename */
|
||||||
fun saveImageFromBytesWithFilename(
|
fun saveImageFromBytesWithFilename(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -384,52 +267,6 @@ object ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Migrates existing images to use consistent naming convention */
|
|
||||||
fun migrateImageNaming(
|
|
||||||
context: Context,
|
|
||||||
problemId: String,
|
|
||||||
currentImagePaths: List<String>
|
|
||||||
): Map<String, String> {
|
|
||||||
val migrationMap = mutableMapOf<String, String>()
|
|
||||||
|
|
||||||
currentImagePaths.forEachIndexed { index, oldPath ->
|
|
||||||
val oldFilename = oldPath.substringAfterLast('/')
|
|
||||||
val newFilename = ImageNamingUtils.generateImageFilename(problemId, index)
|
|
||||||
|
|
||||||
if (oldFilename != newFilename) {
|
|
||||||
try {
|
|
||||||
val oldFile = getImageFile(context, oldPath)
|
|
||||||
val newFile = File(getImagesDirectory(context), newFilename)
|
|
||||||
|
|
||||||
if (oldFile.exists() && oldFile.renameTo(newFile)) {
|
|
||||||
val newPath = "$IMAGES_DIR/$newFilename"
|
|
||||||
migrationMap[oldPath] = newPath
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Log error but continue with other images
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return migrationMap
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Batch migrates all images in the system to use consistent naming */
|
|
||||||
fun batchMigrateAllImages(
|
|
||||||
context: Context,
|
|
||||||
problemImageMap: Map<String, List<String>>
|
|
||||||
): Map<String, String> {
|
|
||||||
val allMigrations = mutableMapOf<String, String>()
|
|
||||||
|
|
||||||
problemImageMap.forEach { (problemId, imagePaths) ->
|
|
||||||
val migrations = migrateImageNaming(context, problemId, imagePaths)
|
|
||||||
allMigrations.putAll(migrations)
|
|
||||||
}
|
|
||||||
|
|
||||||
return allMigrations
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cleans up orphaned images that are not referenced by any problems */
|
/** Cleans up orphaned images that are not referenced by any problems */
|
||||||
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
|
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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()
|
||||||
repository.getActiveSession()
|
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats
|
||||||
val completedSessions = sessions.filter { it.endTime != null }
|
val completedSessions = sessions.filter { it.endTime != null }
|
||||||
@@ -108,7 +107,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||||
views.setTextViewText(R.id.widget_total_sessions, "0")
|
views.setTextViewText(R.id.widget_total_sessions, "0")
|
||||||
|
|||||||
Reference in New Issue
Block a user