Compare commits

..

7 Commits

Author SHA1 Message Date
071e47f95e Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m18s
2025-10-25 09:41:27 +00:00
c6c3e6084b Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m19s
2025-10-25 09:33:33 +00:00
c2f95f2793 [Android] 2.2.1 - Better Widget 2025-10-21 10:22:31 -06:00
b7a3c98b2c [Android] 2.2.1 - Better Widget 2025-10-21 10:21:35 -06:00
fed9bab2ea Fixeds QR flashing
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m49s
2025-10-21 08:50:34 -06:00
862622b07b Fixed
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m58s
2025-10-20 00:03:35 -06:00
eba503eb5e Updated docs with QR Codes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m26s
2025-10-19 23:55:30 -06:00
17 changed files with 922 additions and 526 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 45 versionCode = 46
versionName = "2.2.0" versionName = "2.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -12,10 +12,11 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.* import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() { class SessionTrackingService : Service() {
@@ -71,12 +72,19 @@ class SessionTrackingService : Service() {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch { serviceScope.launch {
try { try {
val targetSession = when { val targetSession =
sessionId != null -> repository.getSessionById(sessionId) when {
else -> repository.getActiveSession() 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() } 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) repository.updateSession(completed)
} }
} finally { } finally {
@@ -97,45 +105,53 @@ class SessionTrackingService : Service() {
try { try {
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
// Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
notificationJob = serviceScope.launch { notificationJob =
try { serviceScope.launch {
if (!isNotificationActive()) { try {
delay(1000L) if (!isNotificationActive()) {
createAndShowNotification(sessionId) delay(1000L)
} createAndShowNotification(sessionId)
}
while (isActive) { while (isActive) {
delay(5000L) delay(5000L)
updateNotification(sessionId) updateNotification(sessionId)
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
} }
monitoringJob = serviceScope.launch { monitoringJob =
try { serviceScope.launch {
while (isActive) { try {
delay(10000L) while (isActive) {
delay(10000L)
if (!isNotificationActive()) {
updateNotification(sessionId) if (!isNotificationActive()) {
} updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { val session = repository.getSessionById(sessionId)
stopSessionTracking() if (session == null ||
break session.status !=
com.atridad.ascently.data.model.SessionStatus
.ACTIVE
) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
e.printStackTrace()
} }
} }
} catch (e: Exception) {
e.printStackTrace()
}
}
} }
private fun stopSessionTracking() { private fun stopSessionTracking() {
@@ -143,6 +159,8 @@ class SessionTrackingService : Service() {
monitoringJob?.cancel() monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
// Update widget when session tracking stops
ClimbStatsWidgetProvider.updateAllWidgets(this)
} }
private fun isNotificationActive(): Boolean { private fun isNotificationActive(): Boolean {
@@ -157,6 +175,8 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) { private suspend fun updateNotification(sessionId: String) {
try { try {
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
// Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -172,67 +192,69 @@ class SessionTrackingService : Service() {
private fun createAndShowNotification(sessionId: String) { private fun createAndShowNotification(sessionId: String) {
try { try {
val session = runBlocking { val session = runBlocking { repository.getSessionById(sessionId) }
repository.getSessionById(sessionId) if (session == null ||
} session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { ) {
stopSessionTracking() stopSessionTracking()
return return
} }
val gym = runBlocking { val gym = runBlocking { repository.getGymById(session.gymId) }
repository.getGymById(session.gymId)
}
val attempts = runBlocking { val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
} }
val duration = session.startTime?.let { startTime -> val duration =
try { session.startTime?.let { startTime ->
val start = LocalDateTime.parse(startTime) try {
val now = LocalDateTime.now() val start = LocalDateTime.parse(startTime)
val totalSeconds = ChronoUnit.SECONDS.between(start, now) val now = LocalDateTime.now()
val hours = totalSeconds / 3600 val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val minutes = (totalSeconds % 3600) / 60 val hours = totalSeconds / 3600
val seconds = totalSeconds % 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when { when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s" else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
} }
} catch (_: Exception) { ?: "Active"
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification =
.setContentTitle("Climbing Session Active") NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts") .setContentTitle("Climbing Session Active")
.setSmallIcon(R.drawable.ic_mountains) .setContentText(
.setOngoing(true) "${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
.setAutoCancel(false) )
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setSmallIcon(R.drawable.ic_mountains)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setAutoCancel(false)
.setContentIntent(createOpenAppIntent()) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction( .setCategory(NotificationCompat.CATEGORY_SERVICE)
R.drawable.ic_mountains, .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
"Open Session", .setContentIntent(createOpenAppIntent())
createOpenAppIntent() .addAction(
) R.drawable.ic_mountains,
.addAction( "Open Session",
android.R.drawable.ic_menu_close_clear_cancel, createOpenAppIntent()
"End Session", )
createStopPendingIntent(sessionId) .addAction(
) android.R.drawable.ic_menu_close_clear_cancel,
.build() "End Session",
createStopPendingIntent(sessionId)
)
.build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
throw e throw e
@@ -240,41 +262,44 @@ class SessionTrackingService : Service() {
} }
private fun createOpenAppIntent(): PendingIntent { private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply { val intent =
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP Intent(this, MainActivity::class.java).apply {
action = "OPEN_SESSION" flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} action = "OPEN_SESSION"
}
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
0, 0,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
} }
private fun createStopPendingIntent(sessionId: String): PendingIntent { private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId) val intent = createStopIntent(this, sessionId)
return PendingIntent.getService( return PendingIntent.getService(
this, this,
1, 1,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
val channel = NotificationChannel( val channel =
CHANNEL_ID, NotificationChannel(
"Session Tracking", CHANNEL_ID,
NotificationManager.IMPORTANCE_DEFAULT "Session Tracking",
).apply { NotificationManager.IMPORTANCE_DEFAULT
description = "Shows active climbing session information" )
setShowBadge(false) .apply {
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC description = "Shows active climbing session information"
enableLights(false) setShowBadge(false)
enableVibration(false) lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
setSound(null, null) enableLights(false)
} enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }

View File

@@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -48,53 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
val database = AscentlyDatabase.getDatabase(context) val database = AscentlyDatabase.getDatabase(context)
val repository = ClimbRepository(database, 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 sessions = repository.getAllSessions().first()
val problems = repository.getAllProblems().first()
val attempts = repository.getAllAttempts().first() val attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first()
// Calculate stats // Filter for last 7 days across all gyms
val completedSessions = sessions.filter { it.endTime != null } val weekSessions =
sessions.filter { session ->
// Count problems that have been completed (have at least one successful attempt) try {
val completedProblems = val sessionDate = LocalDate.parse(session.date.substring(0, 10))
problems !sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today)
.filter { problem -> } catch (_: Exception) {
attempts.any { attempt -> false
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
} }
?: "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) { launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats) val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText( // Set weekly stats
R.id.widget_total_sessions, views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString())
completedSessions.size.toString() views.setTextViewText(R.id.widget_sessions_value, totalSessions.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 intent =
Intent(context, MainActivity::class.java).apply {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = val pendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
@@ -110,10 +105,8 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
} catch (_: Exception) { } catch (_: Exception) {
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats) val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(R.id.widget_total_sessions, "0") views.setTextViewText(R.id.widget_attempts_value, "0")
views.setTextViewText(R.id.widget_problems_completed, "0") views.setTextViewText(R.id.widget_sessions_value, "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 intent = Intent(context, MainActivity::class.java)
val pendingIntent = val pendingIntent =

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="#000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"/>
</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="#000000"
android:pathData="M9,11.24V7.5C9,6.12 10.12,5 11.5,5S14,6.12 14,7.5v3.74c1.21,-0.81 2,-2.18 2,-3.74C16,5.01 13.99,3 11.5,3S7,5.01 7,7.5C7,9.06 7.79,10.43 9,11.24zM18.84,15.87l-4.54,-2.26c-0.17,-0.07 -0.35,-0.11 -0.54,-0.11H13v-6C13,6.67 12.33,6 11.5,6S10,6.67 10,7.5v10.74l-3.43,-0.72c-0.08,-0.01 -0.15,-0.03 -0.24,-0.03c-0.31,0 -0.59,0.13 -0.79,0.33l-0.79,0.8l4.94,4.94C9.96,23.83 10.34,24 10.75,24h6.79c0.75,0 1.33,-0.55 1.44,-1.28l0.75,-5.27c0.01,-0.07 0.02,-0.14 0.02,-0.2C19.75,16.63 19.37,16.09 18.84,15.87z"/>
</vector>

View File

@@ -5,190 +5,84 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/widget_background" android:background="@drawable/widget_background"
android:orientation="vertical" android:orientation="vertical"
android:padding="12dp"> android:padding="12dp"
android:gravity="center">
<!-- Header --> <!-- Header with icon and "Weekly" text -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp"> android:layout_marginBottom="12dp">
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="28dp"
android:layout_height="24dp" android:layout_height="28dp"
android:src="@drawable/ic_mountains" android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary" android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" /> android:layout_marginEnd="8dp"
android:contentDescription="@string/ascently_icon" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Ascently"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Climbing Stats" android:text="@string/weekly"
android:textSize="12sp" android:textSize="18sp"
android:textColor="@color/widget_text_secondary" /> android:textColor="@color/widget_text_primary" />
</LinearLayout> </LinearLayout>
<!-- Stats Grid --> <!-- Attempts Row -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="wrap_content"
android:layout_weight="1" android:orientation="horizontal"
android:orientation="vertical" android:gravity="center_vertical"
android:gravity="center"> android:layout_marginBottom="12dp">
<!-- Top Row --> <ImageView
<LinearLayout android:layout_width="32dp"
android:layout_width="match_parent" android:layout_height="32dp"
android:layout_height="0dp" android:src="@drawable/ic_circle_filled"
android:layout_weight="1" android:tint="@color/widget_primary"
android:orientation="horizontal" android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp"> android:contentDescription="Attempts icon" />
<!-- Sessions Card --> <TextView
<LinearLayout android:id="@+id/widget_attempts_value"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" android:text="0"
android:orientation="vertical" android:textSize="40sp"
android:gravity="center" android:textStyle="bold"
android:background="@drawable/widget_stat_card_background" android:textColor="@color/widget_text_primary" />
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView </LinearLayout>
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 <!-- Sessions Row -->
android:layout_width="wrap_content" <LinearLayout
android:layout_height="wrap_content" android:layout_width="match_parent"
android:text="Sessions" android:layout_height="wrap_content"
android:textSize="12sp" android:orientation="horizontal"
android:textColor="@color/widget_text_secondary" android:gravity="center_vertical">
android:layout_marginTop="2dp" />
</LinearLayout> <ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_play_arrow_24"
android:tint="@color/widget_primary"
android:layout_marginEnd="12dp"
android:contentDescription="@string/sessions_icon" />
<!-- Problems Card --> <TextView
<LinearLayout android:id="@+id/widget_sessions_value"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" android:text="@string/_0"
android:orientation="vertical" android:textSize="40sp"
android:gravity="center" android:textStyle="bold"
android:background="@drawable/widget_stat_card_background" android:textColor="@color/widget_text_primary" />
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

@@ -12,5 +12,9 @@
<string name="shortcut_end_session_disabled">No active session to end</string> <string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget --> <!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string> <string name="widget_description">View your weekly climbing stats</string>
<string name="ascently_icon">Ascently icon</string>
<string name="weekly">Weekly</string>
<string name="sessions_icon">Sessions icon</string>
<string name="_0">0</string>
</resources> </resources>

View File

@@ -3,15 +3,14 @@
android:description="@string/widget_description" android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats" android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats" android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp" android:minWidth="110dp"
android:minHeight="180dp" android:minHeight="110dp"
android:maxResizeWidth="110dp"
android:maxResizeHeight="110dp"
android:previewImage="@drawable/ic_mountains" android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats" android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical" android:resizeMode="none"
android:targetCellWidth="4" android:targetCellWidth="2"
android:targetCellHeight="2" android:targetCellHeight="2"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" android:widgetCategory="home_screen" />
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />

View File

@@ -27,7 +27,11 @@
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.0", "@astrojs/node": "^9.5.0",
"@astrojs/starlight": "^0.36.1", "@astrojs/starlight": "^0.36.1",
"astro": "^5.14.5", "astro": "^5.14.6",
"qrcode": "^1.5.4",
"sharp": "^0.34.4" "sharp": "^0.34.4"
},
"devDependencies": {
"@types/qrcode": "^1.5.5"
} }
} }

488
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
---
import { Tabs, TabItem } from "@astrojs/starlight/components";
import { Card, CardGrid } from "@astrojs/starlight/components";
import { LinkButton } from "@astrojs/starlight/components";
import { Badge } from "@astrojs/starlight/components";
import QRCode from "./QRCode.astro";
import { downloadLinks, requirements } from "../config";
interface Props {
showQR?: boolean;
}
const { showQR = false } = Astro.props;
const hasLink = (link: string | undefined) => link && link.trim() !== "";
---
<Tabs syncKey="platform">
<TabItem label="Android" icon="star">
<CardGrid>
{
hasLink(downloadLinks.android.playStore) && (
<Card title="Google Play Store" icon="star">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.android.playStore}
variant="primary"
icon="external"
>
Get on Play Store
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.android.playStore}
size={200}
alt="QR code for Play Store"
/>
</p>
)}
</Card>
)
}
{
hasLink(downloadLinks.android.obtainium) && (
<Card title="Obtainium" icon="rocket">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.android.obtainium}
variant="primary"
icon="external"
>
Get on Obtainium
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.android.obtainium}
size={200}
alt="QR code for Obtainium"
/>
</p>
)}
</Card>
)
}
{
hasLink(downloadLinks.android.releases) && (
<Card title="Direct Download" icon="download">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.android.releases}
variant="secondary"
icon="external"
>
Download APK
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.android.releases}
size={200}
alt="QR code for APK download"
/>
</p>
)}
</Card>
)
}
</CardGrid>
<p><strong>Requirements:</strong> {requirements.android}</p>
</TabItem>
<TabItem label="iOS" icon="apple">
<CardGrid>
{
hasLink(downloadLinks.ios.appStore) && (
<Card title="App Store" icon="rocket">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.ios.appStore}
variant="primary"
icon="external"
>
Download on App Store
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.ios.appStore}
size={200}
alt="QR code for App Store"
/>
</p>
)}
</Card>
)
}
{
hasLink(downloadLinks.ios.testFlight) && (
<Card title="TestFlight Beta" icon="warning">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.ios.testFlight}
variant="secondary"
icon="external"
>
Join TestFlight
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.ios.testFlight}
size={200}
alt="QR code for TestFlight"
/>
</p>
)}
</Card>
)
}
</CardGrid>
<p><strong>Requirements:</strong> {requirements.ios}</p>
</TabItem>
</Tabs>

View File

@@ -0,0 +1,111 @@
---
import * as QR from "qrcode";
interface Props {
data: string;
size?: number;
alt?: string;
}
const { data, size = 200, alt = "QR Code" } = Astro.props;
// Generate QR code for dark mode
let darkModeQR = "";
try {
darkModeQR = await QR.toDataURL(data, {
width: size,
margin: 2,
color: {
dark: "#FFBF00",
light: "#17181C",
},
});
} catch (err) {
console.error("Failed to generate dark mode QR code:", err);
}
// Generate QR code for light mode
let lightModeQR = "";
try {
lightModeQR = await QR.toDataURL(data, {
width: size,
margin: 2,
color: {
dark: "#F24B3C",
light: "#FFFFFF",
},
});
} catch (err) {
console.error("Failed to generate light mode QR code:", err);
}
const uniqueId = `qr-${Math.random().toString(36).substr(2, 9)}`;
---
{
(darkModeQR || lightModeQR) && (
<img
id={uniqueId}
alt={alt}
width={size}
height={size}
data-light-src={lightModeQR}
data-dark-src={darkModeQR}
style="margin: auto;"
/>
)
}
<script is:inline define:vars={{ uniqueId, lightModeQR, darkModeQR }}>
(function () {
const img = document.getElementById(uniqueId);
if (!img) return;
const theme = document.documentElement.getAttribute("data-theme");
if (theme === "dark" && darkModeQR) {
img.setAttribute("src", darkModeQR);
} else if (lightModeQR) {
img.setAttribute("src", lightModeQR);
}
})();
</script>
<script>
function updateQRCodes() {
const theme = document.documentElement.getAttribute("data-theme");
const qrImages = document.querySelectorAll(
"img[data-light-src][data-dark-src]",
);
qrImages.forEach((img) => {
const lightSrc = img.getAttribute("data-light-src");
const darkSrc = img.getAttribute("data-dark-src");
if (theme === "dark" && darkSrc) {
img.setAttribute("src", darkSrc);
} else if (lightSrc) {
img.setAttribute("src", lightSrc);
}
});
}
// Set initial theme on page load
updateQRCodes();
// Watch for theme changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.attributeName === "data-theme"
) {
updateQRCodes();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
</script>

17
docs/src/config.ts Normal file
View File

@@ -0,0 +1,17 @@
export const requirements = {
android: "Android 12+",
ios: "iOS 17+",
} as const;
export const downloadLinks = {
android: {
releases: "https://git.atri.dad/atridad/Ascently/releases",
obtainium:
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases",
playStore: "",
},
ios: {
appStore: "https://apps.apple.com/ca/app/ascently/id6753959144",
testFlight: "https://testflight.apple.com/join/E2DYRGH8",
},
} as const;

View File

@@ -1,26 +0,0 @@
---
title: Download
description: Get Ascently on your Android or iOS device
---
## Android
### Option 1: Direct APK Download
Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases).
### Option 2: Obtainium
Use Obtainium for automatic updates:
[<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.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%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%22Ascently%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)
## iOS
### App Store
Download from the app store [here](https://apps.apple.com/ca/app/ascently/id6753959144)
### TestFlight Beta
Join the TestFlight beta [here](https://testflight.apple.com/join/E2DYRGH8)
## Requirements
- **Android 12+** or **iOS 17+**

View File

@@ -0,0 +1,10 @@
---
title: Download
description: Get Ascently on your Android or iOS device
---
import DownloadButtons from '../../components/DownloadButtons.astro';
Get Ascently on your device and start tracking your climbs today!
<DownloadButtons showQR={true} />

View File

@@ -5,7 +5,7 @@ description: Ascently's Privacy Policy
**Last updated: September 29, 2025** **Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software. This Privacy Policy describes my policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection ## No Data Collection
@@ -36,7 +36,7 @@ You may optionally integrate with Apple Health or Android Health Connect to impo
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private. This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
## Contact Us ## Contact
If you have any questions about this Privacy Policy, you can contact me: If you have any questions about this Privacy Policy, you can contact me: