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