Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
7770997fd4
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 21
|
versionCode = 22
|
||||||
versionName = "1.4.0"
|
versionName = "1.4.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
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
|
||||||
@@ -47,23 +41,32 @@ class ClimbRepository(
|
|||||||
// 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 {
|
||||||
@@ -76,7 +79,8 @@ class ClimbRepository(
|
|||||||
// 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 =
|
||||||
|
ClimbDataExport(
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
version = "1.0",
|
version = "1.0",
|
||||||
gyms = allGyms,
|
gyms = allGyms,
|
||||||
@@ -87,19 +91,29 @@ class ClimbRepository(
|
|||||||
|
|
||||||
// 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 =
|
||||||
|
referencedImagePaths
|
||||||
|
.filter { imagePath ->
|
||||||
try {
|
try {
|
||||||
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
|
val imageFile =
|
||||||
|
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||||
|
context,
|
||||||
|
imagePath
|
||||||
|
)
|
||||||
imageFile.exists() && imageFile.length() > 0
|
imageFile.exists() && imageFile.length() > 0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}.toSet()
|
}
|
||||||
|
.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(
|
||||||
@@ -124,7 +138,8 @@ class ClimbRepository(
|
|||||||
// 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 =
|
||||||
|
ClimbDataExport(
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
version = "1.0",
|
version = "1.0",
|
||||||
gyms = allGyms,
|
gyms = allGyms,
|
||||||
@@ -135,14 +150,21 @@ class ClimbRepository(
|
|||||||
|
|
||||||
// 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 =
|
||||||
|
referencedImagePaths
|
||||||
|
.filter { imagePath ->
|
||||||
try {
|
try {
|
||||||
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
|
val imageFile =
|
||||||
|
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||||
|
context,
|
||||||
|
imagePath
|
||||||
|
)
|
||||||
imageFile.exists() && imageFile.length() > 0
|
imageFile.exists() && imageFile.length() > 0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}.toSet()
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
ZipExportImportUtils.createExportZipToUri(
|
ZipExportImportUtils.createExportZipToUri(
|
||||||
context = context,
|
context = context,
|
||||||
@@ -171,7 +193,8 @@ class ClimbRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate the data structure
|
// Parse and validate the data structure
|
||||||
val importData = try {
|
val importData =
|
||||||
|
try {
|
||||||
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Invalid data format: ${e.message}")
|
throw Exception("Invalid data format: ${e.message}")
|
||||||
@@ -196,7 +219,8 @@ class ClimbRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import problems with updated image paths
|
// Import problems with updated image paths
|
||||||
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
val updatedProblems =
|
||||||
|
ZipExportImportUtils.updateProblemImagePaths(
|
||||||
importData.problems,
|
importData.problems,
|
||||||
importResult.importedImagePaths
|
importResult.importedImagePaths
|
||||||
)
|
)
|
||||||
@@ -226,7 +250,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -242,24 +265,29 @@ class ClimbRepository(
|
|||||||
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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +304,8 @@ class ClimbRepository(
|
|||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +320,6 @@ class ClimbRepository(
|
|||||||
|
|
||||||
// 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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ 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 =
|
||||||
|
listOf(
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
screen = Screen.Sessions,
|
screen = Screen.Sessions,
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
@@ -36,4 +33,4 @@ val bottomNavigationItems = listOf(
|
|||||||
icon = Icons.Default.Settings,
|
icon = Icons.Default.Settings,
|
||||||
label = "Settings"
|
label = "Settings"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user