Compare commits

...

12 Commits
0.4.1 ... 1.0.1

20 changed files with 644 additions and 187 deletions

View File

@@ -817,6 +817,19 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2424" /> <option name="screenY" value="2424" />
</PersistentDeviceSelectionData> </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> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <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"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <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> </project>

View File

@@ -8,14 +8,14 @@ plugins {
android { android {
namespace = "com.atridad.openclimb" namespace = "com.atridad.openclimb"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 33
targetSdk = 35 targetSdk = 36
versionCode = 8 versionCode = 15
versionName = "0.4.1" versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -30,12 +30,19 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "17"
} }
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures { buildFeatures {
compose = true compose = true
} }
@@ -74,13 +81,16 @@ dependencies {
// Image Loading // Image Loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Charts - Placeholder for future implementation
// Charts will be implemented with a stable library in future versions
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) 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(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) 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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -27,7 +28,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb"> android:theme="@style/Theme.OpenClimb.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

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

View File

@@ -7,6 +7,7 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R import com.atridad.openclimb.R
@@ -21,8 +22,11 @@ class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null private var notificationJob: Job? = null
private var monitoringJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
private lateinit var repository: ClimbRepository private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
companion object { companion object {
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
@@ -51,8 +55,10 @@ class SessionTrackingService : Service() {
val database = OpenClimbDatabase.getDatabase(this) val database = OpenClimbDatabase.getDatabase(this)
repository = ClimbRepository(database, this) repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel() createNotificationChannel()
acquireWakeLock()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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 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 override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) { private fun startSessionTracking(sessionId: String) {
// Cancel any existing jobs
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel()
// Start the main notification update job
notificationJob = serviceScope.launch { notificationJob = serviceScope.launch {
try {
// Initial notification update // Initial notification update
updateNotification(sessionId) updateNotification(sessionId)
// Then update every second // Update every 2 seconds for better performance
while (isActive) { while (isActive) {
delay(1000L) delay(2000L)
updateNotification(sessionId) 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() { private fun stopSessionTracking() {
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel()
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() 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) { private suspend fun updateNotification(sessionId: String) {
try { try {
val session = repository.getSessionById(sessionId) val session = repository.getSessionById(sessionId)
@@ -141,7 +204,10 @@ class SessionTrackingService : Service() {
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts") .setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent()) .setContentIntent(createOpenAppIntent())
.addAction( .addAction(
R.drawable.ic_mountains, R.drawable.ic_mountains,
@@ -155,20 +221,31 @@ class SessionTrackingService : Service() {
) )
.build() .build()
// Force update the notification every second // Always start foreground to ensure service stays alive
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager startForeground(NOTIFICATION_ID, notification)
// Also notify separately to ensure it's visible
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
startForeground(NOTIFICATION_ID, notification) } catch (e: Exception) {
} catch (_: Exception) { e.printStackTrace()
// Handle errors gracefully // 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() stopSessionTracking()
} }
} }
}
private fun createOpenAppIntent(): PendingIntent { private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply { val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
} }
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
@@ -196,15 +273,46 @@ class SessionTrackingService : Service() {
).apply { ).apply {
description = "Shows active climbing session information" description = "Shows active climbing session information"
setShowBadge(false) setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
} }
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel()
releaseWakeLock()
serviceScope.cancel() serviceScope.cancel()
} }
} }

View File

@@ -1,8 +1,13 @@
package com.atridad.openclimb.ui 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.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier 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.data.repository.ClimbRepository
import com.atridad.openclimb.navigation.Screen import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.* 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.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -34,6 +42,40 @@ fun OpenClimbApp() {
factory = ClimbViewModelFactory(repository) 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 // FAB configuration
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
@@ -67,15 +109,21 @@ fun OpenClimbApp() {
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) { fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.PlayArrow,
contentDescription = "Start Session", contentDescription = "Start Session",
onClick = { onClick = {
// Check notification permission before starting session
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) { if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
}
) )
} else { } else {
null null
@@ -151,7 +199,10 @@ fun OpenClimbApp() {
SessionDetailScreen( SessionDetailScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
) )
} }
@@ -177,6 +228,12 @@ fun OpenClimbApp() {
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId -> onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -13,9 +12,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym 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.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlinx.coroutines.delay
@Composable @Composable
fun ActiveSessionBanner( fun ActiveSessionBanner(
@@ -95,7 +95,7 @@ fun ActiveSessionBanner(
) )
) { ) {
Icon( Icon(
Icons.Default.Close, imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session" 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.data.model.*
import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplaySection import com.atridad.openclimb.ui.components.ImageDisplaySection
import com.atridad.openclimb.ui.theme.CustomIcons
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -209,7 +210,12 @@ fun EditAttemptDialog(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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 context = LocalContext.current
val attempts by viewModel.getAttemptsBySession(sessionId).collectAsState(initial = emptyList()) val attempts by viewModel.getAttemptsBySession(sessionId).collectAsState(initial = emptyList())
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
@@ -283,10 +289,25 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
} }
} }
// 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 }) { IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete") Icon(Icons.Default.Delete, contentDescription = "Delete")
} }
} }
}
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -410,74 +431,50 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
value = "${((successfulAttempts.size.toDouble() / attempts.size) * 100).toInt()}%" value = "${((successfulAttempts.size.toDouble() / attempts.size) * 100).toInt()}%"
) )
// Show average grade if available
val attemptedProblems = problems.filter { it.id in uniqueProblems }
if (attemptedProblems.isNotEmpty()) {
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val averageGrade = when {
boulderProblems.isNotEmpty() && ropeProblems.isNotEmpty() -> {
val boulderAvg = calculateAverageGrade(boulderProblems)
val ropeAvg = calculateAverageGrade(ropeProblems)
"${boulderAvg ?: "N/A"} / ${ropeAvg ?: "N/A"}"
}
boulderProblems.isNotEmpty() -> calculateAverageGrade(boulderProblems) ?: "N/A"
ropeProblems.isNotEmpty() -> calculateAverageGrade(ropeProblems) ?: "N/A"
else -> "N/A"
} }
StatItem( // Show grade range(s) with better layout
label = "Average Grade",
value = averageGrade
)
} else {
StatItem(
label = "Average Grade",
value = "N/A"
)
}
}
// Show grade range if available
val grades = attemptedProblems.map { it.difficulty } val grades = attemptedProblems.map { it.difficulty }
if (grades.isNotEmpty()) { if (grades.isNotEmpty()) {
// Separate boulder and rope problems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val gradeRange = when {
boulderProblems.isNotEmpty() && ropeProblems.isNotEmpty() -> {
val boulderRange = if (boulderProblems.isNotEmpty()) { val boulderRange = if (boulderProblems.isNotEmpty()) {
val boulderGrades = boulderProblems.map { it.difficulty } val boulderGrades = boulderProblems.map { it.difficulty }
val sortedBoulderGrades = boulderGrades.sortedWith { a, b -> a.compareTo(b) } val sorted = boulderGrades.sortedWith { a, b -> a.compareTo(b) }
"${sortedBoulderGrades.first().grade} - ${sortedBoulderGrades.last().grade}" "${sorted.first().grade} - ${sorted.last().grade}"
} else null } else null
val ropeRange = if (ropeProblems.isNotEmpty()) { val ropeRange = if (ropeProblems.isNotEmpty()) {
val ropeGrades = ropeProblems.map { it.difficulty } val ropeGrades = ropeProblems.map { it.difficulty }
val sortedRopeGrades = ropeGrades.sortedWith { a, b -> a.compareTo(b) } val sorted = ropeGrades.sortedWith { a, b -> a.compareTo(b) }
"${sortedRopeGrades.first().grade} - ${sortedRopeGrades.last().grade}" "${sorted.first().grade} - ${sorted.last().grade}"
} else null } else null
when { if (boulderRange != null && ropeRange != null) {
boulderRange != null && ropeRange != null -> "$boulderRange / $ropeRange" Row(
boulderRange != null -> boulderRange modifier = Modifier.fillMaxWidth(),
ropeRange != null -> ropeRange horizontalArrangement = Arrangement.SpaceEvenly
else -> "N/A" ) {
StatItem(label = "Boulder Range", value = boulderRange)
StatItem(label = "Rope Range", value = ropeRange)
} }
} } else {
else -> { Row(
val sortedGrades = grades.sortedWith { a, b -> a.compareTo(b) } modifier = Modifier.fillMaxWidth(),
"${sortedGrades.first().grade} - ${sortedGrades.last().grade}" horizontalArrangement = Arrangement.Center
} ) {
}
StatItem( StatItem(
label = "Grade Range", label = "Grade Range",
value = gradeRange value = boulderRange ?: ropeRange ?: "N/A"
) )
}
}
} else { } else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
StatItem( StatItem(
label = "Grade Range", label = "Grade Range",
value = "N/A" value = "N/A"
@@ -487,6 +484,7 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
} }
} }
} }
}
// Attempts List // Attempts List
item { item {
@@ -527,7 +525,8 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit }, onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit },
onDeleteAttempt = { attemptToDelete -> onDeleteAttempt = { attemptToDelete ->
viewModel.deleteAttempt(attemptToDelete) viewModel.deleteAttempt(attemptToDelete)
} },
onAttemptClick = { onNavigateToProblemDetail(problem.id) }
) )
} }
} }
@@ -911,7 +910,9 @@ fun GymDetailScreen(
gymId: String, gymId: String,
viewModel: ClimbViewModel, viewModel: ClimbViewModel,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit onNavigateToEdit: (String) -> Unit,
onNavigateToSessionDetail: (String) -> Unit = {},
onNavigateToProblemDetail: (String) -> Unit = {}
) { ) {
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val gym = gyms.find { it.id == gymId } val gym = gyms.find { it.id == gymId }
@@ -1142,14 +1143,16 @@ fun GymDetailScreen(
Card( Card(
modifier = modifier =
Modifier.fillMaxWidth().padding(vertical = 4.dp), Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onNavigateToProblemDetail(problem.id) },
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor = MaterialTheme.colorScheme.surface
MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f)
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -1215,14 +1218,16 @@ fun GymDetailScreen(
Card( Card(
modifier = modifier =
Modifier.fillMaxWidth().padding(vertical = 4.dp), Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onNavigateToSessionDetail(session.id) },
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor = MaterialTheme.colorScheme.surface
MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f)
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -1463,11 +1468,17 @@ fun SessionAttemptCard(
attempt: Attempt, attempt: Attempt,
problem: Problem, problem: Problem,
onEditAttempt: (Attempt) -> Unit = {}, onEditAttempt: (Attempt) -> Unit = {},
onDeleteAttempt: (Attempt) -> Unit = {} onDeleteAttempt: (Attempt) -> Unit = {},
onAttemptClick: () -> Unit = {}
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } 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)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -1518,11 +1529,7 @@ fun SessionAttemptCard(
// Delete button // Delete button
IconButton( IconButton(
onClick = { showDeleteDialog = true }, onClick = { showDeleteDialog = true },
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp)
colors =
IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) { ) {
Icon( Icon(
Icons.Default.Delete, 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 // 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 {
// 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() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -170,6 +178,14 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) { fun endSession(context: Context, sessionId: String) {
viewModelScope.launch { 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) val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) { if (session != null && session.status == SessionStatus.ACTIVE) {
val completedSession = with(ClimbSession) { session.complete() } 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 // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt) {
viewModelScope.launch { viewModelScope.launch {

View File

@@ -35,18 +35,11 @@ object ImageUtils {
*/ */
fun saveImageFromUri(context: Context, imageUri: Uri): String? { fun saveImageFromUri(context: Context, imageUri: Uri): String? {
return try { return try {
val inputStream = context.contentResolver.openInputStream(imageUri) // Decode bitmap from a fresh stream to avoid mark/reset dependency
inputStream?.use { input -> val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
// Decode with options to get EXIF data BitmapFactory.decodeStream(input)
val options = BitmapFactory.Options().apply { } ?: return null
inJustDecodeBounds = true
}
input.reset()
BitmapFactory.decodeStream(input, null, options)
// Reset stream and decode with proper orientation
input.reset()
val originalBitmap = BitmapFactory.decodeStream(input)
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap) val compressedBitmap = compressImage(orientedBitmap)
@@ -68,7 +61,6 @@ object ImageUtils {
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null

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

@@ -172,8 +172,8 @@ object SessionShareUtils {
stats: SessionStats stats: SessionStats
): File? { ): File? {
return try { return try {
val width = 1080 val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1350 val height = 1656
val bitmap = createBitmap(width, height) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
@@ -227,7 +227,7 @@ object SessionShareUtils {
} }
// Draw main card background // Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 100f) val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint) canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content // Draw content
@@ -248,31 +248,48 @@ object SessionShareUtils {
// Stats grid // Stats grid
val statsStartY = yPosition val statsStartY = yPosition
val columnWidth = width / 2f val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats // Left column stats
var leftY = statsStartY var leftY = statsStartY
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 140f leftY += 120f
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 140f leftY += 120f
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
// Right column stats // Right column stats
var rightY = statsStartY var rightY = statsStartY
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 140f rightY += 120f
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 140f rightY += 120f
var rightYAfter = rightY
stats.topGrade?.let { grade -> stats.topGrade?.let { grade ->
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth)
rightYAfter += 120f
}
// Grade range(s)
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER })
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
} else if (boulderRange != null || ropeRange != null) {
// Single centered item
val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
} }
// Success rate arc // Success rate arc
if (stats.totalAttempts > 0) { val successRate = if (stats.totalAttempts > 0) {
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100 (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint) } else 0f
} drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint)
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint = Paint().apply {
@@ -320,6 +337,41 @@ object SessionShareUtils {
canvas.drawText(label, x, y + 50f, labelPaint) canvas.drawText(label, x, y + 50f, labelPaint)
} }
/**
* Draws a stat item while fitting the value text to a max width by reducing text size if needed.
*/
private fun drawStatItemFitting(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint,
maxTextWidth: Float
) {
val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize
var textWidth = tempPaint.measureText(value)
while (textWidth > maxTextWidth && textSize > 36f) {
textSize -= 2f
tempPaint.textSize = textSize
textWidth = tempPaint.measureText(value)
}
canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/
private fun gradeRangeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
val grades = problems.map { it.difficulty }
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}"
}
private fun drawSuccessRateArc( private fun drawSuccessRateArc(
canvas: Canvas, canvas: Canvas,
centerX: Float, centerX: Float,
@@ -328,12 +380,12 @@ object SessionShareUtils {
labelPaint: Paint, labelPaint: Paint,
valuePaint: Paint valuePaint: Paint
) { ) {
val radius = 80f val radius = 70f
val strokeWidth = 16f val strokeWidth = 14f
// Background arc // Background arc
val bgPaint = Paint().apply { val bgPaint = Paint().apply {
color = "#40FFFFFF".toColorInt() color = "#30FFFFFF".toColorInt()
style = Paint.Style.STROKE style = Paint.Style.STROKE
this.strokeWidth = strokeWidth this.strokeWidth = strokeWidth
isAntiAlias = true isAntiAlias = true
@@ -360,8 +412,11 @@ object SessionShareUtils {
// Draw percentage text // Draw percentage text
val percentText = "${successRate.roundToInt()}%" val percentText = "${successRate.roundToInt()}%"
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint) canvas.drawText(percentText, centerX, centerY + 8f, valuePaint)
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
// Draw label below the arc (outside the ring) for better readability
val belowLabelPaint = Paint(labelPaint)
canvas.drawText("Success Rate", centerX, centerY + radius + 36f, belowLabelPaint)
} }
private fun formatSessionDate(dateString: String): String { private fun formatSessionDate(dateString: String): String {

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="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color>
</resources> </resources>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" /> <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> </resources>

View File

@@ -1,10 +1,14 @@
[versions] [versions]
agp = "8.9.1" agp = "8.12.1"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.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" lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.09.00" 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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 # Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } 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 # Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } 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 #Fri Aug 15 11:23:25 MDT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists