Compare commits

...

9 Commits
0.4.4 ... 1.0.1

18 changed files with 493 additions and 60 deletions

View File

@@ -817,6 +817,19 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />

3
.idea/misc.xml generated
View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
</project>

View File

@@ -8,14 +8,14 @@ plugins {
android {
namespace = "com.atridad.openclimb"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 35
versionCode = 11
versionName = "0.4.4"
minSdk = 33
targetSdk = 36
versionCode = 15
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -30,12 +30,19 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures {
compose = true
}
@@ -74,13 +81,16 @@ dependencies {
// Image Loading
implementation(libs.coil.compose)
// Charts - Placeholder for future implementation
// Charts will be implemented with a stable library in future versions
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)

View File

@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
@@ -27,7 +28,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb">
android:theme="@style/Theme.OpenClimb.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -13,6 +13,7 @@ import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge()
setContent {
OpenClimbTheme {

View File

@@ -7,6 +7,7 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
@@ -21,8 +22,11 @@ class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null
private var monitoringJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
companion object {
const val NOTIFICATION_ID = 1001
@@ -51,8 +55,10 @@ class SessionTrackingService : Service() {
val database = OpenClimbDatabase.getDatabase(this)
repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
acquireWakeLock()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -81,31 +87,88 @@ class SessionTrackingService : Service() {
}
}
}
// Return START_STICKY to restart service if it gets killed
return START_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
// If the app is removed from recent tasks, ensure the service keeps running
// This helps maintain the notification even if the user swipes away the app
}
override fun onLowMemory() {
super.onLowMemory()
// Don't stop the service on low memory, just log it
// The notification is important for user experience
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
// Cancel any existing jobs
notificationJob?.cancel()
monitoringJob?.cancel()
// Start the main notification update job
notificationJob = serviceScope.launch {
// Initial notification update
updateNotification(sessionId)
// Then update every second
while (isActive) {
delay(1000L)
try {
// Initial notification update
updateNotification(sessionId)
// Update every 2 seconds for better performance
while (isActive) {
delay(2000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
// Log error and continue
e.printStackTrace()
}
}
// Start the monitoring job that ensures notification stays active
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(5000L) // Check every 5 seconds
// Verify the notification is still active
if (!isNotificationActive()) {
// Notification was dismissed, recreate it
updateNotification(sessionId)
}
// Verify the session is still active
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun stopSessionTracking() {
notificationJob?.cancel()
monitoringJob?.cancel()
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun isNotificationActive(): Boolean {
return try {
val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) {
false
}
}
private suspend fun updateNotification(sessionId: String) {
try {
val session = repository.getSessionById(sessionId)
@@ -141,7 +204,10 @@ class SessionTrackingService : Service() {
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
@@ -155,20 +221,31 @@ class SessionTrackingService : Service() {
)
.build()
// Force update the notification every second
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Always start foreground to ensure service stays alive
startForeground(NOTIFICATION_ID, notification)
// Also notify separately to ensure it's visible
notificationManager.notify(NOTIFICATION_ID, notification)
startForeground(NOTIFICATION_ID, notification)
} catch (_: Exception) {
// Handle errors gracefully
stopSessionTracking()
} catch (e: Exception) {
e.printStackTrace()
// Don't stop the service on notification errors, just log them
// Try to restart the notification after a delay
try {
delay(5000L)
updateNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
// If retry fails, stop the service to prevent infinite loops
stopSessionTracking()
}
}
}
private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
}
return PendingIntent.getActivity(
this,
@@ -196,15 +273,46 @@ class SessionTrackingService : Service() {
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
try {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"OpenClimb:SessionTrackingWakeLock"
).apply {
acquire(10*60*1000L) // 10 minutes timeout
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun releaseWakeLock() {
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()
monitoringJob?.cancel()
releaseWakeLock()
serviceScope.cancel()
}
}

View File

@@ -1,8 +1,13 @@
package com.atridad.openclimb.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -18,9 +23,12 @@ import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.theme.OpenClimbTheme
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -34,6 +42,40 @@ fun OpenClimbApp() {
factory = ClimbViewModelFactory(repository)
)
// Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
// Handle permission result
if (isGranted) {
// Permission granted, continue
} else {
// Permission denied, show dialog again later
showNotificationPermissionDialog = false
}
}
// Check notification permission on first launch
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
}
}
}
// Ensure session tracking service is running when app resumes
LaunchedEffect(Unit) {
viewModel.ensureSessionTrackingServiceRunning(context)
}
// FAB configuration
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
@@ -67,13 +109,19 @@ fun OpenClimbApp() {
LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
FabConfig(
icon = Icons.Default.Add,
icon = Icons.Default.PlayArrow,
contentDescription = "Start Session",
onClick = {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
// Check notification permission before starting session
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
} else {
navController.navigate(Screen.AddEditSession())
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
navController.navigate(Screen.AddEditSession())
}
}
}
)
@@ -151,7 +199,10 @@ fun OpenClimbApp() {
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -177,6 +228,12 @@ fun OpenClimbApp() {
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -214,6 +271,16 @@ fun OpenClimbApp() {
)
}
}
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString())
}
)
}
}
}

View File

@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -13,9 +12,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.theme.CustomIcons
import kotlinx.coroutines.delay
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.delay
@Composable
fun ActiveSessionBanner(
@@ -95,7 +95,7 @@ fun ActiveSessionBanner(
)
) {
Icon(
Icons.Default.Close,
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session"
)
}

