diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7f9b74c..0e6b1e5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.ascently" minSdk = 31 targetSdk = 36 - versionCode = 45 - versionName = "2.2.0" + versionCode = 46 + versionName = "2.2.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt index a4734b1..6af2e5e 100644 --- a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt +++ b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt @@ -12,35 +12,36 @@ import com.atridad.ascently.MainActivity import com.atridad.ascently.R import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.repository.ClimbRepository -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.firstOrNull +import com.atridad.ascently.widget.ClimbStatsWidgetProvider import java.time.LocalDateTime import java.time.temporal.ChronoUnit +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking class SessionTrackingService : Service() { - + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var notificationJob: Job? = null private var monitoringJob: Job? = null - + private lateinit var repository: ClimbRepository private lateinit var notificationManager: NotificationManager - + companion object { const val NOTIFICATION_ID = 1001 const val CHANNEL_ID = "session_tracking_channel" const val ACTION_START_SESSION = "start_session" const val ACTION_STOP_SESSION = "stop_session" const val EXTRA_SESSION_ID = "session_id" - + fun createStartIntent(context: Context, sessionId: String): Intent { return Intent(context, SessionTrackingService::class.java).apply { action = ACTION_START_SESSION putExtra(EXTRA_SESSION_ID, sessionId) } } - + fun createStopIntent(context: Context, sessionId: String): Intent { return Intent(context, SessionTrackingService::class.java).apply { action = ACTION_STOP_SESSION @@ -48,17 +49,17 @@ class SessionTrackingService : Service() { } } } - + override fun onCreate() { super.onCreate() - + val database = AscentlyDatabase.getDatabase(this) repository = ClimbRepository(database, this) notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - + createNotificationChannel() } - + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START_SESSION -> { @@ -71,12 +72,19 @@ class SessionTrackingService : Service() { val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) serviceScope.launch { try { - val targetSession = when { - sessionId != null -> repository.getSessionById(sessionId) - else -> repository.getActiveSession() - } - if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) { - val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() } + val targetSession = + when { + sessionId != null -> repository.getSessionById(sessionId) + else -> repository.getActiveSession() + } + if (targetSession != null && + targetSession.status == + com.atridad.ascently.data.model.SessionStatus.ACTIVE + ) { + val completed = + with(com.atridad.ascently.data.model.ClimbSession) { + targetSession.complete() + } repository.updateSession(completed) } } finally { @@ -90,61 +98,71 @@ class SessionTrackingService : Service() { } override fun onBind(intent: Intent?): IBinder? = null - + private fun startSessionTracking(sessionId: String) { notificationJob?.cancel() monitoringJob?.cancel() - + try { createAndShowNotification(sessionId) + // Update widget when session tracking starts + ClimbStatsWidgetProvider.updateAllWidgets(this) } catch (e: Exception) { e.printStackTrace() } - - notificationJob = serviceScope.launch { - try { - if (!isNotificationActive()) { - delay(1000L) - createAndShowNotification(sessionId) - } - - while (isActive) { - delay(5000L) - updateNotification(sessionId) - } - } catch (e: Exception) { - e.printStackTrace() - } - } - - monitoringJob = serviceScope.launch { - try { - while (isActive) { - delay(10000L) - - if (!isNotificationActive()) { - updateNotification(sessionId) - } - - val session = repository.getSessionById(sessionId) - if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { - stopSessionTracking() - break + + notificationJob = + serviceScope.launch { + try { + if (!isNotificationActive()) { + delay(1000L) + createAndShowNotification(sessionId) + } + + while (isActive) { + delay(5000L) + updateNotification(sessionId) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + monitoringJob = + serviceScope.launch { + try { + while (isActive) { + delay(10000L) + + if (!isNotificationActive()) { + updateNotification(sessionId) + } + + val session = repository.getSessionById(sessionId) + if (session == null || + session.status != + com.atridad.ascently.data.model.SessionStatus + .ACTIVE + ) { + stopSessionTracking() + break + } + } + } catch (e: Exception) { + e.printStackTrace() } } - } catch (e: Exception) { - e.printStackTrace() - } - } } - + private fun stopSessionTracking() { notificationJob?.cancel() monitoringJob?.cancel() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() + // Update widget when session tracking stops + ClimbStatsWidgetProvider.updateAllWidgets(this) } - + private fun isNotificationActive(): Boolean { return try { val activeNotifications = notificationManager.activeNotifications @@ -153,10 +171,12 @@ class SessionTrackingService : Service() { false } } - + private suspend fun updateNotification(sessionId: String) { try { createAndShowNotification(sessionId) + // Update widget when notification updates + ClimbStatsWidgetProvider.updateAllWidgets(this) } catch (e: Exception) { e.printStackTrace() @@ -169,116 +189,121 @@ class SessionTrackingService : Service() { } } } - + private fun createAndShowNotification(sessionId: String) { try { - val session = runBlocking { - repository.getSessionById(sessionId) - } - if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { + val session = runBlocking { repository.getSessionById(sessionId) } + if (session == null || + session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE + ) { stopSessionTracking() return } - - val gym = runBlocking { - repository.getGymById(session.gymId) - } - + + val gym = runBlocking { repository.getGymById(session.gymId) } + val attempts = runBlocking { repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() } - - val duration = session.startTime?.let { startTime -> - try { - val start = LocalDateTime.parse(startTime) - val now = LocalDateTime.now() - val totalSeconds = ChronoUnit.SECONDS.between(start, now) - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - val seconds = totalSeconds % 60 - - when { - hours > 0 -> "${hours}h ${minutes}m ${seconds}s" - minutes > 0 -> "${minutes}m ${seconds}s" - else -> "${totalSeconds}s" + + val duration = + session.startTime?.let { startTime -> + try { + val start = LocalDateTime.parse(startTime) + val now = LocalDateTime.now() + val totalSeconds = ChronoUnit.SECONDS.between(start, now) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + when { + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${totalSeconds}s" + } + } catch (_: Exception) { + "Active" + } } - } catch (_: Exception) { - "Active" - } - } ?: "Active" - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Climbing Session Active") - .setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts") - .setSmallIcon(R.drawable.ic_mountains) - .setOngoing(true) - .setAutoCancel(false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(createOpenAppIntent()) - .addAction( - R.drawable.ic_mountains, - "Open Session", - createOpenAppIntent() - ) - .addAction( - android.R.drawable.ic_menu_close_clear_cancel, - "End Session", - createStopPendingIntent(sessionId) - ) - .build() - + ?: "Active" + + val notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Climbing Session Active") + .setContentText( + "${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts" + ) + .setSmallIcon(R.drawable.ic_mountains) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(createOpenAppIntent()) + .addAction( + R.drawable.ic_mountains, + "Open Session", + createOpenAppIntent() + ) + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + "End Session", + createStopPendingIntent(sessionId) + ) + .build() + startForeground(NOTIFICATION_ID, notification) - + notificationManager.notify(NOTIFICATION_ID, notification) - } catch (e: Exception) { e.printStackTrace() throw e } } - + private fun createOpenAppIntent(): PendingIntent { - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - action = "OPEN_SESSION" - } + val intent = + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + action = "OPEN_SESSION" + } return PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } - + private fun createStopPendingIntent(sessionId: String): PendingIntent { val intent = createStopIntent(this, sessionId) return PendingIntent.getService( - this, - 1, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + this, + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } - + private fun createNotificationChannel() { - val channel = NotificationChannel( - CHANNEL_ID, - "Session Tracking", - NotificationManager.IMPORTANCE_DEFAULT - ).apply { - description = "Shows active climbing session information" - setShowBadge(false) - lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - enableLights(false) - enableVibration(false) - setSound(null, null) - } + val channel = + NotificationChannel( + CHANNEL_ID, + "Session Tracking", + NotificationManager.IMPORTANCE_DEFAULT + ) + .apply { + description = "Shows active climbing session information" + setShowBadge(false) + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + enableLights(false) + enableVibration(false) + setSound(null, null) + } notificationManager.createNotificationChannel(channel) } - + override fun onDestroy() { super.onDestroy() notificationJob?.cancel() diff --git a/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt b/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt index dd3daca..2db58c8 100644 --- a/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt +++ b/android/app/src/main/java/com/atridad/ascently/widget/ClimbStatsWidgetProvider.kt @@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity import com.atridad.ascently.R import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.repository.ClimbRepository +import java.time.LocalDate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -48,53 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() { val database = AscentlyDatabase.getDatabase(context) val repository = ClimbRepository(database, context) - // Fetch stats data + // Get last 7 days date range (rolling period) + val today = LocalDate.now() + val sevenDaysAgo = today.minusDays(6) // Today + 6 days ago = 7 days total + + // Fetch all sessions and attempts val sessions = repository.getAllSessions().first() - val problems = repository.getAllProblems().first() val attempts = repository.getAllAttempts().first() - val gyms = repository.getAllGyms().first() - // 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.ascently.data.model - .AttemptResult.SUCCESS || - attempt.result == - com.atridad.ascently.data.model - .AttemptResult.FLASH) - } - } - .size - - val favoriteGym = - sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let { - (gymId, _) -> - gyms.find { it.id == gymId }?.name + // Filter for last 7 days across all gyms + val weekSessions = + sessions.filter { session -> + try { + val sessionDate = LocalDate.parse(session.date.substring(0, 10)) + !sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today) + } catch (_: Exception) { + false + } } - ?: "No sessions yet" + + val weekSessionIds = weekSessions.map { it.id }.toSet() + + // Count total attempts this week + val totalAttempts = + attempts.count { attempt -> weekSessionIds.contains(attempt.sessionId) } + + // Count sessions this week + val totalSessions = weekSessions.size 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) + // Set weekly stats + views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString()) + views.setTextViewText(R.id.widget_sessions_value, totalSessions.toString()) - val intent = Intent(context, MainActivity::class.java) + val intent = + Intent(context, MainActivity::class.java).apply { + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP + } val pendingIntent = PendingIntent.getActivity( context, @@ -110,10 +105,8 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() { } catch (_: 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") + views.setTextViewText(R.id.widget_attempts_value, "0") + views.setTextViewText(R.id.widget_sessions_value, "0") val intent = Intent(context, MainActivity::class.java) val pendingIntent = diff --git a/android/app/src/main/res/drawable/ic_checkmark_circle.xml b/android/app/src/main/res/drawable/ic_checkmark_circle.xml new file mode 100644 index 0000000..c85e4b5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_checkmark_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_circle_filled.xml b/android/app/src/main/res/drawable/ic_circle_filled.xml new file mode 100644 index 0000000..15cb636 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_circle_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout/widget_climb_stats.xml b/android/app/src/main/res/layout/widget_climb_stats.xml index c36d38d..213acd5 100644 --- a/android/app/src/main/res/layout/widget_climb_stats.xml +++ b/android/app/src/main/res/layout/widget_climb_stats.xml @@ -5,190 +5,84 @@ android:layout_height="match_parent" android:background="@drawable/widget_background" android:orientation="vertical" - android:padding="12dp"> + android:padding="12dp" + android:gravity="center"> - + - - + android:layout_marginEnd="8dp" + android:contentDescription="Ascently icon" /> + android:text="Weekly" + android:textSize="18sp" + android:textColor="@color/widget_text_primary" /> - + + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="12dp"> - - + - - + - + - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8434f4c..0b68ad1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -12,5 +12,5 @@ No active session to end - View your climbing stats at a glance + View your weekly climbing stats diff --git a/android/app/src/main/res/xml/widget_climb_stats_info.xml b/android/app/src/main/res/xml/widget_climb_stats_info.xml index 6a80e00..d9c21b0 100644 --- a/android/app/src/main/res/xml/widget_climb_stats_info.xml +++ b/android/app/src/main/res/xml/widget_climb_stats_info.xml @@ -3,15 +3,14 @@ android:description="@string/widget_description" android:initialKeyguardLayout="@layout/widget_climb_stats" android:initialLayout="@layout/widget_climb_stats" - android:minWidth="250dp" - android:minHeight="180dp" + android:minWidth="110dp" + android:minHeight="110dp" + android:maxResizeWidth="110dp" + android:maxResizeHeight="110dp" android:previewImage="@drawable/ic_mountains" android:previewLayout="@layout/widget_climb_stats" - android:resizeMode="horizontal|vertical" - android:targetCellWidth="4" + android:resizeMode="none" + android:targetCellWidth="2" android:targetCellHeight="2" android:updatePeriodMillis="1800000" - android:widgetCategory="home_screen" - android:widgetFeatures="reconfigurable" - android:maxResizeWidth="320dp" - android:maxResizeHeight="240dp" /> + android:widgetCategory="home_screen" /> diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 70d93e7..9d0efa4 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