diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
index 528f19e..17b82fc 100644
--- a/.idea/caches/deviceStreaming.xml
+++ b/.idea/caches/deviceStreaming.xml
@@ -51,6 +51,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index e0d73ba..f185138 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,10 +4,10 @@
-
+
-
+
diff --git a/README.md b/README.md
index 2ff115a..d8d647a 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
You have two options:
-1. Download the latest APK from the Released page
-2. Use Obtainium
+1. Download the latest APK from the Releases page
+2. [
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
## Requirements
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fa9ca4a..567f39b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb"
minSdk = 34
targetSdk = 36
- versionCode = 20
- versionName = "1.3.1"
+ versionCode = 21
+ versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d14e7ac..5af5fc4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,15 +7,15 @@
-
+
-
+
-
+
+
+
-
+
-
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/atridad/openclimb/MainActivity.kt b/app/src/main/java/com/atridad/openclimb/MainActivity.kt
index ec97980..827ea59 100644
--- a/app/src/main/java/com/atridad/openclimb/MainActivity.kt
+++ b/app/src/main/java/com/atridad/openclimb/MainActivity.kt
@@ -1,28 +1,40 @@
package com.atridad.openclimb
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
+import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() {
+ private var shortcutAction by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge()
+
+ shortcutAction = intent?.action
+
setContent {
OpenClimbTheme {
- Surface(
- modifier = Modifier.fillMaxSize()
- ) {
- OpenClimbApp()
+ Surface(modifier = Modifier.fillMaxSize()) {
+ OpenClimbApp(shortcutAction = shortcutAction)
}
}
}
}
-}
\ No newline at end of file
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+
+ shortcutAction = intent.action
+ }
+}
diff --git a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
index cc830fe..aae5cf1 100644
--- a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
+++ b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
@@ -1,7 +1,5 @@
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
@@ -25,260 +23,293 @@ 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.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun OpenClimbApp() {
+fun OpenClimbApp(shortcutAction: String? = null) {
val navController = rememberNavController()
val context = LocalContext.current
-
+
val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) }
- val viewModel: ClimbViewModel = viewModel(
- factory = ClimbViewModelFactory(repository)
- )
-
+ val viewModel: ClimbViewModel = viewModel(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
+ val permissionLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (!isGranted) {
+ showNotificationPermissionDialog = false
+ }
+ }
+
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
-
+
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
- !NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
+ !NotificationPermissionUtils.isNotificationPermissionGranted(context)
+ ) {
showNotificationPermissionDialog = true
}
}
}
-
- // Ensure session tracking service is running when app resumes
- LaunchedEffect(Unit) {
- viewModel.ensureSessionTrackingServiceRunning(context)
+
+ LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
+
+ val activeSession by viewModel.activeSession.collectAsState()
+ val gyms by viewModel.gyms.collectAsState()
+
+ LaunchedEffect(activeSession, gyms) {
+ AppShortcutManager.updateShortcuts(
+ context = context,
+ hasActiveSession = activeSession != null,
+ hasGyms = gyms.isNotEmpty()
+ )
}
-
- // FAB configuration
+
+ LaunchedEffect(shortcutAction) {
+ when (shortcutAction) {
+ AppShortcutManager.ACTION_START_SESSION -> {
+ navController.navigate(Screen.Sessions) {
+ popUpTo(0) { inclusive = true }
+ launchSingleTop = true
+ }
+
+ if (activeSession == null && gyms.isNotEmpty()) {
+ if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
+ !NotificationPermissionUtils.isNotificationPermissionGranted(
+ context
+ )
+ ) {
+ showNotificationPermissionDialog = true
+ } else {
+ if (gyms.size == 1) {
+ viewModel.startSession(context, gyms.first().id)
+ } else {
+ navController.navigate(Screen.AddEditSession())
+ }
+ }
+ }
+ }
+ AppShortcutManager.ACTION_END_SESSION -> {
+ navController.navigate(Screen.Sessions) {
+ popUpTo(0) { inclusive = true }
+ launchSingleTop = true
+ }
+
+ activeSession?.let { session -> viewModel.endSession(context, session.id) }
+ }
+ }
+ }
+
var fabConfig by remember { mutableStateOf(null) }
Scaffold(
- bottomBar = {
- OpenClimbBottomNavigation(navController = navController)
- },
- floatingActionButton = {
- fabConfig?.let { config ->
- FloatingActionButton(
- onClick = config.onClick,
- containerColor = MaterialTheme.colorScheme.primary
- ) {
- Icon(
- imageVector = config.icon,
- contentDescription = config.contentDescription
- )
+ bottomBar = { OpenClimbBottomNavigation(navController = navController) },
+ floatingActionButton = {
+ fabConfig?.let { config ->
+ FloatingActionButton(
+ onClick = config.onClick,
+ containerColor = MaterialTheme.colorScheme.primary
+ ) {
+ Icon(
+ imageVector = config.icon,
+ contentDescription = config.contentDescription
+ )
+ }
}
}
- }
) { innerPadding ->
NavHost(
- navController = navController,
- startDestination = Screen.Sessions,
- modifier = Modifier.padding(innerPadding)
+ navController = navController,
+ startDestination = Screen.Sessions,
+ modifier = Modifier.padding(innerPadding)
) {
- // Main screens
composable {
- val gyms by viewModel.gyms.collectAsState()
- val activeSession by viewModel.activeSession.collectAsState()
LaunchedEffect(gyms, activeSession) {
- fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
- FabConfig(
- icon = Icons.Default.PlayArrow,
- contentDescription = "Start Session",
- onClick = {
- // Check notification permission before starting session
- if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
- !NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
- showNotificationPermissionDialog = true
- } else {
- if (gyms.size == 1) {
- viewModel.startSession(context, gyms.first().id)
- } else {
- navController.navigate(Screen.AddEditSession())
- }
- }
+ fabConfig =
+ if (gyms.isNotEmpty() && activeSession == null) {
+ FabConfig(
+ icon = Icons.Default.PlayArrow,
+ contentDescription = "Start Session",
+ onClick = {
+ if (NotificationPermissionUtils
+ .shouldRequestNotificationPermission() &&
+ !NotificationPermissionUtils
+ .isNotificationPermissionGranted(
+ context
+ )
+ ) {
+ showNotificationPermissionDialog = true
+ } else {
+ if (gyms.size == 1) {
+ viewModel.startSession(context, gyms.first().id)
+ } else {
+ navController.navigate(Screen.AddEditSession())
+ }
+ }
+ }
+ )
+ } else {
+ null
}
- )
- } else {
- null
- }
}
SessionsScreen(
- viewModel = viewModel,
- onNavigateToSessionDetail = { sessionId ->
- navController.navigate(Screen.SessionDetail(sessionId))
- }
+ viewModel = viewModel,
+ onNavigateToSessionDetail = { sessionId ->
+ navController.navigate(Screen.SessionDetail(sessionId))
+ }
)
}
-
+
composable {
val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(gyms) {
- fabConfig = if (gyms.isNotEmpty()) {
- FabConfig(
- icon = Icons.Default.Add,
- contentDescription = "Add Problem",
- onClick = {
- navController.navigate(Screen.AddEditProblem())
+ fabConfig =
+ if (gyms.isNotEmpty()) {
+ FabConfig(
+ icon = Icons.Default.Add,
+ contentDescription = "Add Problem",
+ onClick = {
+ navController.navigate(Screen.AddEditProblem())
+ }
+ )
+ } else {
+ null
}
- )
- } else {
- null
- }
}
ProblemsScreen(
- viewModel = viewModel,
- onNavigateToProblemDetail = { problemId ->
- navController.navigate(Screen.ProblemDetail(problemId))
- }
+ viewModel = viewModel,
+ onNavigateToProblemDetail = { problemId ->
+ navController.navigate(Screen.ProblemDetail(problemId))
+ }
)
}
-
+
composable {
- LaunchedEffect(Unit) {
- fabConfig = null // No FAB for analytics
- }
+ LaunchedEffect(Unit) { fabConfig = null }
AnalyticsScreen(viewModel = viewModel)
}
-
+
composable {
LaunchedEffect(Unit) {
- fabConfig = FabConfig(
- icon = Icons.Default.Add,
- contentDescription = "Add Gym",
- onClick = {
- navController.navigate(Screen.AddEditGym())
- }
- )
+ fabConfig =
+ FabConfig(
+ icon = Icons.Default.Add,
+ contentDescription = "Add Gym",
+ onClick = { navController.navigate(Screen.AddEditGym()) }
+ )
}
GymsScreen(
- viewModel = viewModel,
- onNavigateToGymDetail = { gymId ->
- navController.navigate(Screen.GymDetail(gymId))
- }
+ viewModel = viewModel,
+ onNavigateToGymDetail = { gymId ->
+ navController.navigate(Screen.GymDetail(gymId))
+ }
)
}
-
+
composable {
- LaunchedEffect(Unit) {
- fabConfig = null // No FAB for settings
- }
+ LaunchedEffect(Unit) { fabConfig = null }
SettingsScreen(viewModel = viewModel)
}
-
- // Detail screens
+
composable { backStackEntry ->
val args = backStackEntry.toRoute()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen(
- sessionId = args.sessionId,
- viewModel = viewModel,
- onNavigateBack = { navController.popBackStack() },
- onNavigateToProblemDetail = { problemId ->
- navController.navigate(Screen.ProblemDetail(problemId))
- }
+ sessionId = args.sessionId,
+ viewModel = viewModel,
+ onNavigateBack = { navController.popBackStack() },
+ onNavigateToProblemDetail = { problemId ->
+ navController.navigate(Screen.ProblemDetail(problemId))
+ }
)
}
-
+
composable { backStackEntry ->
val args = backStackEntry.toRoute()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen(
- problemId = args.problemId,
- viewModel = viewModel,
- onNavigateBack = { navController.popBackStack() },
- onNavigateToEdit = { problemId ->
- navController.navigate(Screen.AddEditProblem(problemId = problemId))
- }
+ problemId = args.problemId,
+ viewModel = viewModel,
+ onNavigateBack = { navController.popBackStack() },
+ onNavigateToEdit = { problemId ->
+ navController.navigate(Screen.AddEditProblem(problemId = problemId))
+ }
)
}
-
+
composable { backStackEntry ->
val args = backStackEntry.toRoute()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen(
- gymId = args.gymId,
- viewModel = viewModel,
- 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))
- }
+ gymId = args.gymId,
+ viewModel = viewModel,
+ 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))
+ }
)
}
-
composable { backStackEntry ->
val args = backStackEntry.toRoute()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
- gymId = args.gymId,
- viewModel = viewModel,
- onNavigateBack = { navController.popBackStack() }
+ gymId = args.gymId,
+ viewModel = viewModel,
+ onNavigateBack = { navController.popBackStack() }
)
}
-
+
composable { backStackEntry ->
val args = backStackEntry.toRoute()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen(
- problemId = args.problemId,
- gymId = args.gymId,
- viewModel = viewModel,
- onNavigateBack = { navController.popBackStack() }
+ problemId = args.problemId,
+ gymId = args.gymId,
+ viewModel = viewModel,
+ onNavigateBack = { navController.popBackStack() }
)
}
-
+
composable { backStackEntry ->
val args = backStackEntry.toRoute()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen(
- sessionId = args.sessionId,
- gymId = args.gymId,
- viewModel = viewModel,
- onNavigateBack = { navController.popBackStack() }
+ sessionId = args.sessionId,
+ gymId = args.gymId,
+ viewModel = viewModel,
+ onNavigateBack = { navController.popBackStack() }
)
}
}
-
+
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
- onDismiss = { showNotificationPermissionDialog = false },
- onRequestPermission = {
- permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString())
- }
+ onDismiss = { showNotificationPermissionDialog = false },
+ onRequestPermission = {
+ permissionLauncher.launch(
+ NotificationPermissionUtils.getNotificationPermissionString()
+ )
+ }
)
}
}
@@ -288,44 +319,41 @@ fun OpenClimbApp() {
fun OpenClimbBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
-
+
NavigationBar {
bottomNavigationItems.forEach { item ->
- val isSelected = when (item.screen) {
- is Screen.Sessions -> currentRoute?.contains("Session") == true
- is Screen.Problems -> currentRoute?.contains("Problem") == true
- is Screen.Gyms -> currentRoute?.contains("Gym") == true
- is Screen.Analytics -> currentRoute?.contains("Analytics") == true
- is Screen.Settings -> currentRoute?.contains("Settings") == true
- else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
- }
-
- NavigationBarItem(
- icon = { Icon(item.icon, contentDescription = item.label) },
- label = { Text(item.label) },
- selected = isSelected,
- onClick = {
- navController.navigate(item.screen) {
- // Clear the entire back stack and go to the selected tab's root screen
- popUpTo(0) {
- inclusive = true
- }
- // Avoid multiple copies of the same destination when
- // reselecting the same item
- launchSingleTop = true
- // Don't restore state - always start fresh when switching tabs
- restoreState = false
+ val isSelected =
+ when (item.screen) {
+ is Screen.Sessions -> currentRoute?.contains("Session") == true
+ is Screen.Problems -> currentRoute?.contains("Problem") == true
+ is Screen.Gyms -> currentRoute?.contains("Gym") == true
+ is Screen.Analytics -> currentRoute?.contains("Analytics") == true
+ is Screen.Settings -> currentRoute?.contains("Settings") == true
+ else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
+ }
+
+ NavigationBarItem(
+ icon = { Icon(item.icon, contentDescription = item.label) },
+ label = { Text(item.label) },
+ selected = isSelected,
+ onClick = {
+ navController.navigate(item.screen) {
+ // Clear the entire back stack and go to the selected tab's root screen
+ popUpTo(0) { inclusive = true }
+ // Avoid multiple copies of the same destination when
+ // reselecting the same item
+ launchSingleTop = true
+ // Don't restore state - always start fresh when switching tabs
+ restoreState = false
+ }
}
- }
)
}
}
}
data class FabConfig(
- val icon: androidx.compose.ui.graphics.vector.ImageVector,
- val contentDescription: String,
- val onClick: () -> Unit
+ val icon: androidx.compose.ui.graphics.vector.ImageVector,
+ val contentDescription: String,
+ val onClick: () -> Unit
)
-
-
diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
index 046ad87..6a55597 100644
--- a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
+++ b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
@@ -8,346 +8,413 @@ import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils
+import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
+import java.io.File
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import java.io.File
-class ClimbViewModel(
- private val repository: ClimbRepository
-) : ViewModel() {
-
+class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
+
// UI State flows
private val _uiState = MutableStateFlow(ClimbUiState())
val uiState: StateFlow = _uiState.asStateFlow()
-
- // Data flows
- val gyms = repository.getAllGyms().stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(),
- initialValue = emptyList()
- )
-
- val problems = repository.getAllProblems().stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(),
- initialValue = emptyList()
- )
-
- val sessions = repository.getAllSessions().stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(),
- initialValue = emptyList()
- )
-
- val activeSession = repository.getActiveSessionFlow().stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(),
- initialValue = null
- )
-
- val attempts = repository.getAllAttempts().stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(),
- initialValue = emptyList()
- )
-
-
+ // Data flows
+ val gyms =
+ repository
+ .getAllGyms()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = emptyList()
+ )
+
+ val problems =
+ repository
+ .getAllProblems()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = emptyList()
+ )
+
+ val sessions =
+ repository
+ .getAllSessions()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = emptyList()
+ )
+
+ val activeSession =
+ repository
+ .getActiveSessionFlow()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = null
+ )
+
+ val attempts =
+ repository
+ .getAllAttempts()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = emptyList()
+ )
+
// Gym operations
fun addGym(gym: Gym) {
+ viewModelScope.launch { repository.insertGym(gym) }
+ }
+
+ fun addGym(gym: Gym, context: Context) {
viewModelScope.launch {
repository.insertGym(gym)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun updateGym(gym: Gym) {
+ viewModelScope.launch { repository.updateGym(gym) }
+ }
+
+ fun updateGym(gym: Gym, context: Context) {
viewModelScope.launch {
repository.updateGym(gym)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun deleteGym(gym: Gym) {
+ viewModelScope.launch { repository.deleteGym(gym) }
+ }
+
+ fun deleteGym(gym: Gym, context: Context) {
viewModelScope.launch {
repository.deleteGym(gym)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
- fun getGymById(id: String): Flow = flow {
- emit(repository.getGymById(id))
- }
-
+
+ fun getGymById(id: String): Flow = flow { emit(repository.getGymById(id)) }
+
// Problem operations
fun addProblem(problem: Problem) {
+ viewModelScope.launch { repository.insertProblem(problem) }
+ }
+
+ fun addProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.insertProblem(problem)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun updateProblem(problem: Problem) {
+ viewModelScope.launch { repository.updateProblem(problem) }
+ }
+
+ fun updateProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.updateProblem(problem)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun deleteProblem(problem: Problem, context: Context) {
viewModelScope.launch {
// Delete associated images
- problem.imagePaths.forEach { imagePath ->
- ImageUtils.deleteImage(context, imagePath)
- }
-
+ problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
+
repository.deleteProblem(problem)
-
- // Clean up any remaining orphaned images
+
cleanupOrphanedImages(context)
+
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
private suspend fun cleanupOrphanedImages(context: Context) {
val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
}
-
- fun getProblemById(id: String): Flow = flow {
- emit(repository.getProblemById(id))
- }
-
- fun getProblemsByGym(gymId: String): Flow> =
- repository.getProblemsByGym(gymId)
-
+
+ fun getProblemById(id: String): Flow = flow { emit(repository.getProblemById(id)) }
+
+ fun getProblemsByGym(gymId: String): Flow> = repository.getProblemsByGym(gymId)
+
// Session operations
fun addSession(session: ClimbSession) {
+ viewModelScope.launch { repository.insertSession(session) }
+ }
+
+ fun addSession(session: ClimbSession, context: Context) {
viewModelScope.launch {
repository.insertSession(session)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun updateSession(session: ClimbSession) {
+ viewModelScope.launch { repository.updateSession(session) }
+ }
+
+ fun updateSession(session: ClimbSession, context: Context) {
viewModelScope.launch {
repository.updateSession(session)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun deleteSession(session: ClimbSession) {
+ viewModelScope.launch { repository.deleteSession(session) }
+ }
+
+ fun deleteSession(session: ClimbSession, context: Context) {
viewModelScope.launch {
repository.deleteSession(session)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun getSessionById(id: String): Flow = flow {
emit(repository.getSessionById(id))
}
-
- fun getSessionsByGym(gymId: String): Flow> =
- repository.getSessionsByGym(gymId)
-
+
+ fun getSessionsByGym(gymId: String): Flow> =
+ repository.getSessionsByGym(gymId)
+
// 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."
- )
+ 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(
- error = "There's already an active session. Please end it first."
- )
+ _uiState.value =
+ _uiState.value.copy(
+ error = "There's already an active session. Please end it first."
+ )
return@launch
}
-
+
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession)
-
+
// Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent)
-
- _uiState.value = _uiState.value.copy(
- message = "Session started successfully!"
- )
+
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
+
+ _uiState.value = _uiState.value.copy(message = "Session started successfully!")
}
}
-
+
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."
- )
+ 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() }
repository.updateSession(completedSession)
-
- // Stop the tracking service, passing the session id so service can finalize if needed
+
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent)
-
- _uiState.value = _uiState.value.copy(
- message = "Session completed!"
- )
+
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
+
+ _uiState.value = _uiState.value.copy(message = "Session completed!")
}
}
}
-
- /**
- * 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)
+ val serviceIntent =
+ SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent)
}
}
}
-
+
// Attempt operations
fun addAttempt(attempt: Attempt) {
+ viewModelScope.launch { repository.insertAttempt(attempt) }
+ }
+
+ fun addAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch {
repository.insertAttempt(attempt)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun deleteAttempt(attempt: Attempt) {
+ viewModelScope.launch { repository.deleteAttempt(attempt) }
+ }
+
+ fun deleteAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
+
fun updateAttempt(attempt: Attempt) {
+ viewModelScope.launch { repository.updateAttempt(attempt) }
+ }
+
+ fun updateAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch {
repository.updateAttempt(attempt)
+ ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
-
- fun getAttemptsBySession(sessionId: String): Flow> =
- repository.getAttemptsBySession(sessionId)
-
- fun getAttemptsByProblem(problemId: String): Flow> =
- repository.getAttemptsByProblem(problemId)
+ fun getAttemptsBySession(sessionId: String): Flow> =
+ repository.getAttemptsBySession(sessionId)
+ fun getAttemptsByProblem(problemId: String): Flow> =
+ repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
repository.exportAllDataToZipUri(context, uri)
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- message = "Data with images exported successfully"
- )
+ _uiState.value =
+ _uiState.value.copy(
+ isLoading = false,
+ message = "Data with images exported successfully"
+ )
} catch (e: Exception) {
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- error = "Export failed: ${e.message}"
- )
+ _uiState.value =
+ _uiState.value.copy(
+ isLoading = false,
+ error = "Export failed: ${e.message}"
+ )
}
}
}
-
+
fun importData(file: File) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
-
- // Only support ZIP format for reliability
+
if (!file.name.lowercase().endsWith(".zip")) {
- throw Exception("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
+ throw Exception(
+ "Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
+ )
}
-
+
repository.importDataFromZip(file)
-
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- message = "Data imported successfully from ${file.name}"
- )
+
+ _uiState.value =
+ _uiState.value.copy(
+ isLoading = false,
+ message = "Data imported successfully from ${file.name}"
+ )
} catch (e: Exception) {
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- error = "Import failed: ${e.message}"
- )
+ _uiState.value =
+ _uiState.value.copy(
+ isLoading = false,
+ error = "Import failed: ${e.message}"
+ )
}
}
}
-
+
// UI state operations
fun clearMessage() {
_uiState.value = _uiState.value.copy(message = null)
}
-
+
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
-
+
fun setError(message: String) {
_uiState.value = _uiState.value.copy(error = message)
}
-
+
fun resetAllData() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
-
+
repository.resetAllData()
-
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- message = "All data has been reset successfully"
- )
+
+ _uiState.value =
+ _uiState.value.copy(
+ isLoading = false,
+ message = "All data has been reset successfully"
+ )
} catch (e: Exception) {
- _uiState.value = _uiState.value.copy(
- isLoading = false,
- error = "Reset failed: ${e.message}"
- )
+ _uiState.value =
+ _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
}
}
}
-
+
// Share operations
- suspend fun generateSessionShareCard(
- context: Context,
- sessionId: String
- ): File? = withContext(Dispatchers.IO) {
- try {
- val session = repository.getSessionById(sessionId) ?: return@withContext null
- val attempts = repository.getAttemptsBySession(sessionId).first()
- val problems = repository.getAllProblems().first().filter { problem ->
- attempts.any { it.problemId == problem.id }
+ suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
+ withContext(Dispatchers.IO) {
+ try {
+ val session = repository.getSessionById(sessionId) ?: return@withContext null
+ val attempts = repository.getAttemptsBySession(sessionId).first()
+ val problems =
+ repository.getAllProblems().first().filter { problem ->
+ attempts.any { it.problemId == problem.id }
+ }
+ val gym = repository.getGymById(session.gymId) ?: return@withContext null
+
+ val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
+ SessionShareUtils.generateShareCard(context, session, gym, stats)
+ } catch (e: Exception) {
+ _uiState.value =
+ _uiState.value.copy(
+ error = "Failed to generate share card: ${e.message}"
+ )
+ null
+ }
}
- val gym = repository.getGymById(session.gymId) ?: return@withContext null
-
- val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
- SessionShareUtils.generateShareCard(context, session, gym, stats)
- } catch (e: Exception) {
- _uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}")
- null
- }
- }
-
+
fun shareSessionCard(context: Context, imageFile: File) {
SessionShareUtils.shareSessionCard(context, imageFile)
}
}
data class ClimbUiState(
- val isLoading: Boolean = false,
- val message: String? = null,
- val error: String? = null
+ val isLoading: Boolean = false,
+ val message: String? = null,
+ val error: String? = null
)
diff --git a/app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt b/app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
new file mode 100644
index 0000000..e9cf3ed
--- /dev/null
+++ b/app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
@@ -0,0 +1,87 @@
+package com.atridad.openclimb.utils
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import android.graphics.drawable.Icon
+import android.os.Build
+import androidx.annotation.RequiresApi
+import com.atridad.openclimb.MainActivity
+import com.atridad.openclimb.R
+
+object AppShortcutManager {
+
+ const val SHORTCUT_START_SESSION = "start_session"
+ const val SHORTCUT_END_SESSION = "end_session"
+
+ const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION"
+ const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
+
+ /** Updates the app shortcuts based on current session state */
+ fun updateShortcuts(context: Context, hasActiveSession: Boolean, hasGyms: Boolean) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ val shortcutManager = context.getSystemService(ShortcutManager::class.java)
+
+ val shortcuts = mutableListOf()
+
+ if (hasActiveSession) {
+ // Show "End Session" shortcut when there's an active session
+ shortcuts.add(createEndSessionShortcut(context))
+ } else if (hasGyms) {
+ // Show "Start Session" shortcut when no active session but gyms exist
+ shortcuts.add(createStartSessionShortcut(context))
+ }
+
+ shortcutManager.dynamicShortcuts = shortcuts
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N_MR1)
+ private fun createStartSessionShortcut(context: Context): ShortcutInfo {
+ val startIntent =
+ Intent(context, MainActivity::class.java).apply {
+ action = ACTION_START_SESSION
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+
+ return ShortcutInfo.Builder(context, SHORTCUT_START_SESSION)
+ .setShortLabel(context.getString(R.string.shortcut_start_session_short))
+ .setLongLabel(context.getString(R.string.shortcut_start_session_long))
+ .setIcon(Icon.createWithResource(context, R.drawable.ic_play_arrow_24))
+ .setIntent(startIntent)
+ .build()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N_MR1)
+ private fun createEndSessionShortcut(context: Context): ShortcutInfo {
+ val endIntent =
+ Intent(context, MainActivity::class.java).apply {
+ action = ACTION_END_SESSION
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+
+ return ShortcutInfo.Builder(context, SHORTCUT_END_SESSION)
+ .setShortLabel(context.getString(R.string.shortcut_end_session_short))
+ .setLongLabel(context.getString(R.string.shortcut_end_session_long))
+ .setIcon(Icon.createWithResource(context, R.drawable.ic_stop_24))
+ .setIntent(endIntent)
+ .build()
+ }
+
+ /** Removes all dynamic shortcuts */
+ fun clearShortcuts(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ val shortcutManager = context.getSystemService(ShortcutManager::class.java)
+ shortcutManager.removeAllDynamicShortcuts()
+ }
+ }
+
+ /** Disables a specific shortcut and shows a disabled message */
+ fun disableShortcut(context: Context, shortcutId: String, disabledMessage: String) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ val shortcutManager = context.getSystemService(ShortcutManager::class.java)
+ shortcutManager.disableShortcuts(listOf(shortcutId), disabledMessage)
+ }
+ }
+}
diff --git a/app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt b/app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt
new file mode 100644
index 0000000..beee244
--- /dev/null
+++ b/app/src/main/java/com/atridad/openclimb/widget/ClimbStatsWidgetProvider.kt
@@ -0,0 +1,150 @@
+package com.atridad.openclimb.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+import com.atridad.openclimb.MainActivity
+import com.atridad.openclimb.R
+import com.atridad.openclimb.data.database.OpenClimbDatabase
+import com.atridad.openclimb.data.repository.ClimbRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+class ClimbStatsWidgetProvider : AppWidgetProvider() {
+
+ private val job = SupervisorJob()
+ private val coroutineScope = CoroutineScope(Dispatchers.IO + job)
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ for (appWidgetId in appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId)
+ }
+ }
+
+ override fun onEnabled(context: Context) {}
+
+ override fun onDisabled(context: Context) {
+ job.cancel()
+ }
+
+ private fun updateAppWidget(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int
+ ) {
+ coroutineScope.launch {
+ try {
+ val database = OpenClimbDatabase.getDatabase(context)
+ val repository = ClimbRepository(database, context)
+
+ // Fetch stats data
+ val sessions = repository.getAllSessions().first()
+ val problems = repository.getAllProblems().first()
+ val attempts = repository.getAllAttempts().first()
+ val gyms = repository.getAllGyms().first()
+ val activeSession = repository.getActiveSession()
+
+ // Calculate stats
+ val completedSessions = sessions.filter { it.endTime != null }
+
+ // Count problems that have been completed (have at least one successful attempt)
+ val completedProblems =
+ problems
+ .filter { problem ->
+ attempts.any { attempt ->
+ attempt.problemId == problem.id &&
+ (attempt.result ==
+ com.atridad.openclimb.data.model
+ .AttemptResult.SUCCESS ||
+ attempt.result ==
+ com.atridad.openclimb.data.model
+ .AttemptResult.FLASH)
+ }
+ }
+ .size
+
+ val favoriteGym =
+ sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
+ (gymId, _) ->
+ gyms.find { it.id == gymId }?.name
+ }
+ ?: "No sessions yet"
+
+ launch(Dispatchers.Main) {
+ val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
+
+ views.setTextViewText(
+ R.id.widget_total_sessions,
+ completedSessions.size.toString()
+ )
+ views.setTextViewText(
+ R.id.widget_problems_completed,
+ completedProblems.toString()
+ )
+ views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
+ views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
+
+ val intent = Intent(context, MainActivity::class.java)
+ val pendingIntent =
+ PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ } catch (e: Exception) {
+ launch(Dispatchers.Main) {
+ val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
+ views.setTextViewText(R.id.widget_total_sessions, "0")
+ views.setTextViewText(R.id.widget_problems_completed, "0")
+ views.setTextViewText(R.id.widget_total_problems, "0")
+ views.setTextViewText(R.id.widget_favorite_gym, "No data")
+
+ val intent = Intent(context, MainActivity::class.java)
+ val pendingIntent =
+ PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ }
+ }
+ }
+
+ companion object {
+ fun updateAllWidgets(context: Context) {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val componentName = ComponentName(context, ClimbStatsWidgetProvider::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
+
+ val intent =
+ Intent(context, ClimbStatsWidgetProvider::class.java).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
+ }
+ context.sendBroadcast(intent)
+ }
+ }
+}
diff --git a/app/src/main/res/drawable-night/ic_play_arrow_24.xml b/app/src/main/res/drawable-night/ic_play_arrow_24.xml
new file mode 100644
index 0000000..90830a5
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_play_arrow_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable-night/ic_stop_24.xml b/app/src/main/res/drawable-night/ic_stop_24.xml
new file mode 100644
index 0000000..b9722a4
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_stop_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_play_arrow_24.xml b/app/src/main/res/drawable/ic_play_arrow_24.xml
new file mode 100644
index 0000000..ab8b2b9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_arrow_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stop_24.xml b/app/src/main/res/drawable/ic_stop_24.xml
new file mode 100644
index 0000000..a127c13
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stop_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml
new file mode 100644
index 0000000..93c9434
--- /dev/null
+++ b/app/src/main/res/drawable/widget_background.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_stat_card_background.xml b/app/src/main/res/drawable/widget_stat_card_background.xml
new file mode 100644
index 0000000..9fee223
--- /dev/null
+++ b/app/src/main/res/drawable/widget_stat_card_background.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_status_background.xml b/app/src/main/res/drawable/widget_status_background.xml
new file mode 100644
index 0000000..90870b6
--- /dev/null
+++ b/app/src/main/res/drawable/widget_status_background.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_climb_stats.xml b/app/src/main/res/layout/widget_climb_stats.xml
new file mode 100644
index 0000000..b8ba2cf
--- /dev/null
+++ b/app/src/main/res/layout/widget_climb_stats.xml
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 26d716f..9e80504 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -2,5 +2,14 @@
#FF121212
-
+
+ #FF1E1E1E
+ #FF2D2D2D
+ #FF404040
+ #FF90CAF9
+ #FFA5D6A7
+ #FFFF8A65
+ #FFFFFFFF
+ #FFBDBDBD
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 3ca8cb9..f1884d3 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -10,4 +10,14 @@
#FFFFFFFF
-
\ No newline at end of file
+
+
+ #FFFFFFFF
+ #FFF8F9FA
+ #FFE0E0E0
+ #FF1976D2
+ #FF388E3C
+ #FFFF5722
+ #FF212121
+ #FF757575
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7ef7531..b600281 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,16 @@
OpenClimb
Tracks active climbing sessions and displays session information in the notification area
-
\ No newline at end of file
+
+
+ Start Session
+ Start a new climbing session
+ No gyms available to start session
+
+ End Session
+ End current climbing session
+ No active session to end
+
+
+ View your climbing stats at a glance
+
diff --git a/app/src/main/res/xml/widget_climb_stats_info.xml b/app/src/main/res/xml/widget_climb_stats_info.xml
new file mode 100644
index 0000000..6a80e00
--- /dev/null
+++ b/app/src/main/res/xml/widget_climb_stats_info.xml
@@ -0,0 +1,17 @@
+
+