0.1.0
61
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Permissions for file access and camera -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Permissions for notifications and foreground service -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.OpenClimb"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.OpenClimb">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FileProvider for sharing images -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Session tracking service -->
|
||||
<service
|
||||
android:name=".service.SessionTrackingService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<meta-data
|
||||
android:name="android.app.foreground_service_type"
|
||||
android:value="specialUse" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
27
app/src/main/java/com/atridad/openclimb/MainActivity.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.atridad.openclimb
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.atridad.openclimb.ui.OpenClimbApp
|
||||
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
OpenClimbTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
OpenClimbApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.atridad.openclimb.data.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun fromClimbTypeList(value: List<ClimbType>): String {
|
||||
return Json.encodeToString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toClimbTypeList(value: String): List<ClimbType> {
|
||||
return Json.decodeFromString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromDifficultySystemList(value: List<DifficultySystem>): String {
|
||||
return Json.encodeToString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toDifficultySystemList(value: String): List<DifficultySystem> {
|
||||
return Json.decodeFromString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(value: List<String>): String {
|
||||
return Json.encodeToString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(value: String): List<String> {
|
||||
return Json.decodeFromString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromDifficultyGrade(value: DifficultyGrade): String {
|
||||
return Json.encodeToString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toDifficultyGrade(value: String): DifficultyGrade {
|
||||
return Json.decodeFromString(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromClimbType(value: ClimbType): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toClimbType(value: String): ClimbType {
|
||||
return ClimbType.valueOf(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromAttemptResult(value: AttemptResult): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toAttemptResult(value: String): AttemptResult {
|
||||
return AttemptResult.valueOf(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromSessionStatus(value: SessionStatus): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toSessionStatus(value: String): SessionStatus {
|
||||
return SessionStatus.valueOf(value)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.atridad.openclimb.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import android.content.Context
|
||||
import com.atridad.openclimb.data.database.dao.*
|
||||
import com.atridad.openclimb.data.model.*
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
Gym::class,
|
||||
Problem::class,
|
||||
ClimbSession::class,
|
||||
Attempt::class
|
||||
],
|
||||
version = 5,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class OpenClimbDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun gymDao(): GymDao
|
||||
abstract fun problemDao(): ProblemDao
|
||||
abstract fun climbSessionDao(): ClimbSessionDao
|
||||
abstract fun attemptDao(): AttemptDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: OpenClimbDatabase? = null
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
val cursor = database.query("PRAGMA table_info(climb_sessions)")
|
||||
val existingColumns = mutableSetOf<String>()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name"))
|
||||
existingColumns.add(columnName)
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
if (!existingColumns.contains("startTime")) {
|
||||
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
|
||||
}
|
||||
if (!existingColumns.contains("endTime")) {
|
||||
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
|
||||
}
|
||||
if (!existingColumns.contains("status")) {
|
||||
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'")
|
||||
}
|
||||
|
||||
database.execSQL("UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL")
|
||||
database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): OpenClimbDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
OpenClimbDatabase::class.java,
|
||||
"openclimb_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||
.enableMultiInstanceInvalidation()
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.atridad.openclimb.data.database.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.atridad.openclimb.data.model.Attempt
|
||||
import com.atridad.openclimb.data.model.AttemptResult
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AttemptDao {
|
||||
|
||||
@Query("SELECT * FROM attempts ORDER BY timestamp DESC")
|
||||
fun getAllAttempts(): Flow<List<Attempt>>
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE id = :id")
|
||||
suspend fun getAttemptById(id: String): Attempt?
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId ORDER BY timestamp ASC")
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>>
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC")
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>>
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId AND problemId = :problemId ORDER BY timestamp ASC")
|
||||
fun getAttemptsBySessionAndProblem(sessionId: String, problemId: String): Flow<List<Attempt>>
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE result = :result ORDER BY timestamp DESC")
|
||||
fun getAttemptsByResult(result: AttemptResult): Flow<List<Attempt>>
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE timestamp BETWEEN :startDate AND :endDate ORDER BY timestamp DESC")
|
||||
fun getAttemptsInDateRange(startDate: String, endDate: String): Flow<List<Attempt>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAttempt(attempt: Attempt)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAttempts(attempts: List<Attempt>)
|
||||
|
||||
@Update
|
||||
suspend fun updateAttempt(attempt: Attempt)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteAttempt(attempt: Attempt)
|
||||
|
||||
@Query("DELETE FROM attempts WHERE id = :id")
|
||||
suspend fun deleteAttemptById(id: String)
|
||||
|
||||
@Query("DELETE FROM attempts WHERE sessionId = :sessionId")
|
||||
suspend fun deleteAttemptsBySession(sessionId: String)
|
||||
|
||||
@Query("DELETE FROM attempts WHERE problemId = :problemId")
|
||||
suspend fun deleteAttemptsByProblem(problemId: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM attempts")
|
||||
suspend fun getAttemptsCount(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
||||
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId")
|
||||
suspend fun getAttemptsCountByProblem(problemId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM attempts WHERE result = :result")
|
||||
suspend fun getAttemptsCountByResult(result: AttemptResult): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT')")
|
||||
suspend fun getSuccessfulAttemptsCountByProblem(problemId: String): Int
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT') ORDER BY timestamp ASC LIMIT 1")
|
||||
suspend fun getFirstSuccessfulAttempt(problemId: String): Attempt?
|
||||
|
||||
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC LIMIT 1")
|
||||
suspend fun getLatestAttemptForProblem(problemId: String): Attempt?
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.atridad.openclimb.data.database.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.SessionStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ClimbSessionDao {
|
||||
|
||||
@Query("SELECT * FROM climb_sessions ORDER BY date DESC")
|
||||
fun getAllSessions(): Flow<List<ClimbSession>>
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE id = :id")
|
||||
suspend fun getSessionById(id: String): ClimbSession?
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE gymId = :gymId ORDER BY date DESC")
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>>
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE date = :date ORDER BY createdAt DESC")
|
||||
fun getSessionsByDate(date: String): Flow<List<ClimbSession>>
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getSessionsInDateRange(startDate: String, endDate: String): Flow<List<ClimbSession>>
|
||||
|
||||
@Query("SELECT * FROM climb_sessions ORDER BY date DESC LIMIT :limit")
|
||||
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSession(session: ClimbSession)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSessions(sessions: List<ClimbSession>)
|
||||
|
||||
@Update
|
||||
suspend fun updateSession(session: ClimbSession)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteSession(session: ClimbSession)
|
||||
|
||||
@Query("DELETE FROM climb_sessions WHERE id = :id")
|
||||
suspend fun deleteSessionById(id: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM climb_sessions")
|
||||
suspend fun getSessionsCount(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM climb_sessions WHERE gymId = :gymId")
|
||||
suspend fun getSessionsCountByGym(gymId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate")
|
||||
suspend fun getSessionsCountInDateRange(startDate: String, endDate: String): Int
|
||||
|
||||
@Query("SELECT DISTINCT date FROM climb_sessions ORDER BY date DESC")
|
||||
suspend fun getUniqueDates(): List<String>
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE status = :status ORDER BY date DESC")
|
||||
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>>
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||
suspend fun getActiveSession(): ClimbSession?
|
||||
|
||||
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.atridad.openclimb.data.database.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.atridad.openclimb.data.model.ClimbType
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface GymDao {
|
||||
|
||||
@Query("SELECT * FROM gyms ORDER BY name ASC")
|
||||
fun getAllGyms(): Flow<List<Gym>>
|
||||
|
||||
@Query("SELECT * FROM gyms WHERE id = :id")
|
||||
suspend fun getGymById(id: String): Gym?
|
||||
|
||||
@Query("SELECT * FROM gyms WHERE :climbType IN (supportedClimbTypes)")
|
||||
fun getGymsByClimbType(climbType: ClimbType): Flow<List<Gym>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertGym(gym: Gym)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertGyms(gyms: List<Gym>)
|
||||
|
||||
@Update
|
||||
suspend fun updateGym(gym: Gym)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteGym(gym: Gym)
|
||||
|
||||
@Query("DELETE FROM gyms WHERE id = :id")
|
||||
suspend fun deleteGymById(id: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM gyms")
|
||||
suspend fun getGymsCount(): Int
|
||||
|
||||
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
||||
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.atridad.openclimb.data.database.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.atridad.openclimb.data.model.ClimbType
|
||||
import com.atridad.openclimb.data.model.Problem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ProblemDao {
|
||||
|
||||
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
||||
fun getAllProblems(): Flow<List<Problem>>
|
||||
|
||||
@Query("SELECT * FROM problems WHERE id = :id")
|
||||
suspend fun getProblemById(id: String): Problem?
|
||||
|
||||
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||
|
||||
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
|
||||
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
||||
|
||||
@Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC")
|
||||
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
|
||||
|
||||
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
|
||||
fun getActiveProblems(): Flow<List<Problem>>
|
||||
|
||||
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
|
||||
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertProblem(problem: Problem)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertProblems(problems: List<Problem>)
|
||||
|
||||
@Update
|
||||
suspend fun updateProblem(problem: Problem)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteProblem(problem: Problem)
|
||||
|
||||
@Query("DELETE FROM problems WHERE id = :id")
|
||||
suspend fun deleteProblemById(id: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
|
||||
suspend fun getProblemsCountByGym(gymId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
|
||||
suspend fun getActiveProblemsCount(): Int
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM problems
|
||||
WHERE (name LIKE '%' || :searchQuery || '%'
|
||||
OR description LIKE '%' || :searchQuery || '%'
|
||||
OR location LIKE '%' || :searchQuery || '%'
|
||||
OR setter LIKE '%' || :searchQuery || '%')
|
||||
ORDER BY updatedAt DESC
|
||||
""")
|
||||
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Serializable
|
||||
enum class AttemptResult {
|
||||
SUCCESS, // Completed the problem/route
|
||||
FALL, // Fell but made progress
|
||||
NO_PROGRESS, // Couldn't make meaningful progress
|
||||
FLASH, // Completed on first try
|
||||
REDPOINT, // Completed after previous attempts
|
||||
ONSIGHT // Completed on first try without prior knowledge
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "attempts",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ClimbSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Problem::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["problemId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["sessionId"]),
|
||||
Index(value = ["problemId"])
|
||||
]
|
||||
)
|
||||
@Serializable
|
||||
data class Attempt(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val sessionId: String,
|
||||
val problemId: String,
|
||||
val result: AttemptResult,
|
||||
val highestHold: String? = null, // Description of the highest hold reached
|
||||
val notes: String? = null,
|
||||
val duration: Long? = null, // Attempt duration in seconds
|
||||
val restTime: Long? = null, // Rest time before this attempt in seconds
|
||||
val timestamp: String, // When this attempt was made
|
||||
val createdAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
sessionId: String,
|
||||
problemId: String,
|
||||
result: AttemptResult,
|
||||
highestHold: String? = null,
|
||||
notes: String? = null,
|
||||
duration: Long? = null,
|
||||
restTime: Long? = null,
|
||||
timestamp: String = LocalDateTime.now().toString()
|
||||
): Attempt {
|
||||
val now = LocalDateTime.now().toString()
|
||||
return Attempt(
|
||||
id = java.util.UUID.randomUUID().toString(),
|
||||
sessionId = sessionId,
|
||||
problemId = problemId,
|
||||
result = result,
|
||||
highestHold = highestHold,
|
||||
notes = notes,
|
||||
duration = duration,
|
||||
restTime = restTime,
|
||||
timestamp = timestamp,
|
||||
createdAt = now
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Serializable
|
||||
enum class SessionStatus {
|
||||
ACTIVE,
|
||||
COMPLETED,
|
||||
PAUSED
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "climb_sessions",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Gym::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["gymId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["gymId"])]
|
||||
)
|
||||
@Serializable
|
||||
data class ClimbSession(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val gymId: String,
|
||||
val date: String, // ISO date string
|
||||
val startTime: String? = null, // When session was started
|
||||
val endTime: String? = null, // When session was completed
|
||||
val duration: Long? = null, // Duration in minutes (calculated when completed)
|
||||
val status: SessionStatus = SessionStatus.ACTIVE,
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
gymId: String,
|
||||
notes: String? = null
|
||||
): ClimbSession {
|
||||
val now = LocalDateTime.now().toString()
|
||||
return ClimbSession(
|
||||
id = java.util.UUID.randomUUID().toString(),
|
||||
gymId = gymId,
|
||||
date = now,
|
||||
startTime = now,
|
||||
status = SessionStatus.ACTIVE,
|
||||
notes = notes,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
|
||||
fun ClimbSession.complete(): ClimbSession {
|
||||
val endTime = LocalDateTime.now().toString()
|
||||
val durationMinutes = if (startTime != null) {
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val end = LocalDateTime.parse(endTime)
|
||||
java.time.Duration.between(start, end).toMinutes()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else null
|
||||
|
||||
return this.copy(
|
||||
endTime = endTime,
|
||||
duration = durationMinutes,
|
||||
status = SessionStatus.COMPLETED,
|
||||
updatedAt = LocalDateTime.now().toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ClimbType {
|
||||
ROPE,
|
||||
BOULDER
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class DifficultySystem {
|
||||
// Rope climbing systems
|
||||
YDS, // Yosemite Decimal System (5.1 - 5.15d)
|
||||
FRENCH, // French system (3 - 9c+)
|
||||
UIAA, // UIAA system (I - XII+)
|
||||
BRITISH, // British system (Mod - E11)
|
||||
|
||||
// Bouldering systems
|
||||
V_SCALE, // V-Scale (VB - V17)
|
||||
FONT, // Fontainebleau (3 - 9A+)
|
||||
|
||||
// Custom system for gyms that use their own colors/naming
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DifficultyGrade(
|
||||
val system: DifficultySystem,
|
||||
val grade: String,
|
||||
val numericValue: Int // For comparison and analytics
|
||||
)
|
||||
45
app/src/main/java/com/atridad/openclimb/data/model/Gym.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity(tableName = "gyms")
|
||||
@Serializable
|
||||
data class Gym(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val location: String? = null,
|
||||
val supportedClimbTypes: List<ClimbType>,
|
||||
val difficultySystems: List<DifficultySystem>, // What systems this gym uses
|
||||
val customDifficultyGrades: List<String> = emptyList(), // For gyms using colors/custom names
|
||||
val notes: String? = null,
|
||||
val createdAt: String, // ISO string format for serialization
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
name: String,
|
||||
location: String? = null,
|
||||
supportedClimbTypes: List<ClimbType>,
|
||||
difficultySystems: List<DifficultySystem>,
|
||||
customDifficultyGrades: List<String> = emptyList(),
|
||||
notes: String? = null
|
||||
): Gym {
|
||||
val now = LocalDateTime.now().toString()
|
||||
return Gym(
|
||||
id = java.util.UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
location = location,
|
||||
supportedClimbTypes = supportedClimbTypes,
|
||||
difficultySystems = difficultySystems,
|
||||
customDifficultyGrades = customDifficultyGrades,
|
||||
notes = notes,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity(
|
||||
tableName = "problems",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Gym::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["gymId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["gymId"])]
|
||||
)
|
||||
@Serializable
|
||||
data class Problem(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val gymId: String,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val climbType: ClimbType,
|
||||
val difficulty: DifficultyGrade,
|
||||
val setter: String? = null, // Route setter name
|
||||
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy"
|
||||
val location: String? = null, // Wall section, area in gym
|
||||
val imagePaths: List<String> = emptyList(), // Local file paths to photos
|
||||
val isActive: Boolean = true, // Whether the problem is still up
|
||||
val dateSet: String? = null, // When the problem was set
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
gymId: String,
|
||||
name: String? = null,
|
||||
description: String? = null,
|
||||
climbType: ClimbType,
|
||||
difficulty: DifficultyGrade,
|
||||
setter: String? = null,
|
||||
tags: List<String> = emptyList(),
|
||||
location: String? = null,
|
||||
imagePaths: List<String> = emptyList(),
|
||||
dateSet: String? = null,
|
||||
notes: String? = null
|
||||
): Problem {
|
||||
val now = LocalDateTime.now().toString()
|
||||
return Problem(
|
||||
id = java.util.UUID.randomUUID().toString(),
|
||||
gymId = gymId,
|
||||
name = name,
|
||||
description = description,
|
||||
climbType = climbType,
|
||||
difficulty = difficulty,
|
||||
setter = setter,
|
||||
tags = tags,
|
||||
location = location,
|
||||
imagePaths = imagePaths,
|
||||
isActive = true,
|
||||
dateSet = dateSet,
|
||||
notes = notes,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Serializable
|
||||
data class ProblemProgress(
|
||||
val problemId: String,
|
||||
val totalAttempts: Int,
|
||||
val successfulAttempts: Int,
|
||||
val firstAttemptDate: String,
|
||||
val lastAttemptDate: String,
|
||||
val bestResult: AttemptResult,
|
||||
val averageAttempts: Double,
|
||||
val successRate: Double,
|
||||
val personalBest: String? = null, // Highest hold or completion details
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionSummary(
|
||||
val sessionId: String,
|
||||
val date: String,
|
||||
val totalAttempts: Int,
|
||||
val successfulAttempts: Int,
|
||||
val uniqueProblems: Int,
|
||||
val avgDifficulty: Double,
|
||||
val maxDifficulty: DifficultyGrade,
|
||||
val climbTypes: List<ClimbType>,
|
||||
val duration: Long?, // in minutes
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClimbingStats(
|
||||
val totalSessions: Int,
|
||||
val totalAttempts: Int,
|
||||
val totalSuccesses: Int,
|
||||
val overallSuccessRate: Double,
|
||||
val uniqueProblemsAttempted: Int,
|
||||
val uniqueProblemsCompleted: Int,
|
||||
val averageSessionDuration: Double, // in minutes
|
||||
val favoriteGym: String?,
|
||||
val mostAttemptedDifficulty: DifficultyGrade?,
|
||||
val currentStreak: Int, // consecutive sessions
|
||||
val longestStreak: Int,
|
||||
val firstClimbDate: String?,
|
||||
val lastClimbDate: String?,
|
||||
val improvementTrend: String? = null // "improving", "stable", "declining"
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class ClimbRepository(
|
||||
private val database: OpenClimbDatabase,
|
||||
private val context: Context
|
||||
) {
|
||||
private val gymDao = database.gymDao()
|
||||
private val problemDao = database.problemDao()
|
||||
private val sessionDao = database.climbSessionDao()
|
||||
private val attemptDao = database.attemptDao()
|
||||
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
// Gym operations
|
||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
||||
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||
suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym)
|
||||
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||
|
||||
// Problem operations
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||
fun getActiveProblems(): Flow<List<Problem>> = problemDao.getActiveProblems()
|
||||
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
|
||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||
|
||||
// Session operations
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId)
|
||||
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>> = sessionDao.getRecentSessions(limit)
|
||||
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
|
||||
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
||||
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>> = sessionDao.getSessionsByStatus(status)
|
||||
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
||||
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
||||
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
|
||||
|
||||
// Attempt operations
|
||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
|
||||
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
||||
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
||||
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
||||
|
||||
|
||||
|
||||
// JSON Export functionality
|
||||
suspend fun exportAllDataToJson(directory: File? = null): File {
|
||||
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
|
||||
if (!exportDir.exists()) {
|
||||
exportDir.mkdirs()
|
||||
}
|
||||
|
||||
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
||||
val exportFile = File(exportDir, "openclimb_export_$timestamp.json")
|
||||
|
||||
val allGyms = gymDao.getAllGyms().first()
|
||||
val allProblems = problemDao.getAllProblems().first()
|
||||
val allSessions = sessionDao.getAllSessions().first()
|
||||
val allAttempts = attemptDao.getAllAttempts().first()
|
||||
|
||||
val exportData = ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
val jsonString = json.encodeToString(exportData)
|
||||
exportFile.writeText(jsonString)
|
||||
|
||||
return exportFile
|
||||
}
|
||||
|
||||
suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) {
|
||||
val gyms = gymDao.getAllGyms().first()
|
||||
val problems = problemDao.getAllProblems().first()
|
||||
val sessions = sessionDao.getAllSessions().first()
|
||||
val attempts = attemptDao.getAllAttempts().first()
|
||||
|
||||
val exportData = ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
gyms = gyms,
|
||||
problems = problems,
|
||||
sessions = sessions,
|
||||
attempts = attempts
|
||||
)
|
||||
|
||||
val jsonString = json.encodeToString(exportData)
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(jsonString.toByteArray())
|
||||
} ?: throw Exception("Could not open output stream")
|
||||
}
|
||||
|
||||
suspend fun importDataFromJson(file: File) {
|
||||
try {
|
||||
val jsonContent = file.readText()
|
||||
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
|
||||
|
||||
// Import gyms (replace if exists due to primary key constraint)
|
||||
importData.gyms.forEach { gym ->
|
||||
try {
|
||||
gymDao.insertGym(gym)
|
||||
} catch (e: Exception) {
|
||||
// If insertion fails due to primary key conflict, update instead
|
||||
gymDao.updateGym(gym)
|
||||
}
|
||||
}
|
||||
|
||||
// Import problems
|
||||
importData.problems.forEach { problem ->
|
||||
try {
|
||||
problemDao.insertProblem(problem)
|
||||
} catch (e: Exception) {
|
||||
problemDao.updateProblem(problem)
|
||||
}
|
||||
}
|
||||
|
||||
// Import sessions
|
||||
importData.sessions.forEach { session ->
|
||||
try {
|
||||
sessionDao.insertSession(session)
|
||||
} catch (e: Exception) {
|
||||
sessionDao.updateSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// Import attempts
|
||||
importData.attempts.forEach { attempt ->
|
||||
try {
|
||||
attemptDao.insertAttempt(attempt)
|
||||
} catch (e: Exception) {
|
||||
attemptDao.updateAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import data: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class ClimbDataExport(
|
||||
val exportedAt: String,
|
||||
val gyms: List<Gym>,
|
||||
val problems: List<Problem>,
|
||||
val sessions: List<ClimbSession>,
|
||||
val attempts: List<Attempt>
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.atridad.openclimb.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class BottomNavigationItem(
|
||||
val screen: Screen,
|
||||
val icon: ImageVector,
|
||||
val label: String
|
||||
)
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Sessions,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
label = "Sessions"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Problems,
|
||||
icon = Icons.Default.Star,
|
||||
label = "Problems"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Analytics,
|
||||
icon = Icons.Default.Info,
|
||||
label = "Analytics"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Gyms,
|
||||
icon = Icons.Default.LocationOn,
|
||||
label = "Gyms"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Settings,
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Settings"
|
||||
)
|
||||
)
|
||||
42
app/src/main/java/com/atridad/openclimb/navigation/Screen.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.atridad.openclimb.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class Screen {
|
||||
@Serializable
|
||||
data object Sessions : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Problems : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Analytics : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Gyms : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen()
|
||||
|
||||
// Detail screens
|
||||
@Serializable
|
||||
data class SessionDetail(val sessionId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class ProblemDetail(val problemId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class GymDetail(val gymId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class AddEditGym(val gymId: String? = null) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.atridad.openclimb.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
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.*
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class SessionTrackingService : Service() {
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
private lateinit var repository: ClimbRepository
|
||||
|
||||
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): Intent {
|
||||
return Intent(context, SessionTrackingService::class.java).apply {
|
||||
action = ACTION_STOP_SESSION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val database = OpenClimbDatabase.getDatabase(this)
|
||||
repository = ClimbRepository(database, this)
|
||||
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_SESSION -> {
|
||||
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
||||
if (sessionId != null) {
|
||||
startSessionTracking(sessionId)
|
||||
}
|
||||
}
|
||||
ACTION_STOP_SESSION -> {
|
||||
stopSessionTracking()
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun startSessionTracking(sessionId: String) {
|
||||
notificationJob?.cancel()
|
||||
notificationJob = serviceScope.launch {
|
||||
while (isActive) {
|
||||
updateNotification(sessionId)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSessionTracking() {
|
||||
notificationJob?.cancel()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private suspend fun updateNotification(sessionId: String) {
|
||||
try {
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
||||
stopSessionTracking()
|
||||
return
|
||||
}
|
||||
|
||||
val gym = repository.getGymById(session.gymId)
|
||||
val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
||||
|
||||
val duration = session.startTime?.let { startTime ->
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(start, now)
|
||||
val hours = minutes / 60
|
||||
val remainingMinutes = minutes % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
||||
else -> "< 1m"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Active"
|
||||
}
|
||||
} ?: "Active"
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("OpenClimb Session Active")
|
||||
.setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts")
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(createOpenAppIntent())
|
||||
.addAction(
|
||||
R.drawable.ic_launcher_foreground,
|
||||
"Open Session",
|
||||
createOpenAppIntent()
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_launcher_foreground,
|
||||
"End Session",
|
||||
createStopIntent()
|
||||
)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
} catch (e: Exception) {
|
||||
// Handle errors gracefully
|
||||
stopSessionTracking()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOpenAppIntent(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createStopIntent(): PendingIntent {
|
||||
val intent = createStopIntent(this)
|
||||
return PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session Tracking",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows active climbing session information"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
notificationJob?.cancel()
|
||||
serviceScope.cancel()
|
||||
}
|
||||
}
|
||||
274
app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package com.atridad.openclimb.ui
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.navigation.Screen
|
||||
import com.atridad.openclimb.navigation.bottomNavigationItems
|
||||
import com.atridad.openclimb.ui.screens.*
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OpenClimbApp() {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = currentBackStackEntry?.destination?.route
|
||||
|
||||
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
||||
val repository = remember { ClimbRepository(database, context) }
|
||||
val viewModel: ClimbViewModel = viewModel(
|
||||
factory = ClimbViewModelFactory(repository)
|
||||
)
|
||||
|
||||
// FAB configuration
|
||||
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
OpenClimbBottomNavigation(navController = navController)
|
||||
},
|
||||
floatingActionButton = {
|
||||
fabConfig?.let { config ->
|
||||
FloatingActionButton(
|
||||
onClick = config.onClick,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = config.icon,
|
||||
contentDescription = config.contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Sessions,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
// Main screens
|
||||
composable<Screen.Sessions> {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
LaunchedEffect(gyms, activeSession) {
|
||||
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Start Session",
|
||||
onClick = {
|
||||
if (gyms.size == 1) {
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
},
|
||||
onNavigateToAddSession = { gymId ->
|
||||
navController.navigate(Screen.AddEditSession(gymId = gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Problems> {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
LaunchedEffect(gyms) {
|
||||
fabConfig = if (gyms.isNotEmpty()) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Problem",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditProblem())
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ProblemsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
},
|
||||
onNavigateToAddProblem = { gymId ->
|
||||
navController.navigate(Screen.AddEditProblem(gymId = gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Analytics> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig = null // No FAB for analytics
|
||||
}
|
||||
AnalyticsScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composable<Screen.Gyms> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig = FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Gym",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditGym())
|
||||
}
|
||||
)
|
||||
}
|
||||
GymsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToGymDetail = { gymId ->
|
||||
navController.navigate(Screen.GymDetail(gymId))
|
||||
},
|
||||
onNavigateToAddGym = {
|
||||
navController.navigate(Screen.AddEditGym())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Settings> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig = null // No FAB for settings
|
||||
}
|
||||
SettingsScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Detail screens
|
||||
composable<Screen.SessionDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
||||
SessionDetailScreen(
|
||||
sessionId = args.sessionId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { sessionId ->
|
||||
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.ProblemDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
|
||||
ProblemDetailScreen(
|
||||
problemId = args.problemId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { problemId ->
|
||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.GymDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.GymDetail>()
|
||||
GymDetailScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { gymId ->
|
||||
navController.navigate(Screen.AddEditGym(gymId = gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.AddEditGym> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditGym>()
|
||||
AddEditGymScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.AddEditProblem> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
|
||||
AddEditProblemScreen(
|
||||
problemId = args.problemId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.AddEditSession> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||
AddEditSessionScreen(
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenClimbBottomNavigation(navController: NavHostController) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
NavigationBar {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
val isSelected = when (item.screen) {
|
||||
is Screen.Sessions -> currentRoute?.contains("Session") == true
|
||||
is Screen.Problems -> currentRoute?.contains("Problem") == true
|
||||
is Screen.Gyms -> currentRoute?.contains("Gym") == true
|
||||
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
|
||||
is Screen.Settings -> currentRoute?.contains("Settings") == true
|
||||
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
|
||||
}
|
||||
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen) {
|
||||
// Pop up to the start destination of the graph to
|
||||
// avoid building up a large stack of destinations
|
||||
// on the back stack as users select items
|
||||
popUpTo(Screen.Sessions) {
|
||||
saveState = true
|
||||
}
|
||||
// Avoid multiple copies of the same destination when
|
||||
// reselecting the same item
|
||||
launchSingleTop = true
|
||||
// Restore state when reselecting a previously selected item
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FabConfig(
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.atridad.openclimb.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Composable
|
||||
fun ActiveSessionBanner(
|
||||
activeSession: ClimbSession?,
|
||||
gym: Gym?,
|
||||
onSessionClick: () -> Unit,
|
||||
onEndSession: () -> Unit
|
||||
) {
|
||||
if (activeSession != null) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSessionClick() },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Active session",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Active Session",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = gym?.name ?: "Unknown Gym",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
|
||||
activeSession.startTime?.let { startTime ->
|
||||
val duration = calculateDuration(startTime)
|
||||
Text(
|
||||
text = duration,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onEndSession,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "End session"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartSessionButton(
|
||||
gyms: List<Gym>,
|
||||
onStartSession: (String) -> Unit
|
||||
) {
|
||||
var showGymSelection by remember { mutableStateOf(false) }
|
||||
|
||||
if (gyms.isEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "No gyms available",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Add a gym first to start a session",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { showGymSelection = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Start Session")
|
||||
}
|
||||
}
|
||||
|
||||
if (showGymSelection) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showGymSelection = false },
|
||||
title = { Text("Select Gym") },
|
||||
text = {
|
||||
Column {
|
||||
gyms.forEach { gym ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
onStartSession(gym.id)
|
||||
showGymSelection = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = gym.name,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showGymSelection = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateDuration(startTimeString: String): String {
|
||||
return try {
|
||||
val startTime = LocalDateTime.parse(startTimeString)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(startTime, now)
|
||||
val hours = minutes / 60
|
||||
val remainingMinutes = minutes % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
||||
else -> "< 1m"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Active"
|
||||
}
|
||||
}
|
||||
1007
app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt
Normal file
@@ -0,0 +1,276 @@
|
||||
package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@Composable
|
||||
fun AnalyticsScreen(
|
||||
viewModel: ClimbViewModel
|
||||
) {
|
||||
val sessions by viewModel.sessions.collectAsState()
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
val attempts by viewModel.attempts.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Analytics",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Overall Stats
|
||||
item {
|
||||
OverallStatsCard(
|
||||
totalSessions = sessions.size,
|
||||
totalProblems = problems.size,
|
||||
totalAttempts = attempts.size,
|
||||
totalGyms = gyms.size
|
||||
)
|
||||
}
|
||||
|
||||
// Success Rate
|
||||
item {
|
||||
val successfulAttempts = attempts.count {
|
||||
it.result.name in listOf("SUCCESS", "FLASH", "REDPOINT", "ONSIGHT")
|
||||
}
|
||||
val successRate = if (attempts.isNotEmpty()) {
|
||||
(successfulAttempts.toDouble() / attempts.size * 100).toInt()
|
||||
} else 0
|
||||
|
||||
SuccessRateCard(
|
||||
successRate = successRate,
|
||||
successfulAttempts = successfulAttempts,
|
||||
totalAttempts = attempts.size
|
||||
)
|
||||
}
|
||||
|
||||
// Favorite Gym
|
||||
item {
|
||||
val favoriteGym = sessions
|
||||
.groupBy { it.gymId }
|
||||
.maxByOrNull { it.value.size }
|
||||
?.let { (gymId, sessions) ->
|
||||
gyms.find { it.id == gymId }?.name to sessions.size
|
||||
}
|
||||
|
||||
FavoriteGymCard(
|
||||
gymName = favoriteGym?.first ?: "No sessions yet",
|
||||
sessionCount = favoriteGym?.second ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Recent Activity
|
||||
item {
|
||||
val recentSessions = sessions.take(5)
|
||||
RecentActivityCard(recentSessions = recentSessions.size)
|
||||
}
|
||||
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Progress Charts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Detailed charts and analytics coming soon!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "📊",
|
||||
style = MaterialTheme.typography.displaySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OverallStatsCard(
|
||||
totalSessions: Int,
|
||||
totalProblems: Int,
|
||||
totalAttempts: Int,
|
||||
totalGyms: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Overall Stats",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatItem(label = "Sessions", value = totalSessions.toString())
|
||||
StatItem(label = "Problems", value = totalProblems.toString())
|
||||
StatItem(label = "Attempts", value = totalAttempts.toString())
|
||||
StatItem(label = "Gyms", value = totalGyms.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun SuccessRateCard(
|
||||
successRate: Int,
|
||||
successfulAttempts: Int,
|
||||
totalAttempts: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Success Rate",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "$successRate%",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "$successfulAttempts successful",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "out of $totalAttempts attempts",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FavoriteGymCard(
|
||||
gymName: String,
|
||||
sessionCount: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Favorite Gym",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (sessionCount > 0) {
|
||||
Text(
|
||||
text = "$sessionCount sessions",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecentActivityCard(
|
||||
recentSessions: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Recent Activity",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (recentSessions > 0) {
|
||||
"You've had $recentSessions recent sessions"
|
||||
} else {
|
||||
"No recent activity"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1824
app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
Normal file
127
app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt
Normal file
@@ -0,0 +1,127 @@
|
||||
package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GymsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToGymDetail: (String) -> Unit,
|
||||
onNavigateToAddGym: () -> Unit
|
||||
) {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Climbing Gyms",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (gyms.isEmpty()) {
|
||||
EmptyStateMessage(
|
||||
title = "No Gyms Added",
|
||||
message = "Add your favorite climbing gyms to start tracking your progress!",
|
||||
onActionClick = { },
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(gyms) { gym ->
|
||||
GymCard(
|
||||
gym = gym,
|
||||
onClick = { onNavigateToGymDetail(gym.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GymCard(
|
||||
gym: Gym,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = gym.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
gym.location?.let { location ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row {
|
||||
gym.supportedClimbTypes.forEach { climbType ->
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = {
|
||||
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||
},
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (gym.difficultySystems.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
gym.notes?.let { notes ->
|
||||
if (notes.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.data.model.Problem
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProblemsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToProblemDetail: (String) -> Unit,
|
||||
onNavigateToAddProblem: (String?) -> Unit
|
||||
) {
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Problems & Routes",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (problems.isEmpty()) {
|
||||
EmptyStateMessage(
|
||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet",
|
||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!",
|
||||
onActionClick = { },
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(problems) { problem ->
|
||||
ProblemCard(
|
||||
problem = problem,
|
||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToProblemDetail(problem.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProblemCard(
|
||||
problem: Problem,
|
||||
gymName: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = problem.name ?: "Unnamed Problem",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = problem.difficulty.grade,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
problem.location?.let { location ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Location: $location",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (problem.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row {
|
||||
problem.tags.take(3).forEach { tag ->
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = { Text(tag) },
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!problem.isActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Inactive",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.SessionStatus
|
||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToSessionDetail: (String) -> Unit,
|
||||
onNavigateToAddSession: (String?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sessions by viewModel.sessions.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
|
||||
// Filter out active sessions from regular session list
|
||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
val activeSessionGym = activeSession?.let { session ->
|
||||
gyms.find { it.id == session.gymId }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Climbing Sessions",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Active session banner
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = {
|
||||
activeSession?.let { onNavigateToSessionDetail(it.id) }
|
||||
},
|
||||
onEndSession = {
|
||||
activeSession?.let {
|
||||
viewModel.endSession(context, it.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (activeSession != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (completedSessions.isEmpty() && activeSession == null) {
|
||||
EmptyStateMessage(
|
||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!",
|
||||
onActionClick = { },
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionCard(
|
||||
session: ClimbSession,
|
||||
gymName: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = formatDate(session.date),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
session.duration?.let { duration ->
|
||||
Text(
|
||||
text = "Duration: ${duration} minutes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
session.notes?.let { notes ->
|
||||
if (notes.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyStateMessage(
|
||||
title: String,
|
||||
message: String,
|
||||
onActionClick: () -> Unit,
|
||||
actionText: String
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (actionText.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(onClick = onActionClick) {
|
||||
Text(actionText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(dateString: String): String {
|
||||
return try {
|
||||
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
||||
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
||||
} catch (e: Exception) {
|
||||
dateString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.os.Environment
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: ClimbViewModel
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val packageInfo = remember {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}
|
||||
val appVersion = packageInfo.versionName
|
||||
|
||||
// File picker launcher for import
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val tempFile = File(context.cacheDir, "temp_import.json")
|
||||
inputStream?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
viewModel.importData(tempFile)
|
||||
} catch (e: Exception) {
|
||||
viewModel.setError("Failed to read file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File picker launcher for export - save location
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
try {
|
||||
val defaultFileName = "openclimb_export_${
|
||||
java.time.LocalDateTime.now()
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "-")
|
||||
}.json"
|
||||
|
||||
// Use the selected URI for export
|
||||
viewModel.exportDataToUri(context, uri)
|
||||
} catch (e: Exception) {
|
||||
viewModel.setError("Failed to save file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Data Management Section
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Data Management",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Export Data
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Export Data") },
|
||||
supportingContent = { Text("Export all your climbing data to JSON") },
|
||||
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val defaultFileName = "openclimb_export_${
|
||||
java.time.LocalDateTime.now()
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "-")
|
||||
}.json"
|
||||
exportLauncher.launch(defaultFileName)
|
||||
},
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Export")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Import Data") },
|
||||
supportingContent = { Text("Import climbing data from JSON file") },
|
||||
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
importLauncher.launch("application/json")
|
||||
},
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App Information Section
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "App Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Version") },
|
||||
supportingContent = { Text(appVersion ?: "Unknown") },
|
||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("About") },
|
||||
supportingContent = { Text("OpenClimb - Track your climbing progress") },
|
||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Show loading/message states
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
uiState.message?.let { message ->
|
||||
LaunchedEffect(message) {
|
||||
kotlinx.coroutines.delay(5000)
|
||||
viewModel.clearMessage()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.error?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
kotlinx.coroutines.delay(5000)
|
||||
viewModel.clearError()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.atridad.openclimb.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Climbing-themed Material You color palette
|
||||
// Orange - Primary (represents rock/sandstone climbing)
|
||||
val ClimbOrange10 = Color(0xFF1F0E00)
|
||||
val ClimbOrange20 = Color(0xFF3E1C00)
|
||||
val ClimbOrange30 = Color(0xFF5D2B00)
|
||||
val ClimbOrange40 = Color(0xFF7C3900)
|
||||
val ClimbOrange80 = Color(0xFFFFB786)
|
||||
val ClimbOrange90 = Color(0xFFFFDCC2)
|
||||
val ClimbOrange100 = Color(0xFFFFFFFF)
|
||||
|
||||
// Grey - Secondary (represents granite/slate)
|
||||
val ClimbGrey10 = Color(0xFF1F1F1F)
|
||||
val ClimbGrey20 = Color(0xFF2F2F2F)
|
||||
val ClimbGrey30 = Color(0xFF484848)
|
||||
val ClimbGrey40 = Color(0xFF606060)
|
||||
val ClimbGrey80 = Color(0xFFC7C7C7)
|
||||
val ClimbGrey90 = Color(0xFFE3E3E3)
|
||||
val ClimbGrey100 = Color(0xFFFFFFFF)
|
||||
|
||||
// Blue - Tertiary (represents ice/water)
|
||||
val ClimbBlue10 = Color(0xFF001F2A)
|
||||
val ClimbBlue20 = Color(0xFF003544)
|
||||
val ClimbBlue30 = Color(0xFF004D61)
|
||||
val ClimbBlue40 = Color(0xFF00677F)
|
||||
val ClimbBlue80 = Color(0xFF5DDBFF)
|
||||
val ClimbBlue90 = Color(0xFFB8EAFF)
|
||||
val ClimbBlue100 = Color(0xFFFFFFFF)
|
||||
|
||||
// Red - Error colors
|
||||
val ClimbRed10 = Color(0xFF410001)
|
||||
val ClimbRed20 = Color(0xFF680003)
|
||||
val ClimbRed30 = Color(0xFF930006)
|
||||
val ClimbRed40 = Color(0xFFBA1B1B)
|
||||
val ClimbRed80 = Color(0xFFFFB4A9)
|
||||
val ClimbRed90 = Color(0xFFFFDAD4)
|
||||
val ClimbRed100 = Color(0xFFFFFFFF)
|
||||
|
||||
// Neutral colors for surfaces
|
||||
val ClimbNeutral0 = Color(0xFF000000)
|
||||
val ClimbNeutral4 = Color(0xFF0F0F0F)
|
||||
val ClimbNeutral6 = Color(0xFF141414)
|
||||
val ClimbNeutral10 = Color(0xFF1F1F1F)
|
||||
val ClimbNeutral12 = Color(0xFF232323)
|
||||
val ClimbNeutral17 = Color(0xFF2C2C2C)
|
||||
val ClimbNeutral20 = Color(0xFF313131)
|
||||
val ClimbNeutral22 = Color(0xFF363636)
|
||||
val ClimbNeutral24 = Color(0xFF393939)
|
||||
val ClimbNeutral87 = Color(0xFFDDDDDD)
|
||||
val ClimbNeutral90 = Color(0xFFE6E6E6)
|
||||
val ClimbNeutral92 = Color(0xFFEBEBEB)
|
||||
val ClimbNeutral94 = Color(0xFFF0F0F0)
|
||||
val ClimbNeutral95 = Color(0xFFF3F3F3)
|
||||
val ClimbNeutral96 = Color(0xFFF5F5F5)
|
||||
val ClimbNeutral98 = Color(0xFFFAFAFA)
|
||||
val ClimbNeutral100 = Color(0xFFFFFFFF)
|
||||
|
||||
// Neutral variant colors for outlines and variants
|
||||
val ClimbNeutralVariant30 = Color(0xFF484848)
|
||||
val ClimbNeutralVariant50 = Color(0xFF797979)
|
||||
val ClimbNeutralVariant60 = Color(0xFF939393)
|
||||
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
||||
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
||||
|
||||
// Legacy colors for backward compatibility
|
||||
val Purple80 = ClimbOrange80
|
||||
val PurpleGrey80 = ClimbGrey80
|
||||
val Pink80 = ClimbBlue80
|
||||
|
||||
val Purple40 = ClimbOrange40
|
||||
val PurpleGrey40 = ClimbGrey40
|
||||
val Pink40 = ClimbBlue40
|
||||
123
app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package com.atridad.openclimb.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
// Climbing-themed dark color scheme with full Material You compatibility
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = ClimbOrange80,
|
||||
onPrimary = ClimbOrange20,
|
||||
primaryContainer = ClimbOrange30,
|
||||
onPrimaryContainer = ClimbOrange90,
|
||||
secondary = ClimbGrey80,
|
||||
onSecondary = ClimbGrey20,
|
||||
secondaryContainer = ClimbGrey30,
|
||||
onSecondaryContainer = ClimbGrey90,
|
||||
tertiary = ClimbBlue80,
|
||||
onTertiary = ClimbBlue20,
|
||||
tertiaryContainer = ClimbBlue30,
|
||||
onTertiaryContainer = ClimbBlue90,
|
||||
error = ClimbRed80,
|
||||
onError = ClimbRed20,
|
||||
errorContainer = ClimbRed30,
|
||||
onErrorContainer = ClimbRed90,
|
||||
surface = ClimbNeutral10,
|
||||
onSurface = ClimbNeutral90,
|
||||
surfaceVariant = ClimbNeutralVariant30,
|
||||
onSurfaceVariant = ClimbNeutralVariant80,
|
||||
outline = ClimbNeutralVariant60,
|
||||
outlineVariant = ClimbNeutralVariant30,
|
||||
scrim = ClimbNeutral0,
|
||||
inverseSurface = ClimbNeutral90,
|
||||
inverseOnSurface = ClimbNeutral20,
|
||||
inversePrimary = ClimbOrange40,
|
||||
surfaceDim = ClimbNeutral6,
|
||||
surfaceBright = ClimbNeutral24,
|
||||
surfaceContainerLowest = ClimbNeutral4,
|
||||
surfaceContainerLow = ClimbNeutral10,
|
||||
surfaceContainer = ClimbNeutral12,
|
||||
surfaceContainerHigh = ClimbNeutral17,
|
||||
surfaceContainerHighest = ClimbNeutral22
|
||||
)
|
||||
|
||||
// Climbing-themed light color scheme with full Material You compatibility
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = ClimbOrange40,
|
||||
onPrimary = ClimbOrange100,
|
||||
primaryContainer = ClimbOrange90,
|
||||
onPrimaryContainer = ClimbOrange10,
|
||||
secondary = ClimbGrey40,
|
||||
onSecondary = ClimbGrey100,
|
||||
secondaryContainer = ClimbGrey90,
|
||||
onSecondaryContainer = ClimbGrey10,
|
||||
tertiary = ClimbBlue40,
|
||||
onTertiary = ClimbBlue100,
|
||||
tertiaryContainer = ClimbBlue90,
|
||||
onTertiaryContainer = ClimbBlue10,
|
||||
error = ClimbRed40,
|
||||
onError = ClimbRed100,
|
||||
errorContainer = ClimbRed90,
|
||||
onErrorContainer = ClimbRed10,
|
||||
surface = ClimbNeutral98,
|
||||
onSurface = ClimbNeutral10,
|
||||
surfaceVariant = ClimbNeutralVariant90,
|
||||
onSurfaceVariant = ClimbNeutralVariant30,
|
||||
outline = ClimbNeutralVariant50,
|
||||
outlineVariant = ClimbNeutralVariant80,
|
||||
scrim = ClimbNeutral0,
|
||||
inverseSurface = ClimbNeutral20,
|
||||
inverseOnSurface = ClimbNeutral95,
|
||||
inversePrimary = ClimbOrange80,
|
||||
surfaceDim = ClimbNeutral87,
|
||||
surfaceBright = ClimbNeutral98,
|
||||
surfaceContainerLowest = ClimbNeutral100,
|
||||
surfaceContainerLow = ClimbNeutral96,
|
||||
surfaceContainer = ClimbNeutral94,
|
||||
surfaceContainerHigh = ClimbNeutral92,
|
||||
surfaceContainerHighest = ClimbNeutral90
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OpenClimbTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+ and provides full Material You theming
|
||||
// When enabled, it adapts to the user's system wallpaper colors
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
18
app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.atridad.openclimb.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.atridad.openclimb.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.service.SessionTrackingService
|
||||
import com.atridad.openclimb.utils.SessionShareUtils
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class ClimbViewModel(
|
||||
private val repository: ClimbRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// UI State flows
|
||||
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Data flows
|
||||
val gyms = repository.getAllGyms().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val problems = repository.getAllProblems().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val sessions = repository.getAllSessions().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val activeSession = repository.getActiveSessionFlow().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
val attempts = repository.getAllAttempts().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
|
||||
|
||||
// Gym operations
|
||||
fun addGym(gym: Gym) {
|
||||
viewModelScope.launch {
|
||||
repository.insertGym(gym)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGym(gym: Gym) {
|
||||
viewModelScope.launch {
|
||||
repository.updateGym(gym)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteGym(gym: Gym) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteGym(gym)
|
||||
}
|
||||
}
|
||||
|
||||
fun getGymById(id: String): Flow<Gym?> = flow {
|
||||
emit(repository.getGymById(id))
|
||||
}
|
||||
|
||||
// Problem operations
|
||||
fun addProblem(problem: Problem) {
|
||||
viewModelScope.launch {
|
||||
repository.insertProblem(problem)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem) {
|
||||
viewModelScope.launch {
|
||||
repository.updateProblem(problem)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteProblem(problem: Problem) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteProblem(problem)
|
||||
}
|
||||
}
|
||||
|
||||
fun getProblemById(id: String): Flow<Problem?> = flow {
|
||||
emit(repository.getProblemById(id))
|
||||
}
|
||||
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> =
|
||||
repository.getProblemsByGym(gymId)
|
||||
|
||||
// Session operations
|
||||
fun addSession(session: ClimbSession) {
|
||||
viewModelScope.launch {
|
||||
repository.insertSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSession(session: ClimbSession) {
|
||||
viewModelScope.launch {
|
||||
repository.updateSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSession(session: ClimbSession) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
||||
emit(repository.getSessionById(id))
|
||||
}
|
||||
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
repository.getSessionsByGym(gymId)
|
||||
|
||||
// Active session management
|
||||
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
||||
viewModelScope.launch {
|
||||
val existingActive = repository.getActiveSession()
|
||||
if (existingActive != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = "There's already an active session. Please end it first."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
|
||||
repository.insertSession(newSession)
|
||||
|
||||
// Start the tracking service
|
||||
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
||||
context.startForegroundService(serviceIntent)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
message = "Session started successfully!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun endSession(context: Context, sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||
val completedSession = with(ClimbSession) { session.complete() }
|
||||
repository.updateSession(completedSession)
|
||||
|
||||
// Stop the tracking service
|
||||
val serviceIntent = SessionTrackingService.createStopIntent(context)
|
||||
context.startService(serviceIntent)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
message = "Session completed!"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseSession(sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||
val pausedSession = session.copy(
|
||||
status = SessionStatus.PAUSED,
|
||||
updatedAt = java.time.LocalDateTime.now().toString()
|
||||
)
|
||||
repository.updateSession(pausedSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeSession(sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.PAUSED) {
|
||||
val resumedSession = session.copy(
|
||||
status = SessionStatus.ACTIVE,
|
||||
updatedAt = java.time.LocalDateTime.now().toString()
|
||||
)
|
||||
repository.updateSession(resumedSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt operations
|
||||
fun addAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.insertAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsBySession(sessionId)
|
||||
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsByProblem(problemId)
|
||||
|
||||
|
||||
|
||||
// Analytics operations
|
||||
// fun getProblemProgress(problemId: String): Flow<ProblemProgress?> =
|
||||
// repository.getProblemProgress(problemId)
|
||||
|
||||
// fun getSessionSummary(sessionId: String): Flow<SessionSummary?> =
|
||||
// repository.getSessionSummary(sessionId)
|
||||
|
||||
// Export operations
|
||||
fun exportData(context: Context, directory: File? = null) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
val exportFile = repository.exportAllDataToJson(directory)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data exported to: ${exportFile.absolutePath}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportDataToUri(context: Context, uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
repository.exportAllDataToUri(context, uri)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data exported successfully"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importData(file: File) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
repository.importDataFromJson(file)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data imported successfully from ${file.name}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Import failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI state operations
|
||||
fun clearMessage() {
|
||||
_uiState.value = _uiState.value.copy(message = null)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun setError(message: String) {
|
||||
_uiState.value = _uiState.value.copy(error = message)
|
||||
}
|
||||
|
||||
// Search operations
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = repository.searchGyms(query)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = repository.searchProblems(query)
|
||||
|
||||
// Share operations
|
||||
suspend fun generateSessionShareCard(
|
||||
context: Context,
|
||||
sessionId: String
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = repository.getSessionById(sessionId) ?: return@withContext null
|
||||
val attempts = repository.getAttemptsBySession(sessionId).first()
|
||||
val problems = repository.getAllProblems().first().filter { problem ->
|
||||
attempts.any { it.problemId == problem.id }
|
||||
}
|
||||
val gym = repository.getGymById(session.gymId) ?: return@withContext null
|
||||
|
||||
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
|
||||
SessionShareUtils.generateShareCard(context, session, gym, stats)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun shareSessionCard(context: Context, imageFile: File) {
|
||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
data class ClimbUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.atridad.openclimb.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
|
||||
class ClimbViewModelFactory(
|
||||
private val repository: ClimbRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
||||
return ClimbViewModel(repository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.atridad.openclimb.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.*
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import androidx.core.content.FileProvider
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object SessionShareUtils {
|
||||
|
||||
data class SessionStats(
|
||||
val totalAttempts: Int,
|
||||
val successfulAttempts: Int,
|
||||
val problems: List<Problem>,
|
||||
val uniqueProblemsAttempted: Int,
|
||||
val uniqueProblemsCompleted: Int,
|
||||
val averageGrade: String?,
|
||||
val sessionDuration: String,
|
||||
val topResult: AttemptResult?
|
||||
)
|
||||
|
||||
fun calculateSessionStats(
|
||||
session: ClimbSession,
|
||||
attempts: List<Attempt>,
|
||||
problems: List<Problem>
|
||||
): SessionStats {
|
||||
val successfulResults = listOf(
|
||||
AttemptResult.SUCCESS,
|
||||
AttemptResult.FLASH,
|
||||
AttemptResult.REDPOINT,
|
||||
AttemptResult.ONSIGHT
|
||||
)
|
||||
|
||||
val successfulAttempts = attempts.filter { it.result in successfulResults }
|
||||
val uniqueProblems = attempts.map { it.problemId }.distinct()
|
||||
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||
|
||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
||||
val averageGrade = if (attemptedProblems.isNotEmpty()) {
|
||||
// This is a simplified average - in reality you'd need proper grade conversion
|
||||
val gradeValues = attemptedProblems.mapNotNull { problem ->
|
||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
"V${gradeValues.average().roundToInt()}"
|
||||
} else null
|
||||
} else null
|
||||
|
||||
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
|
||||
val topResult = attempts.maxByOrNull {
|
||||
when (it.result) {
|
||||
AttemptResult.ONSIGHT -> 5
|
||||
AttemptResult.FLASH -> 4
|
||||
AttemptResult.REDPOINT -> 3
|
||||
AttemptResult.SUCCESS -> 2
|
||||
AttemptResult.FALL -> 1
|
||||
else -> 0
|
||||
}
|
||||
}?.result
|
||||
|
||||
return SessionStats(
|
||||
totalAttempts = attempts.size,
|
||||
successfulAttempts = successfulAttempts.size,
|
||||
problems = attemptedProblems,
|
||||
uniqueProblemsAttempted = uniqueProblems.size,
|
||||
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
||||
averageGrade = averageGrade,
|
||||
sessionDuration = duration,
|
||||
topResult = topResult
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDuration(startTime: String?, endTime: String?): String {
|
||||
return try {
|
||||
if (startTime != null && endTime != null) {
|
||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
val start = LocalDateTime.parse(startTime, formatter)
|
||||
val end = LocalDateTime.parse(endTime, formatter)
|
||||
val duration = java.time.Duration.between(start, end)
|
||||
|
||||
val hours = duration.toHours()
|
||||
val minutes = duration.toMinutes() % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${minutes}m"
|
||||
minutes > 0 -> "${minutes}m"
|
||||
else -> "< 1m"
|
||||
}
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fun generateShareCard(
|
||||
context: Context,
|
||||
session: ClimbSession,
|
||||
gym: Gym,
|
||||
stats: SessionStats
|
||||
): File? {
|
||||
return try {
|
||||
val width = 1080
|
||||
val height = 1350
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
val gradientDrawable = GradientDrawable(
|
||||
GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
intArrayOf(
|
||||
Color.parseColor("#667eea"),
|
||||
Color.parseColor("#764ba2")
|
||||
)
|
||||
)
|
||||
gradientDrawable.setBounds(0, 0, width, height)
|
||||
gradientDrawable.draw(canvas)
|
||||
|
||||
// Setup paint objects
|
||||
val titlePaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
textSize = 72f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val subtitlePaint = Paint().apply {
|
||||
color = Color.parseColor("#E8E8E8")
|
||||
textSize = 48f
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val statLabelPaint = Paint().apply {
|
||||
color = Color.parseColor("#B8B8B8")
|
||||
textSize = 36f
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val statValuePaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
textSize = 64f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val cardPaint = Paint().apply {
|
||||
color = Color.parseColor("#40FFFFFF")
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
// Draw main card background
|
||||
val cardRect = RectF(60f, 200f, width - 60f, height - 100f)
|
||||
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
|
||||
|
||||
// Draw content
|
||||
var yPosition = 300f
|
||||
|
||||
// Title
|
||||
canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint)
|
||||
yPosition += 80f
|
||||
|
||||
// Gym and date
|
||||
canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint)
|
||||
yPosition += 60f
|
||||
|
||||
val dateText = formatSessionDate(session.date)
|
||||
canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint)
|
||||
yPosition += 120f
|
||||
|
||||
// Stats grid
|
||||
val statsStartY = yPosition
|
||||
val columnWidth = width / 2f
|
||||
|
||||
// Left column stats
|
||||
var leftY = statsStartY
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint)
|
||||
leftY += 140f
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint)
|
||||
leftY += 140f
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint)
|
||||
|
||||
// Right column stats
|
||||
var rightY = statsStartY
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint)
|
||||
rightY += 140f
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint)
|
||||
rightY += 140f
|
||||
|
||||
stats.averageGrade?.let { grade ->
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint)
|
||||
}
|
||||
|
||||
// Success rate arc
|
||||
if (stats.totalAttempts > 0) {
|
||||
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100
|
||||
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint)
|
||||
}
|
||||
|
||||
// App branding
|
||||
val brandingPaint = Paint().apply {
|
||||
color = Color.parseColor("#80FFFFFF")
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint)
|
||||
|
||||
// Save to file
|
||||
val shareDir = File(context.cacheDir, "shares")
|
||||
if (!shareDir.exists()) {
|
||||
shareDir.mkdirs()
|
||||
}
|
||||
|
||||
val filename = "session_${session.id}_${System.currentTimeMillis()}.png"
|
||||
val file = File(shareDir, filename)
|
||||
|
||||
val outputStream = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
bitmap.recycle()
|
||||
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStatItem(
|
||||
canvas: Canvas,
|
||||
x: Float,
|
||||
y: Float,
|
||||
label: String,
|
||||
value: String,
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint
|
||||
) {
|
||||
canvas.drawText(value, x, y, valuePaint)
|
||||
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||
}
|
||||
|
||||
private fun drawSuccessRateArc(
|
||||
canvas: Canvas,
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
successRate: Float,
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint
|
||||
) {
|
||||
val radius = 80f
|
||||
val strokeWidth = 16f
|
||||
|
||||
// Background arc
|
||||
val bgPaint = Paint().apply {
|
||||
color = Color.parseColor("#40FFFFFF")
|
||||
style = Paint.Style.STROKE
|
||||
this.strokeWidth = strokeWidth
|
||||
isAntiAlias = true
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
// Success arc
|
||||
val successPaint = Paint().apply {
|
||||
color = Color.parseColor("#4CAF50")
|
||||
style = Paint.Style.STROKE
|
||||
this.strokeWidth = strokeWidth
|
||||
isAntiAlias = true
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
|
||||
|
||||
// Draw background arc (full circle)
|
||||
canvas.drawArc(rect, -90f, 360f, false, bgPaint)
|
||||
|
||||
// Draw success arc
|
||||
val sweepAngle = (successRate / 100f) * 360f
|
||||
canvas.drawArc(rect, -90f, sweepAngle, false, successPaint)
|
||||
|
||||
// Draw percentage text
|
||||
val percentText = "${successRate.roundToInt()}%"
|
||||
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint)
|
||||
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
|
||||
}
|
||||
|
||||
private fun formatSessionDate(dateString: String): String {
|
||||
return try {
|
||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
val date = LocalDateTime.parse(dateString, formatter)
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
||||
date.format(displayFormatter)
|
||||
} catch (e: Exception) {
|
||||
dateString.take(10)
|
||||
}
|
||||
}
|
||||
|
||||
fun shareSessionCard(context: Context, imageFile: File) {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
|
||||
val shareIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "image/png"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! 🧗♀️ #OpenClimb")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
val chooser = Intent.createChooser(shareIntent, "Share Session")
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClimb</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
5
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="share_images" path="shares/" />
|
||||
<external-files-path name="external_files" path="." />
|
||||
</paths>
|
||||