Compare commits

..

4 Commits
1.4.0 ... 1.4.2

Author SHA1 Message Date
f106244e57 oooops 2025-09-09 12:59:59 -06:00
76a9120184 oops 2025-09-09 12:58:26 -06:00
abeed46c90 1.4.2 - Dropped minSDK down to support Android 12 2025-09-09 12:57:02 -06:00
7770997fd4 1.4.1 - Shortcuts Bug Fix 2025-09-08 00:49:00 -06:00
8 changed files with 320 additions and 187 deletions

3
.gitignore vendored
View File

@@ -8,6 +8,7 @@ local.properties
# Log/OS Files # Log/OS Files
*.log *.log
.DS_Store
# Android Studio generated files and folders # Android Studio generated files and folders
captures/ captures/
@@ -32,4 +33,4 @@ render.experimental.xml
google-services.json google-services.json
# Android Profiling # Android Profiling
*.hprof *.hprof

View File

@@ -14,10 +14,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 34 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 21 versionCode = 23
versionName = "1.4.0" versionName = "1.4.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -14,6 +14,12 @@ import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null) private var shortcutAction by mutableStateOf<String?>(null)
private var lastUsedGymId by mutableStateOf<String?>(null)
fun clearShortcutAction() {
shortcutAction = null
lastUsedGymId = null
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -21,11 +27,16 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
shortcutAction = intent?.action shortcutAction = intent?.action
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
setContent { setContent {
OpenClimbTheme { OpenClimbTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
OpenClimbApp(shortcutAction = shortcutAction) OpenClimbApp(
shortcutAction = shortcutAction,
lastUsedGymId = lastUsedGymId,
onShortcutActionProcessed = { clearShortcutAction() }
)
} }
} }
} }
@@ -36,5 +47,6 @@ class MainActivity : ComponentActivity() {
setIntent(intent) setIntent(intent)
shortcutAction = intent.action shortcutAction = intent.action
lastUsedGymId = intent.getStringExtra("LAST_USED_GYM_ID")
} }
} }

View File