View File

@@ -0,0 +1,89 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun NotificationPermissionDialog(
onDismiss: () -> Unit,
onRequestPermission: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = "Notifications",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.headlineSmall,
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "OpenClimb needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Not Now")
}
Button(
onClick = {
onRequestPermission()
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Text("Enable")
}
}
}
}
}
}

View File

@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplaySection
import com.atridad.openclimb.ui.theme.CustomIcons
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -209,7 +210,12 @@ fun EditAttemptDialog(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigateBack: () -> Unit) {
fun SessionDetailScreen(
sessionId: String,
viewModel: ClimbViewModel,
onNavigateBack: () -> Unit,
onNavigateToProblemDetail: (String) -> Unit = {}
) {
val context = LocalContext.current
val attempts by viewModel.getAttemptsBySession(sessionId).collectAsState(initial = emptyList())
val sessions by viewModel.sessions.collectAsState()
@@ -283,8 +289,23 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
}
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
// Show stop icon for active sessions, delete icon for completed sessions
if (session?.status == SessionStatus.ACTIVE) {
IconButton(onClick = {
session?.let { s ->
viewModel.endSession(context, s.id)
onNavigateBack()
}
}) {
Icon(
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onSurface),
contentDescription = "Stop Session"
)
}
} else {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
)
@@ -504,7 +525,8 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit },
onDeleteAttempt = { attemptToDelete ->
viewModel.deleteAttempt(attemptToDelete)
}
},
onAttemptClick = { onNavigateToProblemDetail(problem.id) }
)
}
}
@@ -888,7 +910,9 @@ fun GymDetailScreen(
gymId: String,
viewModel: ClimbViewModel,
onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit
onNavigateToEdit: (String) -> Unit,
onNavigateToSessionDetail: (String) -> Unit = {},
onNavigateToProblemDetail: (String) -> Unit = {}
) {
val gyms by viewModel.gyms.collectAsState()
val gym = gyms.find { it.id == gymId }
@@ -1119,14 +1143,16 @@ fun GymDetailScreen(
Card(
modifier =
Modifier.fillMaxWidth().padding(vertical = 4.dp),
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onNavigateToProblemDetail(problem.id) },
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f)
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
ListItem(
headlineContent = {
@@ -1192,14 +1218,16 @@ fun GymDetailScreen(
Card(
modifier =
Modifier.fillMaxWidth().padding(vertical = 4.dp),
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onNavigateToSessionDetail(session.id) },
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f)
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
ListItem(
headlineContent = {
@@ -1440,11 +1468,17 @@ fun SessionAttemptCard(
attempt: Attempt,
problem: Problem,
onEditAttempt: (Attempt) -> Unit = {},
onDeleteAttempt: (Attempt) -> Unit = {}
onDeleteAttempt: (Attempt) -> Unit = {},
onAttemptClick: () -> Unit = {}
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onAttemptClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -1495,11 +1529,7 @@ fun SessionAttemptCard(
// Delete button
IconButton(
onClick = { showDeleteDialog = true },
modifier = Modifier.size(32.dp),
colors =
IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Delete,

View File

@@ -0,0 +1,25 @@
package com.atridad.openclimb.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object CustomIcons {
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Stop",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6f, 6f)
horizontalLineTo(18f)
verticalLineTo(18f)
horizontalLineTo(6f)
close()
}.build()
}

View File

@@ -147,6 +147,14 @@ class ClimbViewModel(
// Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch {
// Check notification permission first
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
_uiState.value = _uiState.value.copy(
error = "Notification permission is required to track your climbing session. Please enable notifications in settings."
)
return@launch
}
val existingActive = repository.getActiveSession()
if (existingActive != null) {
_uiState.value = _uiState.value.copy(
@@ -170,6 +178,14 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
// Check notification permission first
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
_uiState.value = _uiState.value.copy(
error = "Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
return@launch
}
val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) {
val completedSession = with(ClimbSession) { session.complete() }
@@ -186,6 +202,21 @@ class ClimbViewModel(
}
}
/**
* Check if the session tracking service is running and restart it if needed
*/
fun ensureSessionTrackingServiceRunning(context: Context) {
viewModelScope.launch {
val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
// Check if service is running by trying to start it again
// The service will handle duplicate starts gracefully
val serviceIntent = SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent)
}
}
}
// Attempt operations
fun addAttempt(attempt: Attempt) {
viewModelScope.launch {

View File

@@ -0,0 +1,33 @@
package com.atridad.openclimb.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
object NotificationPermissionUtils {
/**
* Check if notification permission is granted
*/
fun isNotificationPermissionGranted(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
/**
* Check if notification permission should be requested
*/
fun shouldRequestNotificationPermission(): Boolean {
return true
}
/**
* Get the notification permission string
*/
fun getNotificationPermissionString(): String {
return Manifest.permission.POST_NOTIFICATIONS
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color>
</resources>

View File

@@ -7,4 +7,7 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color>
</resources>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimationDuration">200</item>
</style>
</resources>

View File

@@ -1,10 +1,14 @@
[versions]
agp = "8.9.1"
agp = "8.12.1"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
androidxTestCore = "1.6.0"
androidxTestExt = "1.2.0"
androidxTestRunner = "1.6.0"
androidxTestRules = "1.6.0"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2024.09.00"
@@ -21,6 +25,10 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
@@ -48,6 +56,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }

View File

@@ -1,6 +1,6 @@
#Fri Aug 15 11:23:25 MDT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists