Compare commits

...

4 Commits
0.5.0 ... 1.0.1

13 changed files with 412 additions and 46 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" />

View File

@@ -12,10 +12,10 @@ android {
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 31
minSdk = 33
targetSdk = 36
versionCode = 13
versionName = "0.5.0"
versionCode = 15
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -37,7 +37,6 @@ android {
jvmTarget = "17"
}
// Ensure consistent JVM toolchain across all tasks
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
@@ -82,9 +81,6 @@ 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)

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,5 +1,9 @@
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
@@ -19,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
@@ -35,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) }
@@ -71,10 +112,16 @@ fun OpenClimbApp() {
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())
}
}
}
)
@@ -152,7 +199,10 @@ fun OpenClimbApp() {
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -178,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))
}
)
}
@@ -215,6 +271,16 @@ fun OpenClimbApp() {
)
}
}
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString())
}
)
}
}
}

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

@@ -210,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()
@@ -520,7 +525,8 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit },
onDeleteAttempt = { attemptToDelete ->
viewModel.deleteAttempt(attemptToDelete)
}
},
onAttemptClick = { onNavigateToProblemDetail(problem.id) }
)
}
}
@@ -904,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 }
@@ -1135,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 = {
@@ -1208,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 = {
@@ -1456,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(),
@@ -1511,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

@@ -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>