@@ -1,32 +1,26 @@
package com.atridad.openclimb.data.repository package com.atridad.openclimb.data.repository
import android.content.Context import android.content.Context
import android.os.Environment
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ZipExportImportUtils import com.atridad.openclimb.utils.ZipExportImportUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository( class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
database: OpenClimbDatabase,
private val context: Context
) {
private val gymDao = database.gymDao() private val gymDao = database.gymDao()
private val problemDao = database.problemDao() private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao() private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val json = Json {
private val json = Json { prettyPrint = true
prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
// Gym operations // Gym operations
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms() fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
@@ -34,7 +28,7 @@ class ClimbRepository(
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query) fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
// Problem operations // Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems() fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
@@ -43,27 +37,36 @@ class ClimbRepository(
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query) fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
// Session operations // Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId) fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow() fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
getGymById(recentSessions.first().gymId)
} else {
null
}
}
// Attempt operations // Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId) fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId) attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
// ZIP Export with images - Single format for reliability // ZIP Export with images - Single format for reliability
suspend fun exportAllDataToZip(directory: File? = null): File { suspend fun exportAllDataToZip(directory: File? = null): File {
try { try {
@@ -72,47 +75,58 @@ class ClimbRepository(
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export // Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport( val exportData =
exportedAt = LocalDateTime.now().toString(), ClimbDataExport(
version = "1.0", exportedAt = LocalDateTime.now().toString(),
gyms = allGyms, version = "1.0",
problems = allProblems, gyms = allGyms,
sessions = allSessions, problems = allProblems,
attempts = allAttempts sessions = allSessions,
) attempts = allAttempts
)
// Collect all referenced image paths and validate they exist // Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath -> val validImagePaths =
try { referencedImagePaths
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath) .filter { imagePath ->
imageFile.exists() && imageFile.length() > 0 try {
} catch (e: Exception) { val imageFile =
false com.atridad.openclimb.utils.ImageUtils.getImageFile(
} context,
}.toSet() imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
// Log any missing images for debugging // Log any missing images for debugging
val missingImages = referencedImagePaths - validImagePaths val missingImages = referencedImagePaths - validImagePaths
if (missingImages.isNotEmpty()) { if (missingImages.isNotEmpty()) {
android.util.Log.w("ClimbRepository", "Some referenced images are missing: $missingImages") android.util.Log.w(
"ClimbRepository",
"Some referenced images are missing: $missingImages"
)
} }
return ZipExportImportUtils.createExportZip( return ZipExportImportUtils.createExportZip(
context = context, context = context,
exportData = exportData, exportData = exportData,
referencedImagePaths = validImagePaths, referencedImagePaths = validImagePaths,
directory = directory directory = directory
) )
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Export failed: ${e.message}") throw Exception("Export failed: ${e.message}")
} }
} }
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try { try {
// Collect all data with proper error handling // Collect all data with proper error handling
@@ -120,72 +134,81 @@ class ClimbRepository(
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export // Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport( val exportData =
exportedAt = LocalDateTime.now().toString(), ClimbDataExport(
version = "1.0", exportedAt = LocalDateTime.now().toString(),
gyms = allGyms, version = "1.0",
problems = allProblems, gyms = allGyms,
sessions = allSessions, problems = allProblems,
attempts = allAttempts sessions = allSessions,
) attempts = allAttempts
)
// Collect all referenced image paths and validate they exist // Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath -> val validImagePaths =
try { referencedImagePaths
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath) .filter { imagePath ->
imageFile.exists() && imageFile.length() > 0 try {
} catch (e: Exception) { val imageFile =
false com.atridad.openclimb.utils.ImageUtils.getImageFile(
} context,
}.toSet() imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
context = context, context = context,
uri = uri, uri = uri,
exportData = exportData, exportData = exportData,
referencedImagePaths = validImagePaths referencedImagePaths = validImagePaths
) )
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Export failed: ${e.message}") throw Exception("Export failed: ${e.message}")
} }
} }
suspend fun importDataFromZip(file: File) { suspend fun importDataFromZip(file: File) {
try { try {
// Validate the ZIP file // Validate the ZIP file
if (!file.exists() || file.length() == 0L) { if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist") throw Exception("Invalid ZIP file: file is empty or doesn't exist")
} }
// Extract and validate the ZIP contents // Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file) val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content // Validate JSON content
if (importResult.jsonContent.isBlank()) { if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content") throw Exception("Invalid ZIP file: no data.json found or empty content")
} }
// Parse and validate the data structure // Parse and validate the data structure
val importData = try { val importData =
json.decodeFromString<ClimbDataExport>(importResult.jsonContent) try {
} catch (e: Exception) { json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
throw Exception("Invalid data format: ${e.message}") } catch (e: Exception) {
} throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity // Validate data integrity
validateImportData(importData) validateImportData(importData)
// Clear existing data to avoid conflicts // Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts() attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) // Import gyms first (problems depend on gyms)
importData.gyms.forEach { gym -> importData.gyms.forEach { gym ->
try { try {
@@ -194,13 +217,14 @@ class ClimbRepository(
throw Exception("Failed to import gym ${gym.name}: ${e.message}") throw Exception("Failed to import gym ${gym.name}: ${e.message}")
} }
} }
// Import problems with updated image paths // Import problems with updated image paths
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( val updatedProblems =
importData.problems, ZipExportImportUtils.updateProblemImagePaths(
importResult.importedImagePaths importData.problems,
) importResult.importedImagePaths
)
updatedProblems.forEach { problem -> updatedProblems.forEach { problem ->
try { try {
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
@@ -208,7 +232,7 @@ class ClimbRepository(
throw Exception("Failed to import problem ${problem.name}: ${e.message}") throw Exception("Failed to import problem ${problem.name}: ${e.message}")
} }
} }
// Import sessions // Import sessions
importData.sessions.forEach { session -> importData.sessions.forEach { session ->
try { try {
@@ -217,7 +241,7 @@ class ClimbRepository(
throw Exception("Failed to import session: ${e.message}") throw Exception("Failed to import session: ${e.message}")
} }
} }
// Import attempts last (depends on problems and sessions) // Import attempts last (depends on problems and sessions)
importData.attempts.forEach { attempt -> importData.attempts.forEach { attempt ->
try { try {
@@ -226,61 +250,66 @@ class ClimbRepository(
throw Exception("Failed to import attempt: ${e.message}") throw Exception("Failed to import attempt: ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Import failed: ${e.message}") throw Exception("Import failed: ${e.message}")
} }
} }
private fun validateDataIntegrity( private fun validateDataIntegrity(
gyms: List<Gym>, gyms: List<Gym>,
problems: List<Problem>, problems: List<Problem>,
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
attempts: List<Attempt> attempts: List<Attempt>
) { ) {
// Validate that all problems reference valid gyms // Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet() val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds } val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) { if (invalidProblems.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidProblems.size} problems reference non-existent gyms") throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
)
} }
// Validate that all sessions reference valid gyms // Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds } val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) { if (invalidSessions.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms") throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
} }
// Validate that all attempts reference valid problems and sessions // Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet() val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts = attempts.filter { val invalidAttempts =
it.problemId !in problemIds || it.sessionId !in sessionIds attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
}
if (invalidAttempts.isNotEmpty()) { if (invalidAttempts.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions") throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
)
} }
} }
private fun validateImportData(importData: ClimbDataExport) { private fun validateImportData(importData: ClimbDataExport) {
if (importData.gyms.isEmpty()) { if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found") throw Exception("Import data is invalid: no gyms found")
} }
if (importData.version.isBlank()) { if (importData.version.isBlank()) {
throw Exception("Import data is invalid: no version information") throw Exception("Import data is invalid: no version information")
} }
// Check for reasonable data sizes to prevent malicious imports // Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 || if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 || importData.problems.size > 10000 ||
importData.sessions.size > 10000 || importData.sessions.size > 10000 ||
importData.attempts.size > 100000) { importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file") throw Exception("Import data is too large: possible corruption or malicious file")
} }
} }
suspend fun resetAllData() { suspend fun resetAllData() {
try { try {
// Clear all data from database // Clear all data from database
@@ -288,15 +317,14 @@ class ClimbRepository(
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Clear all images from storage // Clear all images from storage
clearAllImages() clearAllImages()
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Reset failed: ${e.message}") throw Exception("Reset failed: ${e.message}")
} }
} }
private fun clearAllImages() { private fun clearAllImages() {
try { try {
// Get the images directory // Get the images directory
@@ -314,10 +342,10 @@ class ClimbRepository(
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class ClimbDataExport( data class ClimbDataExport(
val exportedAt: String, val exportedAt: String,
val version: String = "1.0", val version: String = "1.0",
val gyms: List<Gym>, val gyms: List<Gym>,
val problems: List<Problem>, val problems: List<Problem>,
val sessions: List<ClimbSession>, val sessions: List<ClimbSession>,
val attempts: List<Attempt> val attempts: List<Attempt>
) )

View File

@@ -4,36 +4,33 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
data class BottomNavigationItem( data class BottomNavigationItem(val screen: Screen, val icon: ImageVector, val label: String)
val screen: Screen,
val icon: ImageVector,
val label: String
)
val bottomNavigationItems = listOf( val bottomNavigationItems =
BottomNavigationItem( listOf(
screen = Screen.Sessions, BottomNavigationItem(
icon = Icons.Default.PlayArrow, screen = Screen.Sessions,
label = "Sessions" icon = Icons.Default.PlayArrow,
), label = "Sessions"
BottomNavigationItem( ),
screen = Screen.Problems, BottomNavigationItem(
icon = Icons.Default.Star, screen = Screen.Problems,
label = "Problems" icon = Icons.Default.Star,
), label = "Problems"
BottomNavigationItem( ),
screen = Screen.Analytics, BottomNavigationItem(
icon = Icons.Default.Info, screen = Screen.Analytics,
label = "Analytics" icon = Icons.Default.Info,
), label = "Analytics"
BottomNavigationItem( ),
screen = Screen.Gyms, BottomNavigationItem(
icon = Icons.Default.LocationOn, screen = Screen.Gyms,
label = "Gyms" icon = Icons.Default.LocationOn,
), label = "Gyms"
BottomNavigationItem( ),
screen = Screen.Settings, BottomNavigationItem(
icon = Icons.Default.Settings, screen = Screen.Settings,
label = "Settings" icon = Icons.Default.Settings,
) label = "Settings"
) )
)

View File

@@ -11,6 +11,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -30,10 +31,16 @@ import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OpenClimbApp(shortcutAction: String? = null) { fun OpenClimbApp(
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
val database = remember { OpenClimbDatabase.getDatabase(context) } val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository)) val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
@@ -69,11 +76,19 @@ fun OpenClimbApp(shortcutAction: String? = null) {
val activeSession by viewModel.activeSession.collectAsState() val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(activeSession, gyms) { // Update last used gym when gyms change
LaunchedEffect(gyms) {
if (gyms.isNotEmpty() && lastUsedGym == null) {
lastUsedGym = viewModel.getLastUsedGym()
}
}
LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts( AppShortcutManager.updateShortcuts(
context = context, context = context,
hasActiveSession = activeSession != null, hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty() hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
) )
} }
@@ -84,22 +99,6 @@ fun OpenClimbApp(shortcutAction: String? = null) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
launchSingleTop = true launchSingleTop = true
} }
if (activeSession == null && gyms.isNotEmpty()) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
navController.navigate(Screen.AddEditSession())
}
}
}
} }
AppShortcutManager.ACTION_END_SESSION -> { AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) { navController.navigate(Screen.Sessions) {
@@ -112,6 +111,62 @@ fun OpenClimbApp(shortcutAction: String? = null) {
} }
} }
// Process shortcut actions after data is loaded
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"OpenClimbApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"OpenClimbApp",
"Starting session with single gym: ${gyms.first().name}"
)
viewModel.startSession(context, gyms.first().id)
} else {
// Try to get the last used gym from the intent or fallback to state
val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
if (targetGym != null) {
android.util.Log.d(
"OpenClimbApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"OpenClimbApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession())
}
}
}
} else {
android.util.Log.d(
"OpenClimbApp",
"Active session already exists: ${activeSession?.id}"
)
}
// Clear the shortcut action after processing to prevent repeated execution
onShortcutActionProcessed()
}
}
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
@@ -155,6 +210,8 @@ fun OpenClimbApp(shortcutAction: String? = null) {
if (gyms.size == 1) { if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
// Always show gym selection for FAB when
// multiple gyms
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
@@ -173,7 +230,6 @@ fun OpenClimbApp(shortcutAction: String? = null) {
} }
composable<Screen.Problems> { composable<Screen.Problems> {
val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
fabConfig = fabConfig =
if (gyms.isNotEmpty()) { if (gyms.isNotEmpty()) {

View File

@@ -190,12 +190,18 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId) repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
// Active session management // Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) { fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch { viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
if (!com.atridad.openclimb.utils.NotificationPermissionUtils if (!com.atridad.openclimb.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = error =
@@ -206,6 +212,10 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
android.util.Log.d(
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = "There's already an active session. Please end it first." error = "There's already an active session. Please end it first."
@@ -213,15 +223,21 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
return@launch return@launch
} }
android.util.Log.d("ClimbViewModel", "Creating new session")
val newSession = ClimbSession.create(gymId = gymId, notes = notes) val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession) repository.insertSession(newSession)
android.util.Log.d(
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
// Start the tracking service // Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
android.util.Log.d("ClimbViewModel", "Session started successfully")
_uiState.value = _uiState.value.copy(message = "Session started successfully!") _uiState.value = _uiState.value.copy(message = "Session started successfully!")
} }
} }

View File

@@ -19,7 +19,12 @@ object AppShortcutManager {
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION" const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
/** Updates the app shortcuts based on current session state */ /** Updates the app shortcuts based on current session state */
fun updateShortcuts(context: Context, hasActiveSession: Boolean, hasGyms: Boolean) { fun updateShortcuts(
context: Context,
hasActiveSession: Boolean,
hasGyms: Boolean,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java) val shortcutManager = context.getSystemService(ShortcutManager::class.java)
@@ -30,7 +35,7 @@ object AppShortcutManager {
shortcuts.add(createEndSessionShortcut(context)) shortcuts.add(createEndSessionShortcut(context))
} else if (hasGyms) { } else if (hasGyms) {
// Show "Start Session" shortcut when no active session but gyms exist // Show "Start Session" shortcut when no active session but gyms exist
shortcuts.add(createStartSessionShortcut(context)) shortcuts.add(createStartSessionShortcut(context, lastUsedGym))
} }
shortcutManager.dynamicShortcuts = shortcuts shortcutManager.dynamicShortcuts = shortcuts
@@ -38,16 +43,34 @@ object AppShortcutManager {
} }
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createStartSessionShortcut(context: Context): ShortcutInfo { private fun createStartSessionShortcut(
context: Context,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
): ShortcutInfo {
val startIntent = val startIntent =
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
action = ACTION_START_SESSION action = ACTION_START_SESSION
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
lastUsedGym?.let { gym -> putExtra("LAST_USED_GYM_ID", gym.id) }
}
val shortLabel =
if (lastUsedGym != null) {
"Start at ${lastUsedGym.name}"
} else {
context.getString(R.string.shortcut_start_session_short)
}
val longLabel =
if (lastUsedGym != null) {
"Start a new climbing session at ${lastUsedGym.name}"
} else {
context.getString(R.string.shortcut_start_session_long)
} }
return ShortcutInfo.Builder(context, SHORTCUT_START_SESSION) return ShortcutInfo.Builder(context, SHORTCUT_START_SESSION)
.setShortLabel(context.getString(R.string.shortcut_start_session_short)) .setShortLabel(shortLabel)
.setLongLabel(context.getString(R.string.shortcut_start_session_long)) .setLongLabel(longLabel)
.setIcon(Icon.createWithResource(context, R.drawable.ic_play_arrow_24)) .setIcon(Icon.createWithResource(context, R.drawable.ic_play_arrow_24))
.setIntent(startIntent) .setIntent(startIntent)
.build() .build()