Compare commits

...

4 Commits
1.3.1 ... 1.4.0

Author SHA1 Message Date
f45ff8963d Merge remote-tracking branch 'origin/main' 2025-09-06 23:19:36 -06:00
5988cbf1fb 1.4.0 - Shortcuts & Widgets 2025-09-06 23:19:26 -06:00
13654cde70 Update README.md 2025-09-01 07:14:52 +00:00
9064dbe2ef Update README.md 2025-09-01 07:14:40 +00:00
22 changed files with 1079 additions and 381 deletions

View File

@@ -51,6 +51,18 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2412" /> <option name="screenY" value="2412" />
</PersistentDeviceSelectionData> </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> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="OPPO" /> <option name="brand" value="OPPO" />

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <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> </handle>
</Target> </Target>
</DropdownSelection> </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: You have two options:
1. Download the latest APK from the Released page 1. Download the latest APK from the Releases 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> 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 ## Requirements

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 20 versionCode = 21
versionName = "1.3.1" versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -36,6 +36,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FileProvider for sharing images --> <!-- FileProvider for sharing images -->
@@ -60,6 +62,18 @@
android:name="android.app.foreground_service_type" android:name="android.app.foreground_service_type"
android:value="specialUse" /> android:value="specialUse" />
</service> </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> </application>
</manifest> </manifest>

View File

@@ -1,28 +1,40 @@
package com.atridad.openclimb package com.atridad.openclimb
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp import com.atridad.openclimb.ui.OpenClimbApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb) setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge() enableEdgeToEdge()
shortcutAction = intent?.action
setContent { setContent {
OpenClimbTheme { OpenClimbTheme {
Surface( Surface(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize() OpenClimbApp(shortcutAction = shortcutAction)
) {
OpenClimbApp()
} }
} }
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
shortcutAction = intent.action
}
} }

View File

@@ -1,7 +1,5 @@
package com.atridad.openclimb.ui package com.atridad.openclimb.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -25,64 +23,99 @@ import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.* import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.theme.OpenClimbTheme
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OpenClimbApp() { fun OpenClimbApp(shortcutAction: String? = null) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val database = remember { OpenClimbDatabase.getDatabase(context) } val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val viewModel: ClimbViewModel = viewModel( val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
factory = ClimbViewModelFactory(repository)
)
// Notification permission state // Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher // Permission launcher
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean -> ) { isGranted: Boolean ->
// Handle permission result if (!isGranted) {
if (isGranted) {
// Permission granted, continue
} else {
// Permission denied, show dialog again later
showNotificationPermissionDialog = false showNotificationPermissionDialog = false
} }
} }
// Check notification permission on first launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) { if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) { !NotificationPermissionUtils.isNotificationPermissionGranted(context)
) {
showNotificationPermissionDialog = true 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()
)
}
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) }
}
}
} }
// FAB configuration
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
bottomBar = { bottomBar = { OpenClimbBottomNavigation(navController = navController) },
OpenClimbBottomNavigation(navController = navController)
},
floatingActionButton = { floatingActionButton = {
fabConfig?.let { config -> fabConfig?.let { config ->
FloatingActionButton( FloatingActionButton(
@@ -102,19 +135,21 @@ fun OpenClimbApp() {
startDestination = Screen.Sessions, startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
// Main screens
composable<Screen.Sessions> { composable<Screen.Sessions> {
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState()
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) { fabConfig =
if (gyms.isNotEmpty() && activeSession == null) {
FabConfig( FabConfig(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
contentDescription = "Start Session", contentDescription = "Start Session",
onClick = { onClick = {
// Check notification permission before starting session if (NotificationPermissionUtils
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && .shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) { !NotificationPermissionUtils
.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
if (gyms.size == 1) { if (gyms.size == 1) {
@@ -140,7 +175,8 @@ fun OpenClimbApp() {
composable<Screen.Problems> { composable<Screen.Problems> {
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
fabConfig = if (gyms.isNotEmpty()) { fabConfig =
if (gyms.isNotEmpty()) {
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = "Add Problem", contentDescription = "Add Problem",
@@ -161,20 +197,17 @@ fun OpenClimbApp() {
} }
composable<Screen.Analytics> { composable<Screen.Analytics> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) { fabConfig = null }
fabConfig = null // No FAB for analytics
}
AnalyticsScreen(viewModel = viewModel) AnalyticsScreen(viewModel = viewModel)
} }
composable<Screen.Gyms> { composable<Screen.Gyms> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fabConfig = FabConfig( fabConfig =
FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = "Add Gym", contentDescription = "Add Gym",
onClick = { onClick = { navController.navigate(Screen.AddEditGym()) }
navController.navigate(Screen.AddEditGym())
}
) )
} }
GymsScreen( GymsScreen(
@@ -186,13 +219,10 @@ fun OpenClimbApp() {
} }
composable<Screen.Settings> { composable<Screen.Settings> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) { fabConfig = null }
fabConfig = null // No FAB for settings
}
SettingsScreen(viewModel = viewModel) SettingsScreen(viewModel = viewModel)
} }
// Detail screens
composable<Screen.SessionDetail> { backStackEntry -> composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>() val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
@@ -238,7 +268,6 @@ fun OpenClimbApp() {
) )
} }
composable<Screen.AddEditGym> { backStackEntry -> composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>() val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
@@ -277,7 +306,9 @@ fun OpenClimbApp() {
NotificationPermissionDialog( NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false }, onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = { onRequestPermission = {
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString()) permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
} }
) )
} }
@@ -291,7 +322,8 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
NavigationBar { NavigationBar {
bottomNavigationItems.forEach { item -> bottomNavigationItems.forEach { item ->
val isSelected = when (item.screen) { val isSelected =
when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true is Screen.Gyms -> currentRoute?.contains("Gym") == true
@@ -307,9 +339,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
// Clear the entire back stack and go to the selected tab's root screen // Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) { popUpTo(0) { inclusive = true }
inclusive = true
}
// Avoid multiple copies of the same destination when // Avoid multiple copies of the same destination when
// reselecting the same item // reselecting the same item
launchSingleTop = true launchSingleTop = true
@@ -327,5 +357,3 @@ data class FabConfig(
val contentDescription: String, val contentDescription: String,
val onClick: () -> Unit val onClick: () -> Unit
) )

View File

@@ -8,100 +8,134 @@ import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.service.SessionTrackingService import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils 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.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
class ClimbViewModel( class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
private val repository: ClimbRepository
) : ViewModel() {
// UI State flows // UI State flows
private val _uiState = MutableStateFlow(ClimbUiState()) private val _uiState = MutableStateFlow(ClimbUiState())
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow() val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
// Data flows // Data flows
val gyms = repository.getAllGyms().stateIn( val gyms =
repository
.getAllGyms()
.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val problems = repository.getAllProblems().stateIn( val problems =
repository
.getAllProblems()
.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val sessions = repository.getAllSessions().stateIn( val sessions =
repository
.getAllSessions()
.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val activeSession = repository.getActiveSessionFlow().stateIn( val activeSession =
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = null initialValue = null
) )
val attempts = repository.getAllAttempts().stateIn( val attempts =
repository
.getAllAttempts()
.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
// Gym operations // Gym operations
fun addGym(gym: Gym) { fun addGym(gym: Gym) {
viewModelScope.launch { repository.insertGym(gym) }
}
fun addGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertGym(gym) repository.insertGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateGym(gym: Gym) { fun updateGym(gym: Gym) {
viewModelScope.launch { repository.updateGym(gym) }
}
fun updateGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateGym(gym) repository.updateGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteGym(gym: Gym) { fun deleteGym(gym: Gym) {
viewModelScope.launch { repository.deleteGym(gym) }
}
fun deleteGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteGym(gym) repository.deleteGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun getGymById(id: String): Flow<Gym?> = flow { fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
emit(repository.getGymById(id))
}
// Problem operations // Problem operations
fun addProblem(problem: Problem) { fun addProblem(problem: Problem) {
viewModelScope.launch { repository.insertProblem(problem) }
}
fun addProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertProblem(problem) repository.insertProblem(problem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateProblem(problem: Problem) { fun updateProblem(problem: Problem) {
viewModelScope.launch { repository.updateProblem(problem) }
}
fun updateProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateProblem(problem) repository.updateProblem(problem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteProblem(problem: Problem, context: Context) { fun deleteProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
// Delete associated images // Delete associated images
problem.imagePaths.forEach { imagePath -> problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
ImageUtils.deleteImage(context, imagePath)
}
repository.deleteProblem(problem) repository.deleteProblem(problem)
// Clean up any remaining orphaned images
cleanupOrphanedImages(context) cleanupOrphanedImages(context)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
@@ -111,29 +145,41 @@ class ClimbViewModel(
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
fun getProblemById(id: String): Flow<Problem?> = flow { fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
emit(repository.getProblemById(id))
}
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
repository.getProblemsByGym(gymId)
// Session operations // Session operations
fun addSession(session: ClimbSession) { fun addSession(session: ClimbSession) {
viewModelScope.launch { repository.insertSession(session) }
}
fun addSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertSession(session) repository.insertSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateSession(session: ClimbSession) { fun updateSession(session: ClimbSession) {
viewModelScope.launch { repository.updateSession(session) }
}
fun updateSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateSession(session) repository.updateSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteSession(session: ClimbSession) { fun deleteSession(session: ClimbSession) {
viewModelScope.launch { repository.deleteSession(session) }
}
fun deleteSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteSession(session) repository.deleteSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
@@ -147,17 +193,21 @@ class ClimbViewModel(
// Active session management // Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) { fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch { viewModelScope.launch {
// Check notification permission first if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) { .isNotificationPermissionGranted(context)
_uiState.value = _uiState.value.copy( ) {
error = "Notification permission is required to track your climbing session. Please enable notifications in settings." _uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to track your climbing session. Please enable notifications in settings."
) )
return@launch return@launch
} }
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
_uiState.value = _uiState.value.copy( _uiState.value =
_uiState.value.copy(
error = "There's already an active session. Please end it first." error = "There's already an active session. Please end it first."
) )
return@launch return@launch
@@ -170,18 +220,21 @@ class ClimbViewModel(
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
_uiState.value = _uiState.value.copy( ClimbStatsWidgetProvider.updateAllWidgets(context)
message = "Session started successfully!"
) _uiState.value = _uiState.value.copy(message = "Session started successfully!")
} }
} }
fun endSession(context: Context, sessionId: String) { fun endSession(context: Context, sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
// Check notification permission first if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) { .isNotificationPermissionGranted(context)
_uiState.value = _uiState.value.copy( ) {
error = "Notification permission is required to manage your climbing session. Please enable notifications in settings." _uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
) )
return@launch return@launch
} }
@@ -191,27 +244,22 @@ class ClimbViewModel(
val completedSession = with(ClimbSession) { session.complete() } val completedSession = with(ClimbSession) { session.complete() }
repository.updateSession(completedSession) repository.updateSession(completedSession)
// Stop the tracking service, passing the session id so service can finalize if needed
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId) val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent) context.startService(serviceIntent)
_uiState.value = _uiState.value.copy( ClimbStatsWidgetProvider.updateAllWidgets(context)
message = "Session completed!"
) _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) { fun ensureSessionTrackingServiceRunning(context: Context) {
viewModelScope.launch { viewModelScope.launch {
val activeSession = repository.getActiveSession() val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) { if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
// Check if service is running by trying to start it again val serviceIntent =
// The service will handle duplicate starts gracefully SessionTrackingService.createStartIntent(context, activeSession.id)
val serviceIntent = SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
} }
} }
@@ -219,20 +267,35 @@ class ClimbViewModel(
// Attempt operations // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt) {
viewModelScope.launch { repository.insertAttempt(attempt) }
}
fun addAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertAttempt(attempt) repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteAttempt(attempt: Attempt) { fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch { repository.deleteAttempt(attempt) }
}
fun deleteAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteAttempt(attempt) repository.deleteAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateAttempt(attempt: Attempt) { fun updateAttempt(attempt: Attempt) {
viewModelScope.launch { repository.updateAttempt(attempt) }
}
fun updateAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateAttempt(attempt) repository.updateAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
@@ -242,19 +305,19 @@ class ClimbViewModel(
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId) repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
repository.exportAllDataToZipUri(context, uri) repository.exportAllDataToZipUri(context, uri)
_uiState.value = _uiState.value.copy( _uiState.value =
_uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data with images exported successfully" message = "Data with images exported successfully"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value =
_uiState.value.copy(
isLoading = false, isLoading = false,
error = "Export failed: ${e.message}" error = "Export failed: ${e.message}"
) )
@@ -267,19 +330,22 @@ class ClimbViewModel(
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
// Only support ZIP format for reliability
if (!file.name.lowercase().endsWith(".zip")) { 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) repository.importDataFromZip(file)
_uiState.value = _uiState.value.copy( _uiState.value =
_uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data imported successfully from ${file.name}" message = "Data imported successfully from ${file.name}"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value =
_uiState.value.copy(
isLoading = false, isLoading = false,
error = "Import failed: ${e.message}" error = "Import failed: ${e.message}"
) )
@@ -307,28 +373,26 @@ class ClimbViewModel(
repository.resetAllData() repository.resetAllData()
_uiState.value = _uiState.value.copy( _uiState.value =
_uiState.value.copy(
isLoading = false, isLoading = false,
message = "All data has been reset successfully" message = "All data has been reset successfully"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
error = "Reset failed: ${e.message}"
)
} }
} }
} }
// Share operations // Share operations
suspend fun generateSessionShareCard( suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
context: Context, withContext(Dispatchers.IO) {
sessionId: String
): File? = withContext(Dispatchers.IO) {
try { try {
val session = repository.getSessionById(sessionId) ?: return@withContext null val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first() val attempts = repository.getAttemptsBySession(sessionId).first()
val problems = repository.getAllProblems().first().filter { problem -> val problems =
repository.getAllProblems().first().filter { problem ->
attempts.any { it.problemId == problem.id } attempts.any { it.problemId == problem.id }
} }
val gym = repository.getGymById(session.gymId) ?: return@withContext null val gym = repository.getGymById(session.gymId) ?: return@withContext null
@@ -336,7 +400,10 @@ class ClimbViewModel(
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems) val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats) SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}") _uiState.value =
_uiState.value.copy(
error = "Failed to generate share card: ${e.message}"
)
null 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> <resources>
<!-- Splash background (dark) --> <!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color> <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) --> <!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color> <color name="splash_background">#FFFFFFFF</color>
<!-- 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> </resources>

View File

@@ -1,4 +1,16 @@
<resources> <resources>
<string name="app_name">OpenClimb</string> <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> <string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
<!-- 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> </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" />