1.4.0 - Shortcuts & Widgets

This commit is contained in:
2025-09-06 23:19:26 -06:00
parent 20f48a31c0
commit 463405479f
22 changed files with 1079 additions and 381 deletions

View File

@@ -51,6 +51,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP5552L1" />
<option name="id" value="OP5552L1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2415" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OPPO" />

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-27T22:55:36.064836Z">
<DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Pixel_9a.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -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 <a href="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">Obtainium</a>
1. Download the latest APK from the Releases page
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](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

View File

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

View File

@@ -7,15 +7,15 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -36,8 +36,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider for sharing images -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -48,7 +50,7 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Session tracking service -->
<service
android:name=".service.SessionTrackingService"
@@ -60,6 +62,18 @@
android:name="android.app.foreground_service_type"
android:value="specialUse" />
</service>
<!-- Widget Provider -->
<receiver
android:name=".widget.ClimbStatsWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_climb_stats_info" />
</receiver>
</application>
</manifest>
</manifest>

View File

@@ -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<String?>(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)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
shortcutAction = intent.action
}
}

View File

@@ -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<FabConfig?>(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<Screen.Sessions> {
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<Screen.Problems> {
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<Screen.Analytics> {
LaunchedEffect(Unit) {
fabConfig = null // No FAB for analytics
}
LaunchedEffect(Unit) { fabConfig = null }
AnalyticsScreen(viewModel = viewModel)
}
composable<Screen.Gyms> {
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<Screen.Settings> {
LaunchedEffect(Unit) {
fabConfig = null // No FAB for settings
}
LaunchedEffect(Unit) { fabConfig = null }
SettingsScreen(viewModel = viewModel)
}
// Detail screens
composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>()
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<Screen.ProblemDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
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<Screen.GymDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.GymDetail>()
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<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
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<Screen.AddEditSession> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditSession>()
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
)

View File

@@ -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<ClimbUiState> = _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<Gym?> = flow {
emit(repository.getGymById(id))
}
fun getGymById(id: String): Flow<Gym?> = 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<Problem?> = flow {
emit(repository.getProblemById(id))
}
fun getProblemsByGym(gymId: String): Flow<List<Problem>> =
repository.getProblemsByGym(gymId)
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = 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<ClimbSession?> = flow {
emit(repository.getSessionById(id))
}
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
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<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId)
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
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
)

View File

@@ -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<ShortcutInfo>()
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)
}
}
}

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#81C784"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#EF5350"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#4CAF50"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#F44336"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_background" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="@color/widget_outline" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="16dp" />
<stroke
android:width="0.5dp"
android:color="@color/widget_outline" />
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_accent" />
<corners android:radius="12dp" />
<stroke
android:width="0.5dp"
android:color="@color/widget_text_primary" />
<padding
android:left="6dp"
android:top="3dp"
android:right="6dp"
android:bottom="3dp" />
</shape>

View File

@@ -0,0 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="OpenClimb"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Climbing Stats"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary" />
</LinearLayout>
<!-- Stats Grid -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<!-- Top Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<!-- Sessions Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_sessions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sessions"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Problems Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_problems_completed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Completed"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Bottom Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<!-- Success Rate Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_problems"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_secondary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Problems"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Favorite Gym Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_favorite_gym"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No gyms"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="@color/widget_accent"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Favorite"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -2,5 +2,14 @@
<resources>
<!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color>
</resources>
<!-- Widget colors (dark theme) -->
<color name="widget_background">#FF1E1E1E</color>
<color name="widget_surface">#FF2D2D2D</color>
<color name="widget_outline">#FF404040</color>
<color name="widget_primary">#FF90CAF9</color>
<color name="widget_secondary">#FFA5D6A7</color>
<color name="widget_accent">#FFFF8A65</color>
<color name="widget_text_primary">#FFFFFFFF</color>
<color name="widget_text_secondary">#FFBDBDBD</color>
</resources>

View File

@@ -10,4 +10,14 @@
<!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color>
</resources>
<!-- Widget colors (light theme) -->
<color name="widget_background">#FFFFFFFF</color>
<color name="widget_surface">#FFF8F9FA</color>
<color name="widget_outline">#FFE0E0E0</color>
<color name="widget_primary">#FF1976D2</color>
<color name="widget_secondary">#FF388E3C</color>
<color name="widget_accent">#FFFF5722</color>
<color name="widget_text_primary">#FF212121</color>
<color name="widget_text_secondary">#FF757575</color>
</resources>

View File

@@ -1,4 +1,16 @@
<resources>
<string name="app_name">OpenClimb</string>
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
</resources>
<!-- App Shortcuts -->
<string name="shortcut_start_session_short">Start Session</string>
<string name="shortcut_start_session_long">Start a new climbing session</string>
<string name="shortcut_start_session_disabled">No gyms available to start session</string>
<string name="shortcut_end_session_short">End Session</string>
<string name="shortcut_end_session_long">End current climbing session</string>
<string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp"
android:minHeight="180dp"
android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />