Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
aa3ddfc7cb
|
|||
|
25688b0615
|
|||
|
3874703fcb
|
|||
| aa08892e75 | |||
|
4da10912fc
|
|||
|
94d2f9d951
|
|||
|
6e679236c8
|
|||
|
06fe659478
|
|||
|
390b4bf499
|
|||
|
394789d609
|
|||
|
94566eabf6
|
|||
|
c020287d1f
|
|||
|
98589645e6
|
|||
|
33610a5959
|
|||
|
20058e9ac0
|
|||
|
e4d6e6fb7e
|
|||
|
d97a5f36ea
|
|||
| 1a85dab6ae | |||
|
2d5382ba28
|
|||
|
05c0430b40
|
|||
|
f4f4968431
|
|||
|
d002c703d5
|
|||
|
afb0456692
|
|||
|
74db155d93
|
|||
|
ec63d7c58f
|
|||
|
1c47dd93b0
|
|||
|
ef05727cde
|
|||
|
452fd96372
|
|||
|
d263c6c87e
|
|||
|
aa6ee4ecd4
|
|||
|
c0d9702e54
|
|||
|
0cc576bb12
|
|||
|
abb76e454b
|
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
79
.gitattributes
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Source code files
|
||||
*.swift text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.java text eol=lf
|
||||
*.go text eol=lf
|
||||
*.py text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
|
||||
# Configuration files
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.plist text eol=lf
|
||||
*.properties text eol=lf
|
||||
*.gradle text eol=lf
|
||||
*.md text eol=lf
|
||||
|
||||
# Shell scripts
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
*.zsh text eol=lf
|
||||
|
||||
# Windows batch files
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.tar binary
|
||||
*.gz binary
|
||||
*.jar binary
|
||||
*.aar binary
|
||||
*.apk binary
|
||||
*.ipa binary
|
||||
*.xcassets binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.eot binary
|
||||
|
||||
# Xcode files
|
||||
*.pbxproj merge=union
|
||||
*.xcworkspacedata text eol=lf
|
||||
*.xcscheme text eol=lf
|
||||
|
||||
# Gradle wrapper
|
||||
gradlew text eol=lf
|
||||
gradlew.bat text eol=crlf
|
||||
gradle-wrapper.jar binary
|
||||
|
||||
# Lock files
|
||||
*.lock text -diff
|
||||
package-lock.json text -diff
|
||||
pnpm-lock.yaml text -diff
|
||||
|
||||
# Documentation
|
||||
LICENSE text eol=lf
|
||||
README.md text eol=lf
|
||||
|
||||
*.pxd linguist-vendored
|
||||
46
.gitignore
vendored
@@ -9,6 +9,11 @@ local.properties
|
||||
# Log/OS Files
|
||||
*.log
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
Thumbs.db
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
@@ -34,3 +39,44 @@ google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# iOS/Xcode
|
||||
ios/build/
|
||||
ios/DerivedData/
|
||||
ios/*.xcuserstate
|
||||
ios/**/xcuserdata/
|
||||
ios/**/*.xcuserstate
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# CocoaPods
|
||||
ios/Pods/
|
||||
ios/Podfile.lock
|
||||
|
||||
# Swift Package Manager
|
||||
ios/.swiftpm/
|
||||
ios/Package.resolved
|
||||
.build/
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Code Injection
|
||||
iOSInjectionProject/
|
||||
|
||||
90
Makefile
Normal file
@@ -0,0 +1,90 @@
|
||||
# Ascently Makefile
|
||||
|
||||
.PHONY: help android-lint android-format android-build android-test \
|
||||
ios-lint ios-format ios-build ios-test clean
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Ascently Dev Commands"
|
||||
@echo "=============================="
|
||||
@echo ""
|
||||
@echo "Android:"
|
||||
@echo " make android-lint: Run Detekt static analysis"
|
||||
@echo " make android-format: Check code formatting with Spotless"
|
||||
@echo " make android-format-fix: Auto-fix code formatting"
|
||||
@echo " make android-build: Build debug APK"
|
||||
@echo " make android-release: Build release APK"
|
||||
@echo " make android-test: Run unit tests"
|
||||
@echo ""
|
||||
@echo "iOS:"
|
||||
@echo " make ios-lint: Run SwiftLint"
|
||||
@echo " make ios-lint-fix: Run SwiftLint with auto-fix"
|
||||
@echo " make ios-format: Run SwiftFormat (dry-run)"
|
||||
@echo " make ios-format-fix: Run SwiftFormat with auto-fix"
|
||||
@echo " make ios-build: Build iOS app"
|
||||
@echo ""
|
||||
@echo "General:"
|
||||
@echo " make lint: Run linters for both platforms"
|
||||
@echo " make format: Check formatting for both platforms"
|
||||
@echo " make clean: Clean build artifacts"
|
||||
|
||||
# Android commands
|
||||
android-lint:
|
||||
cd android && ./gradlew detekt
|
||||
|
||||
android-format:
|
||||
cd android && ./gradlew spotlessCheck
|
||||
|
||||
android-format-fix:
|
||||
cd android && ./gradlew spotlessApply
|
||||
|
||||
android-build:
|
||||
cd android && ./gradlew assembleDebug
|
||||
|
||||
android-release:
|
||||
cd android && ./gradlew assembleRelease
|
||||
|
||||
android-test:
|
||||
cd android && ./gradlew test
|
||||
|
||||
# iOS commands (requires SwiftLint and SwiftFormat!!!)
|
||||
ios-lint:
|
||||
@if command -v swiftlint >/dev/null 2>&1; then \
|
||||
cd ios && swiftlint; \
|
||||
else \
|
||||
echo "SwiftLint not installed. Install with: brew install swiftlint"; \
|
||||
fi
|
||||
|
||||
ios-lint-fix:
|
||||
@if command -v swiftlint >/dev/null 2>&1; then \
|
||||
cd ios && swiftlint --fix; \
|
||||
else \
|
||||
echo "SwiftLint not installed. Install with: brew install swiftlint"; \
|
||||
fi
|
||||
|
||||
ios-format:
|
||||
@if command -v swiftformat >/dev/null 2>&1; then \
|
||||
cd ios && swiftformat . --lint; \
|
||||
else \
|
||||
echo "SwiftFormat not installed. Install with: brew install swiftformat"; \
|
||||
fi
|
||||
|
||||
ios-format-fix:
|
||||
@if command -v swiftformat >/dev/null 2>&1; then \
|
||||
cd ios && swiftformat .; \
|
||||
else \
|
||||
echo "SwiftFormat not installed. Install with: brew install swiftformat"; \
|
||||
fi
|
||||
|
||||
ios-build:
|
||||
cd ios && xcodebuild -project Ascently.xcodeproj -scheme Ascently -configuration Debug build
|
||||
|
||||
# Combined commands
|
||||
lint: android-lint ios-lint
|
||||
|
||||
format: android-format ios-format
|
||||
|
||||
# Clean
|
||||
clean:
|
||||
cd android && ./gradlew clean
|
||||
rm -rf ios/build ios/DerivedData
|
||||
@@ -1,5 +1,7 @@
|
||||
# Ascently
|
||||
|
||||
<img src="https://git.atri.dad/atridad/Ascently/raw/branch/main/docs/src/assets/logo.png" alt="Ascently Logo" width="250" height="250">
|
||||
|
||||
_Formerly OpenClimb_
|
||||
|
||||
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
|
||||
|
||||
31
android/.editorconfig
Normal file
@@ -0,0 +1,31 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
indent_size = 4
|
||||
max_line_length = 220
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
ktlint_standard_package-name = disabled
|
||||
|
||||
[*.{xml,gradle}]
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.properties]
|
||||
indent_size = 2
|
||||
|
||||
[*.toml]
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -3,7 +3,6 @@
|
||||
This is the native Android app for Ascently, built with Kotlin and Jetpack Compose.
|
||||
|
||||
## Project Structure
|
||||
|
||||
This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/ascently/`.
|
||||
|
||||
- `data/`: Handles all the app's data.
|
||||
|
||||
@@ -6,6 +6,8 @@ plugins {
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.detekt)
|
||||
alias(libs.plugins.spotless)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -16,8 +18,8 @@ android {
|
||||
applicationId = "com.atridad.ascently"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 48
|
||||
versionName = "2.4.0"
|
||||
versionCode = 51
|
||||
versionName = "2.5.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -27,7 +29,7 @@ android {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +85,7 @@ dependencies {
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Health Connect
|
||||
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
|
||||
implementation(libs.health.connect)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
@@ -99,6 +101,51 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
|
||||
// Detekt configuration
|
||||
detekt {
|
||||
config.setFrom(files("$rootDir/detekt.yml"))
|
||||
buildUponDefaultConfig = true
|
||||
allRules = false
|
||||
parallel = true
|
||||
}
|
||||
|
||||
// Spotless configuration for code formatting
|
||||
spotless {
|
||||
kotlin {
|
||||
target("**/*.kt")
|
||||
targetExclude("**/build/**/*.kt")
|
||||
ktlint("1.2.1")
|
||||
.editorConfigOverride(
|
||||
mapOf(
|
||||
"ktlint_standard_no-wildcard-imports" to "disabled",
|
||||
"ktlint_standard_package-name" to "disabled",
|
||||
"ktlint_standard_function-naming" to "disabled",
|
||||
"ktlint_standard_filename" to "disabled",
|
||||
"ktlint_standard_value-parameter-comment" to "disabled",
|
||||
"ktlint_standard_comment-wrapping" to "disabled",
|
||||
"ktlint_standard_multiline-expression-wrapping" to "disabled",
|
||||
"ktlint_standard_string-template-indent" to "disabled",
|
||||
"ktlint_standard_property-naming" to "disabled",
|
||||
"ktlint_standard_class-naming" to "disabled",
|
||||
"ktlint_standard_backing-property-naming" to "disabled",
|
||||
"ktlint_standard_function-signature" to "disabled",
|
||||
"ktlint_standard_parameter-list-wrapping" to "disabled",
|
||||
"ktlint_standard_max-line-length" to "disabled",
|
||||
"max_line_length" to "off",
|
||||
),
|
||||
)
|
||||
}
|
||||
kotlinGradle {
|
||||
target("**/*.kts")
|
||||
targetExclude("**/build/**/*.kts")
|
||||
ktlint("1.2.1")
|
||||
.editorConfigOverride(
|
||||
mapOf(
|
||||
"ktlint_standard_no-wildcard-imports" to "disabled",
|
||||
"max_line_length" to "off",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
60
android/app/proguard-rules.pro
vendored
@@ -5,17 +5,61 @@
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Preserve line number information for debugging stack traces
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Hide the original source file name
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
# Keep generic signatures for Kotlin
|
||||
-keepattributes Signature
|
||||
|
||||
# Keep annotations
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Room Database
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-keep @androidx.room.Entity class *
|
||||
-keepclassmembers class * {
|
||||
@androidx.room.* <fields>;
|
||||
@androidx.room.* <methods>;
|
||||
}
|
||||
|
||||
# Kotlinx Serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
-keep,includedescriptorclasses class com.atridad.ascently.**$$serializer { *; }
|
||||
-keepclassmembers class com.atridad.ascently.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.atridad.ascently.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep data classes used for serialization
|
||||
-keep class com.atridad.ascently.data.model.** { *; }
|
||||
-keep class com.atridad.ascently.data.format.** { *; }
|
||||
|
||||
# Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -39,7 +39,7 @@ class MainActivity : ComponentActivity() {
|
||||
AscentlyApp(
|
||||
shortcutAction = shortcutAction,
|
||||
lastUsedGymId = lastUsedGymId,
|
||||
onShortcutActionProcessed = { clearShortcutAction() }
|
||||
onShortcutActionProcessed = { clearShortcutAction() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,4 @@ class Converters {
|
||||
fun toSessionStatus(value: String): SessionStatus {
|
||||
return SessionStatus.valueOf(value)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import com.atridad.ascently.data.model.*
|
||||
@Database(
|
||||
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
|
||||
version = 6,
|
||||
exportSchema = false
|
||||
exportSchema = false,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AscentlyDatabase : RoomDatabase() {
|
||||
@@ -46,15 +46,15 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
||||
}
|
||||
if (!existingColumns.contains("status")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'"
|
||||
"ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'",
|
||||
)
|
||||
}
|
||||
|
||||
db.execSQL(
|
||||
"UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL"
|
||||
"UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL",
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''"
|
||||
"UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,11 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
||||
|
||||
if (!existingColumns.contains("updatedAt")) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''"
|
||||
"ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''",
|
||||
)
|
||||
// Set updatedAt to createdAt for existing records
|
||||
db.execSQL(
|
||||
"UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''"
|
||||
"UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AscentlyDatabase::class.java,
|
||||
"ascently_database"
|
||||
"ascently_database",
|
||||
)
|
||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||
.enableMultiInstanceInvalidation()
|
||||
|
||||
@@ -11,7 +11,8 @@ 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 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>>
|
||||
@@ -20,7 +21,7 @@ interface ProblemDao {
|
||||
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC"
|
||||
"SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC",
|
||||
)
|
||||
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
|
||||
|
||||
@@ -30,7 +31,8 @@ interface ProblemDao {
|
||||
@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 insertProblem(problem: Problem)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertProblems(problems: List<Problem>)
|
||||
@@ -39,7 +41,8 @@ interface ProblemDao {
|
||||
|
||||
@Delete suspend fun deleteProblem(problem: Problem)
|
||||
|
||||
@Query("DELETE FROM problems WHERE id = :id") suspend fun deleteProblemById(id: String)
|
||||
@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
|
||||
@@ -54,11 +57,13 @@ interface ProblemDao {
|
||||
OR description LIKE '%' || :searchQuery || '%'
|
||||
OR location LIKE '%' || :searchQuery || '%')
|
||||
ORDER BY updatedAt DESC
|
||||
"""
|
||||
""",
|
||||
)
|
||||
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int
|
||||
@Query("SELECT COUNT(*) FROM problems")
|
||||
suspend fun getProblemsCount(): Int
|
||||
|
||||
@Query("DELETE FROM problems") suspend fun deleteAllProblems()
|
||||
@Query("DELETE FROM problems")
|
||||
suspend fun deleteAllProblems()
|
||||
}
|
||||
|
||||
@@ -13,14 +13,13 @@ data class ClimbDataBackup(
|
||||
val problems: List<BackupProblem>,
|
||||
val sessions: List<BackupClimbSession>,
|
||||
val attempts: List<BackupAttempt>,
|
||||
val deletedItems: List<DeletedItem> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeletedItem(
|
||||
val id: String,
|
||||
val type: String, // "gym", "problem", "session", "attempt"
|
||||
val deletedAt: String
|
||||
val deletedAt: String,
|
||||
)
|
||||
|
||||
// Platform-neutral gym representation for backup/restore
|
||||
@@ -34,8 +33,9 @@ data class BackupGym(
|
||||
@kotlinx.serialization.SerialName("customDifficultyGrades")
|
||||
val customDifficultyGrades: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val isDeleted: Boolean = false,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromGym(gym: Gym): BackupGym {
|
||||
@@ -47,8 +47,24 @@ data class BackupGym(
|
||||
difficultySystems = gym.difficultySystems,
|
||||
customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null },
|
||||
notes = gym.notes,
|
||||
isDeleted = false,
|
||||
createdAt = gym.createdAt,
|
||||
updatedAt = gym.updatedAt
|
||||
updatedAt = gym.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun createTombstone(id: String, deletedAt: String): BackupGym {
|
||||
return BackupGym(
|
||||
id = id,
|
||||
name = "DELETED",
|
||||
location = null,
|
||||
supportedClimbTypes = emptyList(),
|
||||
difficultySystems = emptyList(),
|
||||
customDifficultyGrades = null,
|
||||
notes = null,
|
||||
isDeleted = true,
|
||||
createdAt = deletedAt,
|
||||
updatedAt = deletedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +79,7 @@ data class BackupGym(
|
||||
customDifficultyGrades = customDifficultyGrades ?: emptyList(),
|
||||
notes = notes,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,8 +99,9 @@ data class BackupProblem(
|
||||
val isActive: Boolean = true,
|
||||
val dateSet: String? = null,
|
||||
val notes: String? = null,
|
||||
val isDeleted: Boolean = false,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromProblem(problem: Problem): BackupProblem {
|
||||
@@ -98,13 +115,37 @@ data class BackupProblem(
|
||||
tags = problem.tags,
|
||||
location = problem.location,
|
||||
imagePaths =
|
||||
if (problem.imagePaths.isEmpty()) null
|
||||
else problem.imagePaths.map { path -> path.substringAfterLast('/') },
|
||||
if (problem.imagePaths.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
problem.imagePaths.map { path -> path.substringAfterLast('/') }
|
||||
},
|
||||
isActive = problem.isActive,
|
||||
dateSet = problem.dateSet,
|
||||
notes = problem.notes,
|
||||
isDeleted = false,
|
||||
createdAt = problem.createdAt,
|
||||
updatedAt = problem.updatedAt
|
||||
updatedAt = problem.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun createTombstone(id: String, deletedAt: String): BackupProblem {
|
||||
return BackupProblem(
|
||||
id = id,
|
||||
gymId = "00000000-0000-0000-0000-000000000000",
|
||||
name = "DELETED",
|
||||
description = null,
|
||||
climbType = ClimbType.values().first(),
|
||||
difficulty = DifficultyGrade(DifficultySystem.values().first(), "0"),
|
||||
tags = null,
|
||||
location = null,
|
||||
imagePaths = null,
|
||||
isActive = false,
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
isDeleted = true,
|
||||
createdAt = deletedAt,
|
||||
updatedAt = deletedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +165,7 @@ data class BackupProblem(
|
||||
dateSet = dateSet,
|
||||
notes = notes,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,8 +185,9 @@ data class BackupClimbSession(
|
||||
val duration: Long? = null,
|
||||
val status: SessionStatus,
|
||||
val notes: String? = null,
|
||||
val isDeleted: Boolean = false,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromClimbSession(session: ClimbSession): BackupClimbSession {
|
||||
@@ -158,8 +200,25 @@ data class BackupClimbSession(
|
||||
duration = session.duration,
|
||||
status = session.status,
|
||||
notes = session.notes,
|
||||
isDeleted = false,
|
||||
createdAt = session.createdAt,
|
||||
updatedAt = session.updatedAt
|
||||
updatedAt = session.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun createTombstone(id: String, deletedAt: String): BackupClimbSession {
|
||||
return BackupClimbSession(
|
||||
id = id,
|
||||
gymId = "00000000-0000-0000-0000-000000000000",
|
||||
date = deletedAt,
|
||||
startTime = null,
|
||||
endTime = null,
|
||||
duration = null,
|
||||
status = SessionStatus.values().first(),
|
||||
notes = null,
|
||||
isDeleted = true,
|
||||
createdAt = deletedAt,
|
||||
updatedAt = deletedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +234,7 @@ data class BackupClimbSession(
|
||||
status = status,
|
||||
notes = notes,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -192,8 +251,9 @@ data class BackupAttempt(
|
||||
val duration: Long? = null,
|
||||
val restTime: Long? = null,
|
||||
val timestamp: String,
|
||||
val isDeleted: Boolean = false,
|
||||
val createdAt: String,
|
||||
val updatedAt: String? = null
|
||||
val updatedAt: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun fromAttempt(attempt: Attempt): BackupAttempt {
|
||||
@@ -207,8 +267,26 @@ data class BackupAttempt(
|
||||
duration = attempt.duration,
|
||||
restTime = attempt.restTime,
|
||||
timestamp = attempt.timestamp,
|
||||
isDeleted = false,
|
||||
createdAt = attempt.createdAt,
|
||||
updatedAt = attempt.updatedAt
|
||||
updatedAt = attempt.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun createTombstone(id: String, deletedAt: String): BackupAttempt {
|
||||
return BackupAttempt(
|
||||
id = id,
|
||||
sessionId = "00000000-0000-0000-0000-000000000000",
|
||||
problemId = "00000000-0000-0000-0000-000000000000",
|
||||
result = AttemptResult.values().first(),
|
||||
highestHold = null,
|
||||
notes = null,
|
||||
duration = null,
|
||||
restTime = null,
|
||||
timestamp = deletedAt,
|
||||
isDeleted = true,
|
||||
createdAt = deletedAt,
|
||||
updatedAt = deletedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +303,7 @@ data class BackupAttempt(
|
||||
restTime = restTime,
|
||||
timestamp = timestamp,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt ?: createdAt
|
||||
updatedAt = updatedAt ?: createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.atridad.ascently.data.health
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.PermissionController
|
||||
@@ -11,17 +10,20 @@ import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.ExerciseSessionRecord
|
||||
import androidx.health.connect.client.records.HeartRateRecord
|
||||
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
||||
|
||||
import androidx.health.connect.client.units.Energy
|
||||
import com.atridad.ascently.data.model.ClimbSession
|
||||
import com.atridad.ascently.data.model.SessionStatus
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit,
|
||||
@@ -52,7 +54,7 @@ class HealthConnectManager(private val context: Context) {
|
||||
HealthPermission.getReadPermission(HeartRateRecord::class),
|
||||
HealthPermission.getWritePermission(HeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
|
||||
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
|
||||
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,8 +129,9 @@ class HealthConnectManager(private val context: Context) {
|
||||
|
||||
suspend fun isReady(): Boolean {
|
||||
return try {
|
||||
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
|
||||
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val isAvailable =
|
||||
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
|
||||
@@ -157,18 +160,18 @@ class HealthConnectManager(private val context: Context) {
|
||||
suspend fun syncCompletedSession(
|
||||
session: ClimbSession,
|
||||
gymName: String,
|
||||
attemptCount: Int = 0
|
||||
attemptCount: Int = 0,
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
if (!isReady() || !_autoSync.value) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Health Connect not ready or auto-sync disabled")
|
||||
IllegalStateException("Health Connect not ready or auto-sync disabled"),
|
||||
)
|
||||
}
|
||||
|
||||
if (session.status != SessionStatus.COMPLETED) {
|
||||
return Result.failure(
|
||||
IllegalArgumentException("Only completed sessions can be synced")
|
||||
IllegalArgumentException("Only completed sessions can be synced"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ class HealthConnectManager(private val context: Context) {
|
||||
|
||||
if (startTime == null || endTime == null) {
|
||||
return Result.failure(
|
||||
IllegalArgumentException("Session must have valid start and end times")
|
||||
IllegalArgumentException("Session must have valid start and end times"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,7 +198,8 @@ class HealthConnectManager(private val context: Context) {
|
||||
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
|
||||
exerciseType =
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
|
||||
title = "Rock Climbing at $gymName"
|
||||
title = "Rock Climbing at $gymName",
|
||||
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
|
||||
)
|
||||
records.add(exerciseSession)
|
||||
} catch (e: Exception) {
|
||||
@@ -215,7 +219,8 @@ class HealthConnectManager(private val context: Context) {
|
||||
endTime = endTime,
|
||||
endZoneOffset =
|
||||
ZoneOffset.systemDefault().rules.getOffset(endTime),
|
||||
energy = Energy.calories(estimatedCalories)
|
||||
energy = Energy.calories(estimatedCalories),
|
||||
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
|
||||
)
|
||||
records.add(caloriesRecord)
|
||||
}
|
||||
@@ -238,9 +243,9 @@ class HealthConnectManager(private val context: Context) {
|
||||
}
|
||||
|
||||
preferences
|
||||
.edit()
|
||||
.putString("last_sync_success", DateFormatUtils.nowISO8601())
|
||||
.apply()
|
||||
.edit {
|
||||
putString("last_sync_success", DateFormatUtils.nowISO8601())
|
||||
}
|
||||
} else {
|
||||
val reason =
|
||||
when {
|
||||
@@ -262,7 +267,7 @@ class HealthConnectManager(private val context: Context) {
|
||||
suspend fun autoSyncCompletedSession(
|
||||
session: ClimbSession,
|
||||
gymName: String,
|
||||
attemptCount: Int = 0
|
||||
attemptCount: Int = 0,
|
||||
): Result<Unit> {
|
||||
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
|
||||
AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
|
||||
@@ -295,7 +300,7 @@ class HealthConnectManager(private val context: Context) {
|
||||
private fun createHeartRateRecord(
|
||||
startTime: Instant,
|
||||
endTime: Instant,
|
||||
attemptCount: Int
|
||||
attemptCount: Int,
|
||||
): HeartRateRecord? {
|
||||
return try {
|
||||
val samples = mutableListOf<HeartRateRecord.Sample>()
|
||||
@@ -324,7 +329,8 @@ class HealthConnectManager(private val context: Context) {
|
||||
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
|
||||
endTime = endTime,
|
||||
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
|
||||
samples = samples
|
||||
samples = samples,
|
||||
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
AppLogger.e(TAG, e) { "Error creating heart rate record" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.atridad.ascently.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
@@ -12,7 +13,7 @@ enum class AttemptResult {
|
||||
SUCCESS,
|
||||
FALL,
|
||||
NO_PROGRESS,
|
||||
FLASH
|
||||
FLASH,
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -23,16 +24,18 @@ enum class AttemptResult {
|
||||
entity = ClimbSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Problem::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["problemId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])]
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])],
|
||||
)
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class Attempt(
|
||||
@PrimaryKey val id: String,
|
||||
@@ -45,7 +48,7 @@ data class Attempt(
|
||||
val restTime: Long? = null,
|
||||
val timestamp: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
@@ -56,7 +59,7 @@ data class Attempt(
|
||||
notes: String? = null,
|
||||
duration: Long? = null,
|
||||
restTime: Long? = null,
|
||||
timestamp: String = DateFormatUtils.nowISO8601()
|
||||
timestamp: String = DateFormatUtils.nowISO8601(),
|
||||
): Attempt {
|
||||
val now = DateFormatUtils.nowISO8601()
|
||||
return Attempt(
|
||||
@@ -70,7 +73,7 @@ data class Attempt(
|
||||
restTime = restTime,
|
||||
timestamp = timestamp,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.atridad.ascently.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
@@ -11,7 +12,7 @@ import kotlinx.serialization.Serializable
|
||||
enum class SessionStatus {
|
||||
ACTIVE,
|
||||
COMPLETED,
|
||||
PAUSED
|
||||
PAUSED,
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -22,10 +23,12 @@ enum class SessionStatus {
|
||||
entity = Gym::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["gymId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
indices = [Index(value = ["gymId"])]
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index(value = ["gymId"])],
|
||||
)
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class ClimbSession(
|
||||
@PrimaryKey val id: String,
|
||||
@@ -37,7 +40,7 @@ data class ClimbSession(
|
||||
val status: SessionStatus = SessionStatus.ACTIVE,
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun create(gymId: String, notes: String? = null): ClimbSession {
|
||||
@@ -50,7 +53,7 @@ data class ClimbSession(
|
||||
status = SessionStatus.ACTIVE,
|
||||
notes = notes,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,17 +66,21 @@ data class ClimbSession(
|
||||
val end = DateFormatUtils.parseISO8601(endTime)
|
||||
if (start != null && end != null) {
|
||||
java.time.Duration.between(start, end).toMinutes()
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return this.copy(
|
||||
endTime = endTime,
|
||||
duration = durationMinutes,
|
||||
status = SessionStatus.COMPLETED,
|
||||
updatedAt = DateFormatUtils.nowISO8601()
|
||||
updatedAt = DateFormatUtils.nowISO8601(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
enum class ClimbType {
|
||||
ROPE,
|
||||
BOULDER;
|
||||
BOULDER,
|
||||
;
|
||||
|
||||
val displayName: String
|
||||
get() =
|
||||
|
||||
@@ -10,7 +10,8 @@ enum class DifficultySystem {
|
||||
|
||||
// Rope
|
||||
YDS,
|
||||
CUSTOM;
|
||||
CUSTOM,
|
||||
;
|
||||
|
||||
val displayName: String
|
||||
get() =
|
||||
@@ -60,7 +61,7 @@ enum class DifficultySystem {
|
||||
"V14",
|
||||
"V15",
|
||||
"V16",
|
||||
"V17"
|
||||
"V17",
|
||||
)
|
||||
FONT ->
|
||||
listOf(
|
||||
@@ -88,7 +89,7 @@ enum class DifficultySystem {
|
||||
"8B",
|
||||
"8B+",
|
||||
"8C",
|
||||
"8C+"
|
||||
"8C+",
|
||||
)
|
||||
YDS ->
|
||||
listOf(
|
||||
@@ -125,7 +126,7 @@ enum class DifficultySystem {
|
||||
"5.15a",
|
||||
"5.15b",
|
||||
"5.15c",
|
||||
"5.15d"
|
||||
"5.15d",
|
||||
)
|
||||
CUSTOM -> emptyList()
|
||||
}
|
||||
@@ -144,7 +145,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
|
||||
constructor(
|
||||
system: DifficultySystem,
|
||||
grade: String
|
||||
grade: String,
|
||||
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
|
||||
|
||||
companion object {
|
||||
@@ -180,7 +181,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
"8B" to 24,
|
||||
"8B+" to 25,
|
||||
"8C" to 26,
|
||||
"8C+" to 27
|
||||
"8C+" to 27,
|
||||
)
|
||||
fontMapping[grade] ?: 0
|
||||
}
|
||||
@@ -220,7 +221,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
"5.15a" to 80,
|
||||
"5.15b" to 81,
|
||||
"5.15c" to 82,
|
||||
"5.15d" to 83
|
||||
"5.15d" to 83,
|
||||
)
|
||||
ydsMapping[grade] ?: 0
|
||||
}
|
||||
@@ -228,6 +229,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare this grade with another grade of the same system Returns negative if this grade is
|
||||
* easier, positive if harder, 0 if equal
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.atridad.ascently.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Immutable
|
||||
@Entity(tableName = "gyms")
|
||||
@Serializable
|
||||
data class Gym(
|
||||
@@ -16,7 +18,7 @@ data class Gym(
|
||||
val customDifficultyGrades: List<String> = emptyList(),
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
@@ -25,7 +27,7 @@ data class Gym(
|
||||
supportedClimbTypes: List<ClimbType>,
|
||||
difficultySystems: List<DifficultySystem>,
|
||||
customDifficultyGrades: List<String> = emptyList(),
|
||||
notes: String? = null
|
||||
notes: String? = null,
|
||||
): Gym {
|
||||
val now = DateFormatUtils.nowISO8601()
|
||||
return Gym(
|
||||
@@ -37,7 +39,7 @@ data class Gym(
|
||||
customDifficultyGrades = customDifficultyGrades,
|
||||
notes = notes,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.atridad.ascently.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
@@ -7,6 +8,7 @@ import androidx.room.PrimaryKey
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Immutable
|
||||
@Entity(
|
||||
tableName = "problems",
|
||||
foreignKeys =
|
||||
@@ -15,9 +17,10 @@ import kotlinx.serialization.Serializable
|
||||
entity = Gym::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["gymId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
indices = [Index(value = ["gymId"])]
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index(value = ["gymId"])],
|
||||
)
|
||||
@Serializable
|
||||
data class Problem(
|
||||
@@ -34,7 +37,7 @@ data class Problem(
|
||||
val dateSet: String? = null,
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
@@ -47,7 +50,7 @@ data class Problem(
|
||||
location: String? = null,
|
||||
imagePaths: List<String> = emptyList(),
|
||||
dateSet: String? = null,
|
||||
notes: String? = null
|
||||
notes: String? = null,
|
||||
): Problem {
|
||||
val now = DateFormatUtils.nowISO8601()
|
||||
return Problem(
|
||||
@@ -64,7 +67,7 @@ data class Problem(
|
||||
dateSet = dateSet,
|
||||
notes = notes,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ import com.atridad.ascently.data.format.ClimbDataBackup
|
||||
import com.atridad.ascently.data.format.DeletedItem
|
||||
import com.atridad.ascently.data.model.*
|
||||
import com.atridad.ascently.data.state.DataStateManager
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import com.atridad.ascently.utils.ZipExportImportUtils
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
|
||||
private val gymDao = database.gymDao()
|
||||
@@ -38,6 +39,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
|
||||
// Gym operations
|
||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
||||
suspend fun getAllGymsSync(): List<Gym> = gymDao.getAllGyms().first()
|
||||
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||
suspend fun insertGym(gym: Gym) {
|
||||
gymDao.insertGym(gym)
|
||||
@@ -60,6 +62,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
|
||||
// Problem operations
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getAllProblemsSync(): List<Problem> = problemDao.getAllProblems().first()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||
suspend fun insertProblem(problem: Problem) {
|
||||
@@ -80,6 +83,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
|
||||
// Session operations
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
suspend fun getAllSessionsSync(): List<ClimbSession> = sessionDao.getAllSessions().first()
|
||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
sessionDao.getSessionsByGym(gymId)
|
||||
@@ -122,6 +126,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
|
||||
// Attempt operations
|
||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||
suspend fun getAllAttemptsSync(): List<Attempt> = attemptDao.getAllAttempts().first()
|
||||
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsBySession(sessionId)
|
||||
|
||||
@@ -161,7 +167,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
gyms = allGyms.map { BackupGym.fromGym(it) },
|
||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
||||
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
|
||||
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
|
||||
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) },
|
||||
)
|
||||
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
@@ -172,7 +178,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
val imageFile =
|
||||
com.atridad.ascently.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
imagePath,
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (_: Exception) {
|
||||
@@ -185,7 +191,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
context = context,
|
||||
uri = uri,
|
||||
exportData = backupData,
|
||||
referencedImagePaths = validImagePaths
|
||||
referencedImagePaths = validImagePaths,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
@@ -229,7 +235,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
val updatedBackupProblems =
|
||||
ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
importResult.importedImagePaths,
|
||||
)
|
||||
|
||||
updatedBackupProblems.forEach { backupProblem ->
|
||||
@@ -237,7 +243,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
problemDao.insertProblem(backupProblem.toProblem())
|
||||
} catch (e: Exception) {
|
||||
throw Exception(
|
||||
"Failed to import problem '${backupProblem.name}': ${e.message}"
|
||||
"Failed to import problem '${backupProblem.name}': ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -273,13 +279,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
}
|
||||
|
||||
fun trackDeletion(itemId: String, itemType: String) {
|
||||
val currentDeletions = getDeletedItems().toMutableList()
|
||||
cleanupOldDeletions()
|
||||
val newDeletion =
|
||||
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
|
||||
currentDeletions.add(newDeletion)
|
||||
|
||||
val json = json.encodeToString(newDeletion)
|
||||
deletionPreferences.edit { putString("deleted_${itemId}", json) }
|
||||
deletionPreferences.edit { putString("deleted_$itemId", json) }
|
||||
}
|
||||
|
||||
fun getDeletedItems(): List<DeletedItem> {
|
||||
@@ -304,24 +309,45 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
deletionPreferences.edit { clear() }
|
||||
}
|
||||
|
||||
private fun cleanupOldDeletions() {
|
||||
val allPrefs = deletionPreferences.all
|
||||
val cutoff = Instant.now().minusSeconds(90L * 24 * 60 * 60)
|
||||
|
||||
deletionPreferences.edit {
|
||||
for ((key, value) in allPrefs) {
|
||||
if (key.startsWith("deleted_") && value is String) {
|
||||
try {
|
||||
val deletion = json.decodeFromString<DeletedItem>(value)
|
||||
val deletedAt = Instant.parse(deletion.deletedAt)
|
||||
if (deletedAt.isBefore(cutoff)) {
|
||||
remove(key)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDataIntegrity(
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
sessions: List<ClimbSession>,
|
||||
attempts: List<Attempt>
|
||||
attempts: List<Attempt>,
|
||||
) {
|
||||
val gymIds = gyms.map { it.id }.toSet()
|
||||
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
||||
if (invalidProblems.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
|
||||
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms",
|
||||
)
|
||||
}
|
||||
|
||||
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||
if (invalidSessions.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
|
||||
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,7 +358,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
||||
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
|
||||
if (invalidAttempts.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
|
||||
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.atridad.ascently.data.format.BackupAttempt
|
||||
import com.atridad.ascently.data.format.BackupClimbSession
|
||||
import com.atridad.ascently.data.format.BackupGym
|
||||
import com.atridad.ascently.data.format.BackupProblem
|
||||
import com.atridad.ascently.data.format.DeletedItem
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Request structure for delta sync - sends only changes since last sync */
|
||||
@@ -15,16 +14,15 @@ data class DeltaSyncRequest(
|
||||
val problems: List<BackupProblem>,
|
||||
val sessions: List<BackupClimbSession>,
|
||||
val attempts: List<BackupAttempt>,
|
||||
val deletedItems: List<DeletedItem>
|
||||
)
|
||||
|
||||
/** Response structure for delta sync - receives only changes from server */
|
||||
@Serializable
|
||||
data class DeltaSyncResponse(
|
||||
val serverTime: String,
|
||||
val requestFullSync: Boolean = false,
|
||||
val gyms: List<BackupGym>,
|
||||
val problems: List<BackupProblem>,
|
||||
val sessions: List<BackupClimbSession>,
|
||||
val attempts: List<BackupAttempt>,
|
||||
val deletedItems: List<DeletedItem>
|
||||
)
|
||||
|
||||
@@ -18,4 +18,6 @@ sealed class SyncException(message: String) : IOException(message), Serializable
|
||||
SyncException("Invalid server response: $details")
|
||||
|
||||
data class NetworkError(val details: String) : SyncException("Network error: $details")
|
||||
|
||||
data class General(val details: String) : SyncException(details)
|
||||
}
|
||||
|
||||
@@ -14,5 +14,5 @@ interface SyncProvider {
|
||||
|
||||
enum class SyncProviderType {
|
||||
NONE,
|
||||
SERVER
|
||||
SERVER,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import com.atridad.ascently.data.state.DataStateManager
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -27,7 +28,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
|
||||
// Currently we only support one provider, but this allows for future expansion
|
||||
private val provider: SyncProvider = AscentlySyncProvider(context, repository)
|
||||
private val provider: SyncProvider = AscentlySyncProvider(context, repository, DataStateManager(context))
|
||||
|
||||
// State
|
||||
private val _isSyncing = MutableStateFlow(false)
|
||||
@@ -104,7 +105,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
// Update last sync time from shared prefs (provider updates it)
|
||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
|
||||
} catch (e: Exception) {
|
||||
_syncError.value = e.message
|
||||
throw e
|
||||
|
||||
@@ -11,26 +11,26 @@ val bottomNavigationItems =
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Sessions,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
label = "Sessions"
|
||||
label = "Sessions",
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Problems,
|
||||
icon = Icons.Default.Star,
|
||||
label = "Problems"
|
||||
label = "Problems",
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Analytics,
|
||||
icon = Icons.Default.Info,
|
||||
label = "Analytics"
|
||||
label = "Analytics",
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Gyms,
|
||||
icon = Icons.Default.LocationOn,
|
||||
label = "Gyms"
|
||||
label = "Gyms",
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Settings,
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Settings"
|
||||
)
|
||||
label = "Settings",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,13 +15,14 @@ import com.atridad.ascently.R
|
||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class SessionTrackingService : Service() {
|
||||
|
||||
@@ -224,12 +225,12 @@ class SessionTrackingService : Service() {
|
||||
.addAction(
|
||||
R.drawable.ic_mountains,
|
||||
"Open Session",
|
||||
createOpenAppIntent()
|
||||
createOpenAppIntent(),
|
||||
)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"End Session",
|
||||
createStopPendingIntent(sessionId)
|
||||
createStopPendingIntent(sessionId),
|
||||
)
|
||||
|
||||
// Use Live Update
|
||||
@@ -237,9 +238,8 @@ class SessionTrackingService : Service() {
|
||||
val startTimeMillis =
|
||||
session.startTime?.let { startTime ->
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val zoneId = ZoneId.systemDefault()
|
||||
start.atZone(zoneId).toInstant().toEpochMilli()
|
||||
DateFormatUtils.parseISO8601(startTime)?.toEpochMilli()
|
||||
?: System.currentTimeMillis()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
@@ -249,7 +249,7 @@ class SessionTrackingService : Service() {
|
||||
notificationBuilder
|
||||
.setContentTitle("Climbing Session Active")
|
||||
.setContentText(
|
||||
"${gym?.name ?: "Gym"} • ${attempts.size} attempts"
|
||||
"${gym?.name ?: "Gym"} • ${attempts.size} attempts",
|
||||
)
|
||||
.setWhen(startTimeMillis)
|
||||
.setUsesChronometer(true)
|
||||
@@ -263,9 +263,9 @@ class SessionTrackingService : Service() {
|
||||
val duration =
|
||||
session.startTime?.let { startTime ->
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val now = LocalDateTime.now()
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||
val start = DateFormatUtils.parseISO8601(startTime)
|
||||
val now = java.time.Instant.now()
|
||||
val totalSeconds = if (start != null) ChronoUnit.SECONDS.between(start, now) else 0L
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
@@ -284,7 +284,7 @@ class SessionTrackingService : Service() {
|
||||
notificationBuilder
|
||||
.setContentTitle("Climbing Session Active")
|
||||
.setContentText(
|
||||
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts"
|
||||
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ class SessionTrackingService : Service() {
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ class SessionTrackingService : Service() {
|
||||
this,
|
||||
1,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ class SessionTrackingService : Service() {
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session Tracking",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
.apply {
|
||||
description = "Shows active climbing session information"
|
||||
|
||||
@@ -35,7 +35,7 @@ import com.atridad.ascently.utils.NotificationPermissionUtils
|
||||
fun AscentlyApp(
|
||||
shortcutAction: String? = null,
|
||||
lastUsedGymId: String? = null,
|
||||
onShortcutActionProcessed: () -> Unit = {}
|
||||
onShortcutActionProcessed: () -> Unit = {},
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
@@ -53,7 +53,7 @@ fun AscentlyApp(
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted: Boolean ->
|
||||
if (!isGranted) {
|
||||
showNotificationPermissionDialog = false
|
||||
@@ -90,7 +90,7 @@ fun AscentlyApp(
|
||||
context = context,
|
||||
hasActiveSession = activeSession != null,
|
||||
hasGyms = gyms.isNotEmpty(),
|
||||
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
|
||||
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ fun AscentlyApp(
|
||||
if (activeSession == null) {
|
||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(
|
||||
context
|
||||
context,
|
||||
)
|
||||
) {
|
||||
AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
|
||||
@@ -160,20 +160,20 @@ fun AscentlyApp(
|
||||
fabConfig?.let { config ->
|
||||
FloatingActionButton(
|
||||
onClick = config.onClick,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = config.icon,
|
||||
contentDescription = config.contentDescription
|
||||
contentDescription = config.contentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Sessions,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
) {
|
||||
composable<Screen.Sessions> {
|
||||
LaunchedEffect(gyms, activeSession) {
|
||||
@@ -187,14 +187,14 @@ fun AscentlyApp(
|
||||
.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils
|
||||
.isNotificationPermissionGranted(
|
||||
context
|
||||
context,
|
||||
)
|
||||
) {
|
||||
showNotificationPermissionDialog = true
|
||||
} else {
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -204,7 +204,7 @@ fun AscentlyApp(
|
||||
viewModel = viewModel,
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ fun AscentlyApp(
|
||||
contentDescription = "Add Problem",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditProblem())
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -227,7 +227,7 @@ fun AscentlyApp(
|
||||
viewModel = viewModel,
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,14 +242,14 @@ fun AscentlyApp(
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Gym",
|
||||
onClick = { navController.navigate(Screen.AddEditGym()) }
|
||||
onClick = { navController.navigate(Screen.AddEditGym()) },
|
||||
)
|
||||
}
|
||||
GymsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToGymDetail = { gymId ->
|
||||
navController.navigate(Screen.GymDetail(gymId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ fun AscentlyApp(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ fun AscentlyApp(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { problemId ->
|
||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ fun AscentlyApp(
|
||||
},
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ fun AscentlyApp(
|
||||
AddEditGymScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ fun AscentlyApp(
|
||||
problemId = args.problemId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ fun AscentlyApp(
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -341,9 +341,9 @@ fun AscentlyApp(
|
||||
onDismiss = { showNotificationPermissionDialog = false },
|
||||
onRequestPermission = {
|
||||
permissionLauncher.launch(
|
||||
NotificationPermissionUtils.getNotificationPermissionString()
|
||||
NotificationPermissionUtils.getNotificationPermissionString(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,7 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
|
||||
// Don't restore state - always start fresh when switching tabs
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -386,5 +386,5 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
|
||||
data class FabConfig(
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val onClick: () -> Unit
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -13,8 +13,9 @@ import androidx.compose.ui.unit.dp
|
||||
import com.atridad.ascently.data.model.ClimbSession
|
||||
import com.atridad.ascently.data.model.Gym
|
||||
import com.atridad.ascently.ui.theme.CustomIcons
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Composable
|
||||
@@ -22,102 +23,113 @@ fun ActiveSessionBanner(
|
||||
activeSession: ClimbSession?,
|
||||
gym: Gym?,
|
||||
onSessionClick: () -> Unit,
|
||||
onEndSession: () -> Unit
|
||||
onEndSession: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (activeSession != null) {
|
||||
// Add a timer that updates every second for real-time duration counting
|
||||
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
|
||||
if (activeSession == null) return
|
||||
|
||||
val sessionId = activeSession.id
|
||||
val startTimeString = activeSession.startTime
|
||||
val gymName = gym?.name ?: "Unknown Gym"
|
||||
|
||||
var elapsedSeconds by remember(sessionId) { mutableLongStateOf(0L) }
|
||||
|
||||
LaunchedEffect(sessionId, startTimeString) {
|
||||
if (startTimeString == null) return@LaunchedEffect
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1000) // Update every second
|
||||
currentTime = LocalDateTime.now()
|
||||
elapsedSeconds = calculateElapsedSeconds(startTimeString)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val durationText = remember(elapsedSeconds) {
|
||||
formatDuration(elapsedSeconds)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSessionClick() },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Active session",
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
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
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = gym?.name ?: "Unknown Gym",
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
|
||||
activeSession.startTime?.let { startTime ->
|
||||
val duration = calculateDuration(startTime, currentTime)
|
||||
if (startTimeString != null) {
|
||||
Text(
|
||||
text = duration,
|
||||
text = durationText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
FilledIconButton(
|
||||
onClick = onEndSession,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
||||
contentDescription = "End session"
|
||||
contentDescription = "End session",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
||||
private fun calculateElapsedSeconds(startTimeString: String): Long {
|
||||
return try {
|
||||
val startTime = LocalDateTime.parse(startTimeString)
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||
val startTime = DateFormatUtils.parseISO8601(startTimeString) ?: return 0L
|
||||
val now = Instant.now()
|
||||
ChronoUnit.SECONDS.between(startTime, now).coerceAtLeast(0)
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(totalSeconds: Long): String {
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
return when {
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
else -> "${seconds}s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ data class BarChartStyle(
|
||||
val barColor: Color,
|
||||
val gridColor: Color,
|
||||
val textColor: Color,
|
||||
val backgroundColor: Color
|
||||
val backgroundColor: Color,
|
||||
)
|
||||
|
||||
/** Custom Bar Chart for displaying grade distribution */
|
||||
@@ -39,9 +39,9 @@ fun BarChart(
|
||||
barColor = MaterialTheme.colorScheme.primary,
|
||||
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
backgroundColor = MaterialTheme.colorScheme.surface
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
showGrid: Boolean = true
|
||||
showGrid: Boolean = true,
|
||||
) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
@@ -70,7 +70,7 @@ fun BarChart(
|
||||
drawRect(
|
||||
color = style.backgroundColor,
|
||||
topLeft = Offset(padding, padding),
|
||||
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
|
||||
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight),
|
||||
)
|
||||
|
||||
// Draw grid
|
||||
@@ -82,7 +82,7 @@ fun BarChart(
|
||||
gridColor = style.gridColor,
|
||||
maxValue = maxValue,
|
||||
textMeasurer = textMeasurer,
|
||||
textColor = style.textColor
|
||||
textColor = style.textColor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ fun BarChart(
|
||||
val barHeight =
|
||||
if (maxValue > 0) {
|
||||
(dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
|
||||
} else 0f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
val barX =
|
||||
padding +
|
||||
@@ -103,7 +105,7 @@ fun BarChart(
|
||||
drawRect(
|
||||
color = style.barColor,
|
||||
topLeft = Offset(barX, barY),
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
|
||||
)
|
||||
|
||||
// Draw value on bar
|
||||
@@ -131,7 +133,7 @@ fun BarChart(
|
||||
textMeasurer = textMeasurer,
|
||||
text = valueText,
|
||||
style = textStyle.copy(color = textColor),
|
||||
topLeft = Offset(barX + barWidth / 2f - textSize.size.width / 2f, textY)
|
||||
topLeft = Offset(barX + barWidth / 2f - textSize.size.width / 2f, textY),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,8 +149,8 @@ fun BarChart(
|
||||
topLeft =
|
||||
Offset(
|
||||
barX + barWidth / 2f - labelTextSize.size.width / 2f,
|
||||
padding + chartHeight + 8.dp.toPx()
|
||||
)
|
||||
padding + chartHeight + 8.dp.toPx(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +164,7 @@ private fun DrawScope.drawGrid(
|
||||
gridColor: Color,
|
||||
maxValue: Int,
|
||||
textMeasurer: TextMeasurer,
|
||||
textColor: Color
|
||||
textColor: Color,
|
||||
) {
|
||||
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
|
||||
|
||||
@@ -186,7 +188,7 @@ private fun DrawScope.drawGrid(
|
||||
color = gridColor,
|
||||
start = Offset(padding, y),
|
||||
end = Offset(padding + chartWidth, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
)
|
||||
|
||||
// Draw Y-axis label
|
||||
@@ -200,8 +202,8 @@ private fun DrawScope.drawGrid(
|
||||
topLeft =
|
||||
Offset(
|
||||
padding - textSize.size.width - 8.dp.toPx(),
|
||||
y - textSize.size.height / 2f
|
||||
)
|
||||
y - textSize.size.height / 2f,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
if (imagePaths.size > 1) {
|
||||
thumbnailListState.animateScrollToItem(
|
||||
index = pagerState.currentPage,
|
||||
scrollOffset = -200
|
||||
scrollOffset = -200,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
|
||||
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
|
||||
// Main image pager
|
||||
@@ -58,26 +58,26 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
imagePath = imagePaths[page],
|
||||
contentDescription = "Full screen image",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
|
||||
// Top bar with back button and counter
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
|
||||
color = Color.Black.copy(alpha = 0.6f)
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Back button
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,13 +100,13 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
if (imagePaths.size > 1) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||
color = Color.Black.copy(alpha = 0.6f)
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
) {
|
||||
LazyRow(
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val isSelected = index == pagerState.currentPage
|
||||
@@ -119,13 +119,13 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
// Selection indicator
|
||||
@@ -135,20 +135,20 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
Color.Transparent,
|
||||
RoundedCornerShape(8.dp)
|
||||
RoundedCornerShape(8.dp),
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
)
|
||||
Color.White.copy(alpha = 0.2f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ fun ImageDisplay(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
onImageClick: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
LocalContext.current
|
||||
|
||||
@@ -34,7 +34,7 @@ fun ImageDisplay(
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,14 @@ fun ImageDisplaySection(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
onImageClick: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -35,7 +35,7 @@ fun ImagePicker(
|
||||
imageUris: List<String>,
|
||||
onImagesChanged: (List<String>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxImages: Int = 5
|
||||
maxImages: Int = 5,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||
@@ -45,7 +45,7 @@ fun ImagePicker(
|
||||
// Image picker launcher
|
||||
val imagePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
contract = ActivityResultContracts.GetMultipleContents(),
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
val currentCount = tempImageUris.size
|
||||
@@ -88,7 +88,7 @@ fun ImagePicker(
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
// Create image file for camera
|
||||
@@ -97,7 +97,7 @@ fun ImagePicker(
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
imageFile
|
||||
imageFile,
|
||||
)
|
||||
cameraImageUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
@@ -108,11 +108,11 @@ fun ImagePicker(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
if (tempImageUris.size < maxImages) {
|
||||
@@ -120,7 +120,7 @@ fun ImagePicker(
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Add Photos")
|
||||
@@ -142,7 +142,7 @@ fun ImagePicker(
|
||||
|
||||
// Delete the image file
|
||||
ImageUtils.deleteImage(context, imagePath)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,22 +154,22 @@ fun ImagePicker(
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Add photos of this problem",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -188,12 +188,12 @@ fun ImagePicker(
|
||||
onClick = {
|
||||
showImageSourceDialog = false
|
||||
imagePickerLauncher.launch("image/*")
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Gallery")
|
||||
@@ -202,9 +202,10 @@ fun ImagePicker(
|
||||
TextButton(
|
||||
onClick = {
|
||||
showImageSourceDialog = false
|
||||
when (ContextCompat.checkSelfPermission(
|
||||
when (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
Manifest.permission.CAMERA,
|
||||
)
|
||||
) {
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
@@ -214,23 +215,23 @@ fun ImagePicker(
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
imageFile
|
||||
imageFile,
|
||||
)
|
||||
cameraImageUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(
|
||||
Manifest.permission.CAMERA
|
||||
Manifest.permission.CAMERA,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Camera")
|
||||
@@ -239,7 +240,7 @@ fun ImagePicker(
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +263,7 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
|
||||
@@ -270,14 +271,14 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove photo",
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.sp
|
||||
data class ChartDataPoint(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val label: String? = null
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ data class ChartStyle(
|
||||
val lineWidth: Float = 3f,
|
||||
val gridColor: Color,
|
||||
val textColor: Color,
|
||||
val backgroundColor: Color
|
||||
val backgroundColor: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -52,11 +52,11 @@ fun LineChart(
|
||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
backgroundColor = MaterialTheme.colorScheme.surface
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
showGrid: Boolean = true,
|
||||
xAxisFormatter: (Float) -> String = { it.toString() },
|
||||
yAxisFormatter: (Float) -> String = { it.toString() }
|
||||
yAxisFormatter: (Float) -> String = { it.toString() },
|
||||
) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
@@ -65,7 +65,7 @@ fun LineChart(
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
if (data.isEmpty()) return@Canvas
|
||||
|
||||
@@ -102,7 +102,7 @@ fun LineChart(
|
||||
drawRect(
|
||||
color = style.backgroundColor,
|
||||
topLeft = Offset(padding, padding),
|
||||
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
|
||||
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight),
|
||||
)
|
||||
|
||||
// Draw grid
|
||||
@@ -120,7 +120,7 @@ fun LineChart(
|
||||
textColor = style.textColor,
|
||||
xAxisFormatter = xAxisFormatter,
|
||||
yAxisFormatter = yAxisFormatter,
|
||||
actualDataPoints = data
|
||||
actualDataPoints = data,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fun LineChart(
|
||||
points = screenPoints,
|
||||
padding = padding,
|
||||
chartHeight = chartHeight,
|
||||
fillColor = style.fillColor
|
||||
fillColor = style.fillColor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ fun LineChart(
|
||||
drawLine(
|
||||
points = screenPoints,
|
||||
lineColor = style.lineColor,
|
||||
lineWidth = style.lineWidth
|
||||
lineWidth = style.lineWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,20 +149,20 @@ fun LineChart(
|
||||
drawCircle(
|
||||
color = style.lineColor,
|
||||
radius = 8f,
|
||||
center = point
|
||||
center = point,
|
||||
)
|
||||
// Draw inner circle (white center)
|
||||
drawCircle(
|
||||
color = style.backgroundColor,
|
||||
radius = 5f,
|
||||
center = point
|
||||
center = point,
|
||||
)
|
||||
// Draw border for better visibility
|
||||
drawCircle(
|
||||
color = style.lineColor,
|
||||
radius = 8f,
|
||||
center = point,
|
||||
style = Stroke(width = 2f)
|
||||
style = Stroke(width = 2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,11 +182,11 @@ private fun DrawScope.drawGrid(
|
||||
textColor: Color,
|
||||
xAxisFormatter: (Float) -> String,
|
||||
yAxisFormatter: (Float) -> String,
|
||||
actualDataPoints: List<ChartDataPoint>
|
||||
actualDataPoints: List<ChartDataPoint>,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = textColor,
|
||||
fontSize = 10.sp
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
// Draw vertical grid lines (X-axis) - only at integer values for sessions
|
||||
@@ -203,7 +203,7 @@ private fun DrawScope.drawGrid(
|
||||
color = gridColor,
|
||||
start = Offset(x, padding),
|
||||
end = Offset(x, padding + chartHeight),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
)
|
||||
|
||||
// X-axis labels removed per user request
|
||||
@@ -226,7 +226,7 @@ private fun DrawScope.drawGrid(
|
||||
color = gridColor,
|
||||
start = Offset(padding, y),
|
||||
end = Offset(padding + chartWidth, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
)
|
||||
|
||||
// Draw label
|
||||
@@ -238,8 +238,8 @@ private fun DrawScope.drawGrid(
|
||||
style = textStyle,
|
||||
topLeft = Offset(
|
||||
padding - textSize.size.width - 8.dp.toPx(),
|
||||
y - textSize.size.height / 2f
|
||||
)
|
||||
y - textSize.size.height / 2f,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ private fun DrawScope.drawAreaFill(
|
||||
points: List<Offset>,
|
||||
padding: Float,
|
||||
chartHeight: Float,
|
||||
fillColor: Color
|
||||
fillColor: Color,
|
||||
) {
|
||||
val bottomY = padding + chartHeight // This represents the bottom of the chart area
|
||||
|
||||
@@ -274,14 +274,14 @@ private fun DrawScope.drawAreaFill(
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
color = fillColor
|
||||
color = fillColor,
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawLine(
|
||||
points: List<Offset>,
|
||||
lineColor: Color,
|
||||
lineWidth: Float
|
||||
lineWidth: Float,
|
||||
) {
|
||||
val path = Path().apply {
|
||||
moveTo(points.first().x, points.first().y)
|
||||
@@ -296,7 +296,7 @@ private fun DrawScope.drawLine(
|
||||
style = Stroke(
|
||||
width = lineWidth,
|
||||
cap = StrokeCap.Round,
|
||||
join = StrokeJoin.Round
|
||||
)
|
||||
join = StrokeJoin.Round,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,21 +16,21 @@ import androidx.compose.ui.window.DialogProperties
|
||||
fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Notifications,
|
||||
contentDescription = "Notifications",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -39,7 +39,7 @@ fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: ()
|
||||
text = "Enable Notifications",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -49,14 +49,14 @@ fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: ()
|
||||
"Ascently needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) {
|
||||
Text("Not Now")
|
||||
@@ -67,7 +67,7 @@ fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: ()
|
||||
onRequestPermission()
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Enable") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,16 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun OrientationAwareImage(
|
||||
imagePath: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var imageBitmap by
|
||||
@@ -62,7 +62,7 @@ fun OrientationAwareImage(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,14 +71,14 @@ fun OrientationAwareImage(
|
||||
|
||||
private fun correctImageOrientation(
|
||||
imageFile: File,
|
||||
bitmap: android.graphics.Bitmap
|
||||
bitmap: android.graphics.Bitmap,
|
||||
): android.graphics.Bitmap {
|
||||
return try {
|
||||
val exif = ExifInterface(imageFile.absolutePath)
|
||||
val orientation =
|
||||
exif.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
|
||||
val matrix = Matrix()
|
||||
@@ -131,7 +131,7 @@ private fun correctImageOrientation(
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
matrix,
|
||||
true
|
||||
true,
|
||||
)
|
||||
if (rotatedBitmap != bitmap) {
|
||||
bitmap.recycle()
|
||||
|
||||
@@ -29,7 +29,7 @@ fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier)
|
||||
visible = syncing,
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut(),
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -37,12 +37,12 @@ fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
||||
.padding(6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
// Permission launcher
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = healthConnectManager.getPermissionRequestContract()
|
||||
contract = healthConnectManager.getPermissionRequestContract(),
|
||||
) { _ ->
|
||||
coroutineScope.launch {
|
||||
val allGranted = healthConnectManager.hasAllPermissions()
|
||||
@@ -77,17 +77,17 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Header with icon and title
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.HealthAndSafety,
|
||||
@@ -98,7 +98,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
@@ -108,7 +108,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
text = "Health Connect",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -126,7 +126,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
when {
|
||||
isLoading ->
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.7f
|
||||
alpha = 0.7f,
|
||||
)
|
||||
|
||||
!isCompatible -> MaterialTheme.colorScheme.error
|
||||
@@ -139,9 +139,9 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
|
||||
else ->
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.7f
|
||||
alpha = 0.7f,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = isHealthConnectAvailable && !isLoading && isCompatible
|
||||
enabled = isHealthConnectAvailable && !isLoading && isCompatible,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (!hasPermissions) {
|
||||
@@ -190,9 +190,9 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -200,7 +200,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -209,7 +209,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
text = "Permissions needed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,8 +221,8 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.8f
|
||||
)
|
||||
alpha = 0.8f,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -243,7 +243,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Grant Permissions") }
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
errorMessage?.let { error ->
|
||||
@@ -266,19 +266,19 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
alpha = 0.5f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -286,7 +286,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.atridad.ascently.data.model.*
|
||||
import com.atridad.ascently.ui.components.ImagePicker
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -72,7 +72,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -85,7 +85,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
location,
|
||||
selectedClimbTypes.toList(),
|
||||
selectedDifficultySystems.toList(),
|
||||
notes = notes
|
||||
notes = notes,
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
@@ -98,15 +98,15 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
enabled =
|
||||
name.isNotBlank() &&
|
||||
selectedClimbTypes.isNotEmpty() &&
|
||||
selectedDifficultySystems.isNotEmpty()
|
||||
selectedDifficultySystems.isNotEmpty(),
|
||||
) { Text("Save") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Name field
|
||||
OutlinedTextField(
|
||||
@@ -114,7 +114,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Gym Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
// Location field
|
||||
@@ -123,7 +123,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
onValueChange = { location = it },
|
||||
label = { Text("Location (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
// Climb Types
|
||||
@@ -132,7 +132,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
Text(
|
||||
text = "Supported Climb Types",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -156,12 +156,12 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
climbType
|
||||
}
|
||||
},
|
||||
role = Role.Checkbox
|
||||
)
|
||||
role = Role.Checkbox,
|
||||
),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = climbType in selectedClimbTypes,
|
||||
onCheckedChange = null
|
||||
onCheckedChange = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(climbType.displayName)
|
||||
@@ -176,7 +176,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
Text(
|
||||
text = "Difficulty Systems",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -187,7 +187,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
"Select climb types first to see available difficulty systems",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
availableDifficultySystems.forEach { system ->
|
||||
@@ -211,12 +211,12 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
system
|
||||
}
|
||||
},
|
||||
role = Role.Checkbox
|
||||
)
|
||||
role = Role.Checkbox,
|
||||
),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = system in selectedDifficultySystems,
|
||||
onCheckedChange = null
|
||||
onCheckedChange = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(system.displayName)
|
||||
@@ -232,7 +232,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3
|
||||
minLines = 3,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,7 @@ fun AddEditProblemScreen(
|
||||
problemId: String?,
|
||||
gymId: String?,
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateBack: () -> Unit
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val isEditing = problemId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
@@ -337,7 +337,7 @@ fun AddEditProblemScreen(
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -350,12 +350,13 @@ fun AddEditProblemScreen(
|
||||
system = selectedDifficultySystem,
|
||||
grade = difficultyGrade,
|
||||
numericValue =
|
||||
when (selectedDifficultySystem
|
||||
when (
|
||||
selectedDifficultySystem
|
||||
) {
|
||||
DifficultySystem.V_SCALE ->
|
||||
difficultyGrade
|
||||
.removePrefix(
|
||||
"V"
|
||||
"V",
|
||||
)
|
||||
.toIntOrNull()
|
||||
?: 0
|
||||
@@ -363,7 +364,7 @@ fun AddEditProblemScreen(
|
||||
difficultyGrade
|
||||
.hashCode() %
|
||||
100 // Simple mapping for other systems
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val problem =
|
||||
@@ -382,7 +383,7 @@ fun AddEditProblemScreen(
|
||||
},
|
||||
location = location.ifBlank { null },
|
||||
imagePaths = imagePaths,
|
||||
notes = notes.ifBlank { null }
|
||||
notes = notes.ifBlank { null },
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
@@ -395,15 +396,15 @@ fun AddEditProblemScreen(
|
||||
onNavigateBack()
|
||||
}
|
||||
},
|
||||
enabled = selectedGym != null && difficultyGrade.isNotBlank()
|
||||
enabled = selectedGym != null && difficultyGrade.isNotBlank(),
|
||||
) { Text("Save") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Gym Selection
|
||||
item {
|
||||
@@ -412,7 +413,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Select Gym",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -421,7 +422,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "No gyms available. Add a gym first.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -429,7 +430,7 @@ fun AddEditProblemScreen(
|
||||
FilterChip(
|
||||
onClick = { selectedGym = gym },
|
||||
label = { Text(gym.name) },
|
||||
selected = selectedGym?.id == gym.id
|
||||
selected = selectedGym?.id == gym.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -445,7 +446,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Problem Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -456,7 +457,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Problem Name (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("e.g., 'The Overhang Monster', 'Yellow V4'") }
|
||||
placeholder = { Text("e.g., 'The Overhang Monster', 'Yellow V4'") },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -467,7 +468,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Description (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
placeholder = { Text("Describe the problem, holds, style, etc.") }
|
||||
placeholder = { Text("Describe the problem, holds, style, etc.") },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -480,7 +481,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Location (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("e.g., 'Cave area', 'Wall 3', 'Right side'") }
|
||||
placeholder = { Text("e.g., 'Cave area', 'Wall 3', 'Right side'") },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -494,7 +495,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Climb Type",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -504,7 +505,7 @@ fun AddEditProblemScreen(
|
||||
FilterChip(
|
||||
onClick = { selectedClimbType = climbType },
|
||||
label = { Text(climbType.displayName) },
|
||||
selected = selectedClimbType == climbType
|
||||
selected = selectedClimbType == climbType,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -521,7 +522,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Difficulty",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -529,7 +530,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Difficulty System",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -537,7 +538,7 @@ fun AddEditProblemScreen(
|
||||
FilterChip(
|
||||
onClick = { selectedDifficultySystem = system },
|
||||
label = { Text(system.displayName) },
|
||||
selected = selectedDifficultySystem == system
|
||||
selected = selectedDifficultySystem == system,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -564,7 +565,7 @@ fun AddEditProblemScreen(
|
||||
Text("Custom grades must be whole numbers")
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
} else {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -573,7 +574,7 @@ fun AddEditProblemScreen(
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = difficultyGrade,
|
||||
@@ -582,7 +583,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Grade *") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = expanded
|
||||
expanded = expanded,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
@@ -592,13 +593,13 @@ fun AddEditProblemScreen(
|
||||
Modifier.menuAnchor(
|
||||
ExposedDropdownMenuAnchorType
|
||||
.PrimaryNotEditable,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
availableGrades.forEach { grade ->
|
||||
DropdownMenuItem(
|
||||
@@ -606,7 +607,7 @@ fun AddEditProblemScreen(
|
||||
onClick = {
|
||||
difficultyGrade = grade
|
||||
expanded = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -624,7 +625,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Photos",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -632,7 +633,7 @@ fun AddEditProblemScreen(
|
||||
ImagePicker(
|
||||
imageUris = imagePaths,
|
||||
onImagesChanged = { imagePaths = it },
|
||||
maxImages = 5
|
||||
maxImages = 5,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -644,7 +645,7 @@ fun AddEditProblemScreen(
|
||||
Text(
|
||||
text = "Additional Info",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -655,7 +656,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Tags (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") }
|
||||
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -666,7 +667,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Notes (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
placeholder = { Text("Any additional notes about this problem") }
|
||||
placeholder = { Text("Any additional notes about this problem") },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -678,14 +679,14 @@ fun AddEditProblemScreen(
|
||||
.selectable(
|
||||
selected = isActive,
|
||||
onClick = { isActive = !isActive },
|
||||
role = Role.Checkbox
|
||||
)
|
||||
role = Role.Checkbox,
|
||||
),
|
||||
) {
|
||||
Checkbox(checked = isActive, onCheckedChange = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Problem is currently active",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -701,7 +702,7 @@ fun AddEditSessionScreen(
|
||||
sessionId: String?,
|
||||
gymId: String?,
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateBack: () -> Unit
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val isEditing = sessionId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
@@ -745,7 +746,7 @@ fun AddEditSessionScreen(
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -760,7 +761,7 @@ fun AddEditSessionScreen(
|
||||
notes =
|
||||
sessionNotes.ifBlank {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
sessionId.let { id ->
|
||||
viewModel.updateSession(session.copy(id = id))
|
||||
@@ -769,21 +770,21 @@ fun AddEditSessionScreen(
|
||||
viewModel.startSession(
|
||||
context,
|
||||
gym.id,
|
||||
sessionNotes.ifBlank { null }
|
||||
sessionNotes.ifBlank { null },
|
||||
)
|
||||
}
|
||||
onNavigateBack()
|
||||
}
|
||||
},
|
||||
enabled = selectedGym != null
|
||||
enabled = selectedGym != null,
|
||||
) { Text("Save") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Gym Selection
|
||||
item {
|
||||
@@ -792,7 +793,7 @@ fun AddEditSessionScreen(
|
||||
Text(
|
||||
text = "Select Gym",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -801,7 +802,7 @@ fun AddEditSessionScreen(
|
||||
Text(
|
||||
text = "No gyms available. Add a gym first.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -809,7 +810,7 @@ fun AddEditSessionScreen(
|
||||
FilterChip(
|
||||
onClick = { selectedGym = gym },
|
||||
label = { Text(gym.name) },
|
||||
selected = selectedGym?.id == gym.id
|
||||
selected = selectedGym?.id == gym.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -825,7 +826,7 @@ fun AddEditSessionScreen(
|
||||
Text(
|
||||
text = "Session Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -835,7 +836,7 @@ fun AddEditSessionScreen(
|
||||
onValueChange = { sessionDate = it },
|
||||
label = { Text("Date (YYYY-MM-DD)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -847,7 +848,7 @@ fun AddEditSessionScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -857,7 +858,7 @@ fun AddEditSessionScreen(
|
||||
onValueChange = { sessionNotes = it },
|
||||
label = { Text("Session Notes (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3
|
||||
minLines = 3,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,27 +27,37 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
||||
val attempts by viewModel.attempts.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
val gradeDistributionData = remember(sessions, problems, attempts) {
|
||||
calculateGradeDistribution(sessions, problems, attempts)
|
||||
}
|
||||
|
||||
val favoriteGym = remember(sessions, gyms) {
|
||||
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let { (gymId, sessionList) ->
|
||||
gyms.find { it.id == gymId }?.name to sessionList.size
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "Ascently Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Analytics",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
@@ -59,27 +69,20 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
||||
totalSessions = sessions.size,
|
||||
totalProblems = problems.size,
|
||||
totalAttempts = attempts.size,
|
||||
totalGyms = gyms.size
|
||||
totalGyms = gyms.size,
|
||||
)
|
||||
}
|
||||
|
||||
// Grade Distribution Chart
|
||||
item {
|
||||
val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
|
||||
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
|
||||
}
|
||||
|
||||
// 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
|
||||
sessionCount = favoriteGym?.second ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,14 +101,14 @@ fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int,
|
||||
Text(
|
||||
text = "Overall Stats",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
StatItem(label = "Sessions", value = totalSessions.toString())
|
||||
StatItem(label = "Problems", value = totalProblems.toString())
|
||||
@@ -137,7 +140,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
Text(
|
||||
text = "Grade Distribution",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -146,7 +149,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Time period toggle
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -161,8 +164,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor =
|
||||
MaterialTheme.colorScheme.primary,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
|
||||
// 7 Days button
|
||||
@@ -174,8 +177,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor =
|
||||
MaterialTheme.colorScheme.primary,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,7 +186,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
if (usedSystems.size > 1) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value =
|
||||
@@ -203,14 +206,14 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
type =
|
||||
ExposedDropdownMenuAnchorType
|
||||
.PrimaryNotEditable,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
)
|
||||
.width(120.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
usedSystems.forEach { system ->
|
||||
DropdownMenuItem(
|
||||
@@ -221,13 +224,13 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
DifficultySystem.FONT -> "Font"
|
||||
DifficultySystem.YDS -> "YDS"
|
||||
DifficultySystem.CUSTOM -> "Custom"
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selectedSystem = system
|
||||
expanded = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -275,7 +278,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
BarChartDataPoint(
|
||||
label = grade,
|
||||
value = count,
|
||||
gradeNumeric = firstDataPoint.gradeNumeric
|
||||
gradeNumeric = firstDataPoint.gradeNumeric,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,26 +288,26 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Successful climbs by ${when(selectedSystem) {
|
||||
"Successful climbs by ${when (selectedSystem) {
|
||||
DifficultySystem.V_SCALE -> "V-grade"
|
||||
DifficultySystem.FONT -> "Font grade"
|
||||
DifficultySystem.YDS -> "YDS grade"
|
||||
DifficultySystem.CUSTOM -> "custom grade"
|
||||
}}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().height(220.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "No data",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -312,16 +315,18 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
Text(
|
||||
text = "No data available.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (showAllTime)
|
||||
if (showAllTime) {
|
||||
"Complete some climbs to see your grade distribution!"
|
||||
else "No climbs in the last 7 days",
|
||||
} else {
|
||||
"No climbs in the last 7 days"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +341,7 @@ fun FavoriteGymCard(gymName: String, sessionCount: Int) {
|
||||
Text(
|
||||
text = "Favorite Gym",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -344,14 +349,14 @@ fun FavoriteGymCard(gymName: String, sessionCount: Int) {
|
||||
Text(
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
if (sessionCount > 0) {
|
||||
Text(
|
||||
text = "$sessionCount sessions",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -365,7 +370,7 @@ fun RecentActivityCard(recentSessions: Int) {
|
||||
Text(
|
||||
text = "Recent Activity",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -377,7 +382,7 @@ fun RecentActivityCard(recentSessions: Int) {
|
||||
} else {
|
||||
"No recent activity"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -389,13 +394,13 @@ data class GradeDistributionDataPoint(
|
||||
val gradeNumeric: Int,
|
||||
val count: Int,
|
||||
val climbType: ClimbType,
|
||||
val difficultySystem: DifficultySystem
|
||||
val difficultySystem: DifficultySystem,
|
||||
)
|
||||
|
||||
fun calculateGradeDistribution(
|
||||
sessions: List<com.atridad.ascently.data.model.ClimbSession>,
|
||||
problems: List<com.atridad.ascently.data.model.Problem>,
|
||||
attempts: List<com.atridad.ascently.data.model.Attempt>
|
||||
attempts: List<com.atridad.ascently.data.model.Attempt>,
|
||||
): List<GradeDistributionDataPoint> {
|
||||
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
|
||||
return emptyList()
|
||||
@@ -432,11 +437,11 @@ fun calculateGradeDistribution(
|
||||
gradeNumeric =
|
||||
gradeToNumeric(
|
||||
problem.difficulty.system,
|
||||
problem.difficulty.grade
|
||||
problem.difficulty.grade,
|
||||
),
|
||||
count = 1,
|
||||
climbType = problem.climbType,
|
||||
difficultySystem = problem.difficulty.system
|
||||
difficultySystem = problem.difficulty.system,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,19 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "Ascently Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Climbing Gyms",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
@@ -48,11 +48,11 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
|
||||
title = "No Gyms Added",
|
||||
message = "Add your favorite climbing gyms to start tracking your progress!",
|
||||
onActionClick = {},
|
||||
actionText = ""
|
||||
actionText = "",
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(gyms) { gym ->
|
||||
items(gyms, key = { it.id }) { gym ->
|
||||
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
@@ -69,7 +69,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
Text(
|
||||
text = gym.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
gym.location?.let { location ->
|
||||
@@ -77,7 +77,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(climbType.displayName) },
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
text =
|
||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,35 +35,36 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
var selectedGym by remember { mutableStateOf<Gym?>(null) }
|
||||
|
||||
// Apply filters
|
||||
val filteredProblems =
|
||||
val filteredProblems = remember(problems, selectedClimbType, selectedGym) {
|
||||
problems.filter { problem ->
|
||||
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
|
||||
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
|
||||
climbTypeMatch && gymMatch
|
||||
}
|
||||
}
|
||||
|
||||
// Separate active and inactive problems
|
||||
val activeProblems = filteredProblems.filter { it.isActive }
|
||||
val inactiveProblems = filteredProblems.filter { !it.isActive }
|
||||
val sortedProblems = activeProblems + inactiveProblems
|
||||
val activeProblems = remember(filteredProblems) { filteredProblems.filter { it.isActive } }
|
||||
val inactiveProblems = remember(filteredProblems) { filteredProblems.filter { !it.isActive } }
|
||||
val sortedProblems = remember(activeProblems, inactiveProblems) { activeProblems + inactiveProblems }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "Ascently Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Climbing Problems",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
@@ -77,7 +78,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
Text(
|
||||
text = "Filters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -86,7 +87,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
Text(
|
||||
text = "Climb Type",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -96,14 +97,14 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
FilterChip(
|
||||
onClick = { selectedClimbType = null },
|
||||
label = { Text("All Types") },
|
||||
selected = selectedClimbType == null
|
||||
selected = selectedClimbType == null,
|
||||
)
|
||||
}
|
||||
items(ClimbType.entries) { climbType ->
|
||||
FilterChip(
|
||||
onClick = { selectedClimbType = climbType },
|
||||
label = { Text(climbType.displayName) },
|
||||
selected = selectedClimbType == climbType
|
||||
selected = selectedClimbType == climbType,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +115,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
Text(
|
||||
text = "Gym",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -124,14 +125,14 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
FilterChip(
|
||||
onClick = { selectedGym = null },
|
||||
label = { Text("All Gyms") },
|
||||
selected = selectedGym == null
|
||||
selected = selectedGym == null,
|
||||
)
|
||||
}
|
||||
items(gyms) { gym ->
|
||||
FilterChip(
|
||||
onClick = { selectedGym = gym },
|
||||
label = { Text(gym.name) },
|
||||
selected = selectedGym?.id == gym.id
|
||||
selected = selectedGym?.id == gym.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +144,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
text =
|
||||
"Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -162,18 +163,20 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
},
|
||||
message =
|
||||
if (problems.isEmpty()) {
|
||||
if (gyms.isEmpty())
|
||||
if (gyms.isEmpty()) {
|
||||
"Add a gym first to start tracking problems and routes!"
|
||||
else "Start tracking your favorite problems and routes!"
|
||||
} else {
|
||||
"Start tracking your favorite problems and routes!"
|
||||
}
|
||||
} else {
|
||||
"Try adjusting your filters to see more problems."
|
||||
},
|
||||
onActionClick = {},
|
||||
actionText = ""
|
||||
actionText = "",
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(sortedProblems) { problem ->
|
||||
items(sortedProblems, key = { it.id }) { problem ->
|
||||
ProblemCard(
|
||||
problem = problem,
|
||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||
@@ -182,7 +185,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
onToggleActive = {
|
||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||
viewModel.updateProblem(updatedProblem)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
@@ -198,13 +201,15 @@ fun ProblemCard(
|
||||
gymName: String,
|
||||
attempts: List<Attempt>,
|
||||
onClick: () -> Unit,
|
||||
onToggleActive: (() -> Unit)? = null
|
||||
onToggleActive: (() -> Unit)? = null,
|
||||
) {
|
||||
val isCompleted =
|
||||
attempts.any { attempt ->
|
||||
attempt.problemId == problem.id &&
|
||||
(attempt.result == AttemptResult.SUCCESS ||
|
||||
attempt.result == AttemptResult.FLASH)
|
||||
(
|
||||
attempt.result == AttemptResult.SUCCESS ||
|
||||
attempt.result == AttemptResult.FLASH
|
||||
)
|
||||
}
|
||||
|
||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -212,7 +217,7 @@ fun ProblemCard(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
@@ -220,8 +225,11 @@ fun ProblemCard(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color =
|
||||
if (problem.isActive) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
if (problem.isActive) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -229,22 +237,22 @@ fun ProblemCard(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = if (problem.isActive) 1f else 0.6f
|
||||
)
|
||||
alpha = if (problem.isActive) 1f else 0.6f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (problem.imagePaths.isNotEmpty()) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Image,
|
||||
contentDescription = "Has images",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,7 +261,7 @@ fun ProblemCard(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Completed",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -261,14 +269,14 @@ fun ProblemCard(
|
||||
text = problem.difficulty.grade,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = problem.climbType.displayName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +286,7 @@ fun ProblemCard(
|
||||
Text(
|
||||
text = "Location: $location",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,7 +297,7 @@ fun ProblemCard(
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(tag) },
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,7 +309,7 @@ fun ProblemCard(
|
||||
text = "Reset / No Longer Set",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,15 +321,17 @@ fun ProblemCard(
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor =
|
||||
if (problem.isActive)
|
||||
if (problem.isActive) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = if (problem.isActive) "Mark as Reset" else "Mark as Active",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.ascently.R
|
||||
import com.atridad.ascently.data.model.ClimbSession
|
||||
import com.atridad.ascently.data.model.SessionStatus
|
||||
@@ -36,11 +37,10 @@ import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import androidx.core.content.edit
|
||||
|
||||
enum class ViewMode {
|
||||
LIST,
|
||||
CALENDAR
|
||||
CALENDAR,
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -61,26 +61,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
|
||||
|
||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||
val completedSessions = remember(sessions) {
|
||||
sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
}
|
||||
val activeSessionGym = remember(activeSession, gyms) {
|
||||
activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "Ascently Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Climbing Sessions",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
@@ -89,15 +93,18 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
||||
selectedDate = null
|
||||
sharedPreferences.edit { putString("view_mode", viewMode.name) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
|
||||
else Icons.AutoMirrored.Filled.List,
|
||||
if (viewMode == ViewMode.LIST) {
|
||||
Icons.Default.CalendarMonth
|
||||
} else {
|
||||
Icons.AutoMirrored.Filled.List
|
||||
},
|
||||
contentDescription =
|
||||
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,7 +117,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
|
||||
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
|
||||
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } },
|
||||
)
|
||||
|
||||
if (activeSession != null) {
|
||||
@@ -121,22 +128,24 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
EmptyStateMessage(
|
||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||
message =
|
||||
if (gyms.isEmpty())
|
||||
if (gyms.isEmpty()) {
|
||||
"Add a gym first to start tracking your climbing sessions!"
|
||||
else "Start your first climbing session!",
|
||||
} else {
|
||||
"Start your first climbing session!"
|
||||
},
|
||||
onActionClick = {},
|
||||
actionText = ""
|
||||
actionText = "",
|
||||
)
|
||||
} else {
|
||||
when (viewMode) {
|
||||
ViewMode.LIST -> {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
items(completedSessions, key = { it.id }) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name
|
||||
?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
onClick = { onNavigateToSessionDetail(session.id) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
@@ -150,7 +159,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
onMonthChange = { selectedMonth = it },
|
||||
selectedDate = selectedDate,
|
||||
onDateSelected = { selectedDate = it },
|
||||
onNavigateToSessionDetail = onNavigateToSessionDetail
|
||||
onNavigateToSessionDetail = onNavigateToSessionDetail,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,24 +176,24 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -200,24 +209,24 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,21 +236,25 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||
val formattedDate = remember(session.date) {
|
||||
DateFormatUtils.formatDateForDisplay(session.date)
|
||||
}
|
||||
|
||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatDate(session.date),
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,7 +263,7 @@ fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||
session.duration?.let { duration ->
|
||||
Text(
|
||||
text = "Duration: $duration minutes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -261,7 +274,7 @@ fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -274,18 +287,18 @@ fun EmptyStateMessage(
|
||||
title: String,
|
||||
message: String,
|
||||
onActionClick: () -> Unit,
|
||||
actionText: String
|
||||
actionText: String,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -294,7 +307,7 @@ fun EmptyStateMessage(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (actionText.isNotEmpty()) {
|
||||
@@ -313,7 +326,7 @@ fun CalendarView(
|
||||
onMonthChange: (YearMonth) -> Unit,
|
||||
selectedDate: LocalDate?,
|
||||
onDateSelected: (LocalDate?) -> Unit,
|
||||
onNavigateToSessionDetail: (String) -> Unit
|
||||
onNavigateToSessionDetail: (String) -> Unit,
|
||||
) {
|
||||
val sessionsByDate =
|
||||
remember(sessions) {
|
||||
@@ -343,18 +356,18 @@ fun CalendarView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
||||
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
||||
@@ -364,7 +377,7 @@ fun CalendarView(
|
||||
text =
|
||||
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
||||
@@ -383,14 +396,14 @@ fun CalendarView(
|
||||
shape = RoundedCornerShape(50),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Today",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -406,7 +419,7 @@ fun CalendarView(
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -436,7 +449,7 @@ fun CalendarView(
|
||||
if (sessionsOnDate.isNotEmpty()) {
|
||||
onDateSelected(if (isSelected) null else date)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.aspectRatio(1f))
|
||||
@@ -457,7 +470,7 @@ fun CalendarView(
|
||||
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -465,7 +478,7 @@ fun CalendarView(
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
onClick = { onNavigateToSessionDetail(session.id) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
@@ -481,7 +494,7 @@ fun CalendarDay(
|
||||
hasSession: Boolean,
|
||||
isSelected: Boolean,
|
||||
isToday: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -493,14 +506,14 @@ fun CalendarDay(
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> Color.Transparent
|
||||
}
|
||||
},
|
||||
)
|
||||
.clickable(enabled = hasSession, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
@@ -512,7 +525,7 @@ fun CalendarDay(
|
||||
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
|
||||
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
|
||||
if (hasSession) {
|
||||
@@ -521,18 +534,16 @@ fun CalendarDay(
|
||||
Modifier.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.7f
|
||||
)
|
||||
alpha = 0.7f,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(dateString: String): String {
|
||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ import com.atridad.ascently.R
|
||||
import com.atridad.ascently.ui.components.SyncIndicator
|
||||
import com.atridad.ascently.ui.health.HealthConnectCard
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -64,7 +64,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
|
||||
// File picker launcher for import - only accepts ZIP files
|
||||
val importLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri,
|
||||
->
|
||||
uri?.let {
|
||||
try {
|
||||
@@ -75,18 +75,20 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
cursor ->
|
||||
val nameIndex =
|
||||
cursor.getColumnIndex(
|
||||
android.provider.OpenableColumns.DISPLAY_NAME
|
||||
android.provider.OpenableColumns.DISPLAY_NAME,
|
||||
)
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||
cursor.getString(nameIndex)
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
?: "import_file"
|
||||
|
||||
// Only allow ZIP files
|
||||
if (!fileName.lowercase().endsWith(".zip")) {
|
||||
viewModel.setError(
|
||||
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
|
||||
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently.",
|
||||
)
|
||||
return@let
|
||||
}
|
||||
@@ -106,7 +108,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
// File picker launcher for export - ZIP format with images
|
||||
val exportZipLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/zip")
|
||||
contract = ActivityResultContracts.CreateDocument("application/zip"),
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
try {
|
||||
@@ -119,25 +121,25 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "Ascently Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
@@ -150,7 +152,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Text(
|
||||
text = "Sync",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -162,23 +164,27 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isConnected)
|
||||
if (isConnected) {
|
||||
MaterialTheme.colorScheme
|
||||
.primaryContainer.copy(
|
||||
alpha = 0.3f
|
||||
alpha = 0.3f,
|
||||
)
|
||||
else
|
||||
} else {
|
||||
MaterialTheme.colorScheme
|
||||
.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
alpha = 0.3f,
|
||||
)
|
||||
},
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
if (isConnected) "Connected to Server"
|
||||
else "Server Configured"
|
||||
if (isConnected) {
|
||||
"Connected to Server"
|
||||
} else {
|
||||
"Server Configured"
|
||||
},
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -193,22 +199,26 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
time
|
||||
}
|
||||
}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
if (isConnected) Icons.Default.CloudDone
|
||||
else Icons.Default.Cloud,
|
||||
if (isConnected) {
|
||||
Icons.Default.CloudDone
|
||||
} else {
|
||||
Icons.Default.Cloud
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (isConnected)
|
||||
if (isConnected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
} else {
|
||||
MaterialTheme.colorScheme
|
||||
.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
@@ -218,12 +228,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
onClick = {
|
||||
viewModel.performManualSync()
|
||||
},
|
||||
enabled = isConnected && !isSyncing
|
||||
enabled = isConnected && !isSyncing,
|
||||
) {
|
||||
if (isSyncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Sync")
|
||||
@@ -235,7 +245,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Text("Configure")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,21 +258,21 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
.copy(alpha = 0.3f)
|
||||
)
|
||||
.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Sync Mode",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Auto-sync")
|
||||
@@ -272,9 +282,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.7f
|
||||
alpha = 0.7f,
|
||||
),
|
||||
maxLines = 2
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
@@ -282,7 +292,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
checked = isAutoSyncEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
syncService.setAutoSyncEnabled(enabled)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -297,8 +307,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
.copy(alpha = 0.3f)
|
||||
)
|
||||
.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Disconnect") },
|
||||
@@ -307,17 +317,17 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Icon(
|
||||
Icons.Default.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(onClick = { showDisconnectDialog = true }) {
|
||||
Text(
|
||||
"Disconnect",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -328,8 +338,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
.copy(alpha = 0.3f)
|
||||
)
|
||||
.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Setup Sync") },
|
||||
@@ -343,7 +353,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
TextButton(onClick = { showSyncConfigDialog = true }) {
|
||||
Text("Setup")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -356,23 +366,23 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -389,7 +399,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Text(
|
||||
text = "Data Management",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -401,15 +411,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Export Data with Images") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Export all your climbing data and images to ZIP file (recommended)"
|
||||
"Export all your climbing data and images to ZIP file (recommended)",
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
@@ -427,18 +437,18 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
}.zip"
|
||||
exportZipLauncher.launch(defaultFileName)
|
||||
},
|
||||
enabled = !uiState.isLoading
|
||||
enabled = !uiState.isLoading,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Export ZIP")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -450,9 +460,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Import Data") },
|
||||
@@ -465,18 +475,18 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { importLauncher.launch("application/zip") },
|
||||
enabled = !uiState.isLoading
|
||||
enabled = !uiState.isLoading,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,9 +498,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Delete All Images") },
|
||||
@@ -501,24 +511,24 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showDeleteImagesDialog = true },
|
||||
enabled = !isDeletingImages && !uiState.isLoading
|
||||
enabled = !isDeletingImages && !uiState.isLoading,
|
||||
) {
|
||||
if (isDeletingImages) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -530,39 +540,39 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Reset All Data") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
||||
"Permanently delete all gyms, problems, sessions, attempts, and images",
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showResetDialog = true },
|
||||
enabled = !uiState.isLoading
|
||||
enabled = !uiState.isLoading,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -576,7 +586,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Text(
|
||||
text = "App Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -587,16 +597,16 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
alpha = 0.3f,
|
||||
),
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Version") },
|
||||
supportingContent = { Text(appVersion ?: "Unknown") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -621,24 +631,24 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -654,24 +664,24 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -689,14 +699,14 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Text(
|
||||
text = "This will permanently delete:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text =
|
||||
"• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -704,7 +714,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
"This action cannot be undone. Consider exporting your data first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -713,12 +723,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
onClick = {
|
||||
viewModel.resetAllData()
|
||||
showResetDialog = false
|
||||
}
|
||||
},
|
||||
) { Text("Reset All Data", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -735,7 +745,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://your-server.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -746,7 +756,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
label = { Text("Auth Token") },
|
||||
placeholder = { Text("your-secret-token") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -755,66 +765,66 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
if (isTesting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Testing connection...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else if (isConnected && isConfigured) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Connection successful",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else if (syncError != null && isConfigured) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Connection failed: $syncError",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else if (isConfigured) {
|
||||
Text(
|
||||
text = "Test connection before enabling sync features",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Enter your server URL and auth token to enable sync",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -835,7 +845,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
showSyncConfigDialog = false
|
||||
} catch (e: Exception) {
|
||||
viewModel.setError(
|
||||
"Failed to save and test: ${e.message}"
|
||||
"Failed to save and test: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -843,14 +853,14 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
enabled =
|
||||
!isTesting &&
|
||||
serverUrl.isNotBlank() &&
|
||||
authToken.isNotBlank()
|
||||
authToken.isNotBlank(),
|
||||
) {
|
||||
if (isTesting) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save & Test")
|
||||
@@ -879,7 +889,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
viewModel.setError(
|
||||
"Connection test failed: ${e.message}"
|
||||
"Connection test failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -887,7 +897,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
enabled =
|
||||
!isTesting &&
|
||||
serverUrl.isNotBlank() &&
|
||||
authToken.isNotBlank()
|
||||
authToken.isNotBlank(),
|
||||
) { Text("Test Only") }
|
||||
}
|
||||
}
|
||||
@@ -898,9 +908,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
serverUrl = syncService.serverUrl
|
||||
authToken = syncService.authToken
|
||||
showSyncConfigDialog = false
|
||||
}
|
||||
},
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -911,7 +921,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
title = { Text("Disconnect from Sync") },
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to disconnect from the sync server? This will clear your server configuration and disable auto-sync."
|
||||
"Are you sure you want to disconnect from the sync server? This will clear your server configuration and disable auto-sync.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -919,12 +929,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
onClick = {
|
||||
viewModel.syncService.clearConfiguration()
|
||||
showDisconnectDialog = false
|
||||
}
|
||||
},
|
||||
) { Text("Disconnect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -935,7 +945,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
title = { Text("Delete All Images") },
|
||||
text = {
|
||||
Text(
|
||||
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
|
||||
"This will permanently delete ALL image files from your device." +
|
||||
"\n\nProblems will keep their references but the actual image files " +
|
||||
"will be removed. This cannot be undone." +
|
||||
"\n\nConsider exporting your data first if you want to keep your images.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -948,12 +961,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
isDeletingImages = false
|
||||
viewModel.setMessage("All images deleted successfully!")
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ object CustomIcons {
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
viewportHeight = 24f,
|
||||
).path(
|
||||
fill = SolidColor(color)
|
||||
fill = SolidColor(color),
|
||||
) {
|
||||
moveTo(6f, 6f)
|
||||
horizontalLineTo(18f)
|
||||
|
||||
@@ -47,7 +47,7 @@ private val DarkColorScheme = darkColorScheme(
|
||||
surfaceContainerLow = ClimbNeutral10,
|
||||
surfaceContainer = ClimbNeutral12,
|
||||
surfaceContainerHigh = ClimbNeutral17,
|
||||
surfaceContainerHighest = ClimbNeutral22
|
||||
surfaceContainerHighest = ClimbNeutral22,
|
||||
)
|
||||
|
||||
// Climbing-themed light color scheme with full Material You compatibility
|
||||
@@ -84,7 +84,7 @@ private val LightColorScheme = lightColorScheme(
|
||||
surfaceContainerLow = ClimbNeutral96,
|
||||
surfaceContainer = ClimbNeutral94,
|
||||
surfaceContainerHigh = ClimbNeutral92,
|
||||
surfaceContainerHighest = ClimbNeutral90
|
||||
surfaceContainerHighest = ClimbNeutral90,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -93,7 +93,7 @@ fun AscentlyTheme(
|
||||
// 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
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && true -> {
|
||||
@@ -116,6 +116,6 @@ fun AscentlyTheme(
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,6 @@ val Typography = Typography(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
@@ -11,14 +11,14 @@ import com.atridad.ascently.service.SessionTrackingService
|
||||
import com.atridad.ascently.utils.AppLogger
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class ClimbViewModel(
|
||||
private val repository: ClimbRepository,
|
||||
val syncService: SyncService,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
// Health Connect manager
|
||||
@@ -35,7 +35,7 @@ class ClimbViewModel(
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val problems =
|
||||
@@ -44,7 +44,7 @@ class ClimbViewModel(
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val sessions =
|
||||
@@ -53,7 +53,7 @@ class ClimbViewModel(
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val activeSession =
|
||||
@@ -62,7 +62,7 @@ class ClimbViewModel(
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
val attempts =
|
||||
@@ -71,7 +71,7 @@ class ClimbViewModel(
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
// Gym operations
|
||||
@@ -243,7 +243,7 @@ class ClimbViewModel(
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error =
|
||||
"Notification permission is required to track your climbing session. Please enable notifications in settings."
|
||||
"Notification permission is required to track your climbing session. Please enable notifications in settings.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
@@ -253,7 +253,7 @@ class ClimbViewModel(
|
||||
AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error = "There's already an active session. Please end it first."
|
||||
error = "There's already an active session. Please end it first.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
@@ -281,7 +281,7 @@ class ClimbViewModel(
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error =
|
||||
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
|
||||
"Notification permission is required to manage your climbing session. Please enable notifications in settings.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
@@ -354,20 +354,20 @@ class ClimbViewModel(
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = true,
|
||||
message = "Creating ZIP file with images..."
|
||||
message = "Creating ZIP file with images...",
|
||||
)
|
||||
repository.exportAllDataToZipUri(context, uri)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message =
|
||||
"Export complete! Your climbing data and images have been saved."
|
||||
"Export complete! Your climbing data and images have been saved.",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
error = "Export failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -380,7 +380,7 @@ class ClimbViewModel(
|
||||
|
||||
if (!file.name.lowercase().endsWith(".zip")) {
|
||||
throw Exception(
|
||||
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
|
||||
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -389,13 +389,13 @@ class ClimbViewModel(
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data imported successfully from ${file.name}"
|
||||
message = "Data imported successfully from ${file.name}",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Import failed: ${e.message}"
|
||||
error = "Import failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -447,7 +447,7 @@ class ClimbViewModel(
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "All data has been reset successfully"
|
||||
message = "All data has been reset successfully",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
@@ -469,7 +469,7 @@ class ClimbViewModel(
|
||||
healthConnectManager.autoSyncCompletedSession(
|
||||
session,
|
||||
gymName,
|
||||
attemptCount
|
||||
attemptCount,
|
||||
)
|
||||
|
||||
result.onFailure { error ->
|
||||
@@ -489,5 +489,5 @@ class ClimbViewModel(
|
||||
data class ClimbUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import com.atridad.ascently.data.sync.SyncService
|
||||
class ClimbViewModelFactory(
|
||||
private val repository: ClimbRepository,
|
||||
private val syncService: SyncService,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
||||
@@ -11,7 +11,7 @@ object AppLogger {
|
||||
DEBUG(Log.DEBUG),
|
||||
INFO(Log.INFO),
|
||||
WARN(Log.WARN),
|
||||
ERROR(Log.ERROR)
|
||||
ERROR(Log.ERROR),
|
||||
}
|
||||
|
||||
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
|
||||
@@ -34,7 +34,7 @@ object AppLogger {
|
||||
level: Level,
|
||||
tag: String,
|
||||
messageProvider: () -> String,
|
||||
throwable: Throwable? = null
|
||||
throwable: Throwable? = null,
|
||||
) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ object DateFormatUtils {
|
||||
return try {
|
||||
Instant.from(ISO_FORMATTER.parse(dateString))
|
||||
} catch (_: Exception) {
|
||||
|
||||
try {
|
||||
Instant.parse(dateString)
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -13,7 +13,7 @@ object ImageNamingUtils {
|
||||
|
||||
/** Generates a deterministic filename for a problem image */
|
||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||
val input = "${problemId}_${imageIndex}"
|
||||
val input = "${problemId}_$imageIndex"
|
||||
val hash = createHash(input)
|
||||
|
||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||
|
||||
@@ -32,7 +32,7 @@ object ImageUtils {
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
originalBitmap: Bitmap,
|
||||
outputFile: File
|
||||
outputFile: File,
|
||||
): Boolean {
|
||||
return try {
|
||||
// Get EXIF data from original image
|
||||
@@ -147,12 +147,16 @@ object ImageUtils {
|
||||
val imagesDir = getImagesDirectory(context)
|
||||
imagesDir.listFiles()?.mapNotNull { file ->
|
||||
if (file.isFile &&
|
||||
(file.extension == "jpg" ||
|
||||
(
|
||||
file.extension == "jpg" ||
|
||||
file.extension == "jpeg" ||
|
||||
file.extension == "png")
|
||||
file.extension == "png"
|
||||
)
|
||||
) {
|
||||
"$IMAGES_DIR/${file.name}"
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
@@ -187,7 +191,7 @@ object ImageUtils {
|
||||
context: Context,
|
||||
tempFilename: String,
|
||||
problemId: String,
|
||||
imageIndex: Int
|
||||
imageIndex: Int,
|
||||
): String? {
|
||||
return try {
|
||||
val tempFile = File(getImagesDirectory(context), tempFilename)
|
||||
@@ -217,7 +221,7 @@ object ImageUtils {
|
||||
fun saveImageFromBytesWithFilename(
|
||||
context: Context,
|
||||
imageData: ByteArray,
|
||||
filename: String
|
||||
filename: String,
|
||||
): String? {
|
||||
return try {
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
@@ -51,7 +51,7 @@ class MigrationManager(private val context: Context) {
|
||||
"openclimb_data_state" to "ascently_data_state",
|
||||
"health_connect_prefs" to "health_connect_prefs", // Keep same name
|
||||
"deleted_items" to "deleted_items", // Keep same name
|
||||
"sync_preferences" to "sync_preferences" // Keep same name
|
||||
"sync_preferences" to "sync_preferences", // Keep same name
|
||||
)
|
||||
|
||||
for ((oldFileName, newFileName) in preferenceFileMigrations) {
|
||||
@@ -82,7 +82,8 @@ class MigrationManager(private val context: Context) {
|
||||
is Float -> putFloat(key, value)
|
||||
is Boolean -> putBoolean(key, value)
|
||||
is Set<*> -> {
|
||||
@Suppress("UNCHECKED_CAST") putStringSet(key, value as Set<String>)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
putStringSet(key, value as Set<String>)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ class MigrationManager(private val context: Context) {
|
||||
"ascently_data_state",
|
||||
"health_connect_prefs",
|
||||
"deleted_items",
|
||||
"sync_preferences"
|
||||
"sync_preferences",
|
||||
)
|
||||
|
||||
for (prefFileName in preferencesToCheck) {
|
||||
|
||||
@@ -13,7 +13,7 @@ object NotificationPermissionUtils {
|
||||
fun isNotificationPermissionGranted(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ object AppShortcutManager {
|
||||
context: Context,
|
||||
hasActiveSession: Boolean,
|
||||
hasGyms: Boolean,
|
||||
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
|
||||
lastUsedGym: com.atridad.ascently.data.model.Gym? = null,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
||||
@@ -45,7 +45,7 @@ object AppShortcutManager {
|
||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||
private fun createStartSessionShortcut(
|
||||
context: Context,
|
||||
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
|
||||
lastUsedGym: com.atridad.ascently.data.model.Gym? = null,
|
||||
): ShortcutInfo {
|
||||
val startIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.atridad.ascently.utils
|
||||
import android.content.Context
|
||||
import com.atridad.ascently.data.format.BackupProblem
|
||||
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
@@ -11,8 +13,6 @@ import java.time.LocalDateTime
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object ZipExportImportUtils {
|
||||
|
||||
@@ -25,15 +25,15 @@ object ZipExportImportUtils {
|
||||
context: Context,
|
||||
exportData: ClimbDataBackup,
|
||||
referencedImagePaths: Set<String>,
|
||||
directory: File? = null
|
||||
directory: File? = null,
|
||||
): File {
|
||||
val exportDir =
|
||||
directory
|
||||
?: File(
|
||||
context.getExternalFilesDir(
|
||||
android.os.Environment.DIRECTORY_DOCUMENTS
|
||||
android.os.Environment.DIRECTORY_DOCUMENTS,
|
||||
),
|
||||
"Ascently"
|
||||
"Ascently",
|
||||
)
|
||||
if (!exportDir.exists()) {
|
||||
exportDir.mkdirs()
|
||||
@@ -92,7 +92,7 @@ object ZipExportImportUtils {
|
||||
|
||||
// Log export summary
|
||||
AppLogger.i("ZipExportImportUtils") {
|
||||
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
|
||||
"Export completed: $successfulImages/${referencedImagePaths.size} images included"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ object ZipExportImportUtils {
|
||||
context: Context,
|
||||
uri: android.net.Uri,
|
||||
exportData: ClimbDataBackup,
|
||||
referencedImagePaths: Set<String>
|
||||
referencedImagePaths: Set<String>,
|
||||
) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
@@ -164,7 +164,7 @@ object ZipExportImportUtils {
|
||||
}
|
||||
|
||||
AppLogger.i("ZipExportImportUtils") {
|
||||
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
|
||||
"Export to URI completed: $successfulImages/${referencedImagePaths.size} images included"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ object ZipExportImportUtils {
|
||||
|
||||
private fun createMetadata(
|
||||
exportData: ClimbDataBackup,
|
||||
referencedImagePaths: Set<String>
|
||||
referencedImagePaths: Set<String>,
|
||||
): String {
|
||||
return buildString {
|
||||
appendLine("Ascently Export Metadata")
|
||||
@@ -195,7 +195,7 @@ object ZipExportImportUtils {
|
||||
/** Data class to hold extraction results */
|
||||
data class ImportResult(
|
||||
val jsonContent: String,
|
||||
val importedImagePaths: Map<String, String> // original filename -> new relative path
|
||||
val importedImagePaths: Map<String, String>, // original filename -> new relative path
|
||||
)
|
||||
|
||||
/** Extracts a ZIP file and returns the JSON content and imported image paths */
|
||||
@@ -235,7 +235,7 @@ object ZipExportImportUtils {
|
||||
File.createTempFile(
|
||||
"import_image_",
|
||||
"_$originalFilename",
|
||||
context.cacheDir
|
||||
context.cacheDir,
|
||||
)
|
||||
|
||||
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
|
||||
@@ -306,7 +306,7 @@ object ZipExportImportUtils {
|
||||
*/
|
||||
fun updateProblemImagePaths(
|
||||
problems: List<BackupProblem>,
|
||||
imagePathMapping: Map<String, String>
|
||||
imagePathMapping: Map<String, String>,
|
||||
): List<BackupProblem> {
|
||||
return problems.map { problem ->
|
||||
val updatedImagePaths =
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.atridad.ascently.MainActivity
|
||||
import com.atridad.ascently.R
|
||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
|
||||
class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
@@ -26,7 +26,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId)
|
||||
@@ -42,7 +42,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
private fun updateAppWidget(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetId: Int
|
||||
appWidgetId: Int,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
@@ -96,7 +96,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
||||
|
||||
@@ -115,7 +115,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
||||
|
||||
|
||||
@@ -1,11 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Clean white background -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 730 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 868 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 514 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 628 B After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 854 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
@@ -2,10 +2,10 @@ package com.atridad.ascently
|
||||
|
||||
import com.atridad.ascently.data.format.*
|
||||
import com.atridad.ascently.data.model.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class BusinessLogicTests {
|
||||
|
||||
@@ -24,7 +24,7 @@ class BusinessLogicTests {
|
||||
session.copy(
|
||||
status = SessionStatus.COMPLETED,
|
||||
endTime = getCurrentTimestamp(),
|
||||
duration = 7200L
|
||||
duration = 7200L,
|
||||
)
|
||||
assertEquals(SessionStatus.COMPLETED, completedSession.status)
|
||||
assertNotNull(completedSession.endTime)
|
||||
@@ -42,7 +42,7 @@ class BusinessLogicTests {
|
||||
sessionId = session.id,
|
||||
problemId = problem.id,
|
||||
result = AttemptResult.SUCCESS,
|
||||
notes = "Clean send!"
|
||||
notes = "Clean send!",
|
||||
)
|
||||
|
||||
assertEquals(session.id, attempt.sessionId)
|
||||
@@ -80,7 +80,7 @@ class BusinessLogicTests {
|
||||
Attempt.create(session.id, problem1.id, AttemptResult.SUCCESS),
|
||||
Attempt.create(session.id, problem1.id, AttemptResult.FALL),
|
||||
Attempt.create(session.id, problem2.id, AttemptResult.FLASH),
|
||||
Attempt.create(session.id, problem2.id, AttemptResult.SUCCESS)
|
||||
Attempt.create(session.id, problem2.id, AttemptResult.SUCCESS),
|
||||
)
|
||||
|
||||
val sessionStats = calculateSessionStatistics(session, attempts)
|
||||
@@ -101,7 +101,7 @@ class BusinessLogicTests {
|
||||
createTestProblemWithGrade(gym.id, "V3"),
|
||||
createTestProblemWithGrade(gym.id, "V4"),
|
||||
createTestProblemWithGrade(gym.id, "V5"),
|
||||
createTestProblemWithGrade(gym.id, "V6")
|
||||
createTestProblemWithGrade(gym.id, "V6"),
|
||||
)
|
||||
|
||||
val attempts =
|
||||
@@ -132,7 +132,7 @@ class BusinessLogicTests {
|
||||
gyms = listOf(gym),
|
||||
problems = problems,
|
||||
sessions = listOf(session),
|
||||
attempts = attempts
|
||||
attempts = attempts,
|
||||
)
|
||||
|
||||
validateBackupIntegrity(backup)
|
||||
@@ -155,7 +155,7 @@ class BusinessLogicTests {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = getCurrentTimestamp(),
|
||||
updatedAt = getCurrentTimestamp()
|
||||
updatedAt = getCurrentTimestamp(),
|
||||
)
|
||||
|
||||
val ropeGym =
|
||||
@@ -168,7 +168,7 @@ class BusinessLogicTests {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = getCurrentTimestamp(),
|
||||
updatedAt = getCurrentTimestamp()
|
||||
updatedAt = getCurrentTimestamp(),
|
||||
)
|
||||
|
||||
// Boulder gym should support boulder problems with V-Scale
|
||||
@@ -202,20 +202,20 @@ class BusinessLogicTests {
|
||||
session.id,
|
||||
problem.id,
|
||||
"2024-01-01T10:00:00Z",
|
||||
AttemptResult.FALL
|
||||
AttemptResult.FALL,
|
||||
),
|
||||
createAttemptWithTimestamp(
|
||||
session.id,
|
||||
problem.id,
|
||||
"2024-01-01T10:05:00Z",
|
||||
AttemptResult.FALL
|
||||
AttemptResult.FALL,
|
||||
),
|
||||
createAttemptWithTimestamp(
|
||||
session.id,
|
||||
problem.id,
|
||||
"2024-01-01T10:10:00Z",
|
||||
AttemptResult.SUCCESS
|
||||
)
|
||||
AttemptResult.SUCCESS,
|
||||
),
|
||||
)
|
||||
|
||||
val sequence = AttemptSequence(attempts)
|
||||
@@ -235,27 +235,27 @@ class BusinessLogicTests {
|
||||
Pair(ClimbType.BOULDER, DifficultySystem.FONT),
|
||||
Pair(ClimbType.ROPE, DifficultySystem.YDS),
|
||||
Pair(ClimbType.BOULDER, DifficultySystem.CUSTOM),
|
||||
Pair(ClimbType.ROPE, DifficultySystem.CUSTOM)
|
||||
Pair(ClimbType.ROPE, DifficultySystem.CUSTOM),
|
||||
)
|
||||
|
||||
val invalidCombinations =
|
||||
listOf(
|
||||
Pair(ClimbType.BOULDER, DifficultySystem.YDS),
|
||||
Pair(ClimbType.ROPE, DifficultySystem.V_SCALE),
|
||||
Pair(ClimbType.ROPE, DifficultySystem.FONT)
|
||||
Pair(ClimbType.ROPE, DifficultySystem.FONT),
|
||||
)
|
||||
|
||||
validCombinations.forEach { (climbType, difficultySystem) ->
|
||||
assertTrue(
|
||||
"$climbType should be compatible with $difficultySystem",
|
||||
isValidGradeCombination(climbType, difficultySystem)
|
||||
isValidGradeCombination(climbType, difficultySystem),
|
||||
)
|
||||
}
|
||||
|
||||
invalidCombinations.forEach { (climbType, difficultySystem) ->
|
||||
assertFalse(
|
||||
"$climbType should not be compatible with $difficultySystem",
|
||||
isValidGradeCombination(climbType, difficultySystem)
|
||||
isValidGradeCombination(climbType, difficultySystem),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ class BusinessLogicTests {
|
||||
listOf(
|
||||
"/storage/images/problem1.jpg",
|
||||
"/data/cache/problem2.png",
|
||||
"relative/path/problem3.jpeg"
|
||||
"relative/path/problem3.jpeg",
|
||||
)
|
||||
|
||||
val relativePaths = convertToRelativePaths(originalPaths)
|
||||
@@ -303,13 +303,13 @@ class BusinessLogicTests {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = "Test gym for unit testing",
|
||||
createdAt = getCurrentTimestamp(),
|
||||
updatedAt = getCurrentTimestamp()
|
||||
updatedAt = getCurrentTimestamp(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestProblem(
|
||||
gymId: String,
|
||||
climbType: ClimbType = ClimbType.BOULDER
|
||||
climbType: ClimbType = ClimbType.BOULDER,
|
||||
): Problem {
|
||||
val difficulty =
|
||||
when (climbType) {
|
||||
@@ -331,7 +331,7 @@ class BusinessLogicTests {
|
||||
dateSet = "2024-01-01",
|
||||
notes = null,
|
||||
createdAt = getCurrentTimestamp(),
|
||||
updatedAt = getCurrentTimestamp()
|
||||
updatedAt = getCurrentTimestamp(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ class BusinessLogicTests {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = getCurrentTimestamp(),
|
||||
updatedAt = getCurrentTimestamp()
|
||||
updatedAt = getCurrentTimestamp(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,13 +358,13 @@ class BusinessLogicTests {
|
||||
sessionId: String,
|
||||
problemId: String,
|
||||
timestamp: String,
|
||||
result: AttemptResult
|
||||
result: AttemptResult,
|
||||
): Attempt {
|
||||
return Attempt.create(
|
||||
sessionId = sessionId,
|
||||
problemId = problemId,
|
||||
result = result,
|
||||
timestamp = timestamp
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ class BusinessLogicTests {
|
||||
|
||||
private fun calculateSessionStatistics(
|
||||
session: ClimbSession,
|
||||
attempts: List<Attempt>
|
||||
attempts: List<Attempt>,
|
||||
): SessionStatistics {
|
||||
val successful =
|
||||
attempts.count {
|
||||
@@ -387,13 +387,13 @@ class BusinessLogicTests {
|
||||
totalAttempts = attempts.size,
|
||||
successfulAttempts = successful,
|
||||
uniqueProblems = uniqueProblems,
|
||||
successRate = successRate
|
||||
successRate = successRate,
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDifficultyProgression(
|
||||
attempts: List<Attempt>,
|
||||
problems: List<Problem>
|
||||
problems: List<Problem>,
|
||||
): DifficultyProgression {
|
||||
val problemMap = problems.associateBy { it.id }
|
||||
val grades =
|
||||
@@ -423,7 +423,7 @@ class BusinessLogicTests {
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
sessions: List<ClimbSession>,
|
||||
attempts: List<Attempt>
|
||||
attempts: List<Attempt>,
|
||||
): ClimbDataBackup {
|
||||
return ClimbDataBackup(
|
||||
exportedAt = getCurrentTimestamp(),
|
||||
@@ -440,7 +440,7 @@ class BusinessLogicTests {
|
||||
customDifficultyGrades = gym.customDifficultyGrades,
|
||||
notes = gym.notes,
|
||||
createdAt = gym.createdAt,
|
||||
updatedAt = gym.updatedAt
|
||||
updatedAt = gym.updatedAt,
|
||||
)
|
||||
},
|
||||
problems =
|
||||
@@ -459,7 +459,7 @@ class BusinessLogicTests {
|
||||
dateSet = problem.dateSet,
|
||||
notes = problem.notes,
|
||||
createdAt = problem.createdAt,
|
||||
updatedAt = problem.updatedAt
|
||||
updatedAt = problem.updatedAt,
|
||||
)
|
||||
},
|
||||
sessions =
|
||||
@@ -474,7 +474,7 @@ class BusinessLogicTests {
|
||||
status = session.status,
|
||||
notes = session.notes,
|
||||
createdAt = session.createdAt,
|
||||
updatedAt = session.updatedAt
|
||||
updatedAt = session.updatedAt,
|
||||
)
|
||||
},
|
||||
attempts =
|
||||
@@ -492,7 +492,7 @@ class BusinessLogicTests {
|
||||
createdAt = attempt.createdAt,
|
||||
updatedAt = attempt.updatedAt,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ class BusinessLogicTests {
|
||||
backup.problems.forEach { problem ->
|
||||
assertTrue(
|
||||
"Problem ${problem.id} references non-existent gym ${problem.gymId}",
|
||||
gymIds.contains(problem.gymId)
|
||||
gymIds.contains(problem.gymId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ class BusinessLogicTests {
|
||||
backup.attempts.forEach { attempt ->
|
||||
assertTrue(
|
||||
"Attempt ${attempt.id} references non-existent session ${attempt.sessionId}",
|
||||
sessionIds.contains(attempt.sessionId)
|
||||
sessionIds.contains(attempt.sessionId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ class BusinessLogicTests {
|
||||
backup.attempts.forEach { attempt ->
|
||||
assertTrue(
|
||||
"Attempt ${attempt.id} references non-existent problem ${attempt.problemId}",
|
||||
problemIds.contains(attempt.problemId)
|
||||
problemIds.contains(attempt.problemId),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -528,7 +528,7 @@ class BusinessLogicTests {
|
||||
private fun isCompatibleClimbType(
|
||||
gym: Gym,
|
||||
climbType: ClimbType,
|
||||
difficultySystem: DifficultySystem
|
||||
difficultySystem: DifficultySystem,
|
||||
): Boolean {
|
||||
return gym.supportedClimbTypes.contains(climbType) &&
|
||||
gym.difficultySystems.contains(difficultySystem)
|
||||
@@ -542,7 +542,7 @@ class BusinessLogicTests {
|
||||
|
||||
private fun isValidGradeCombination(
|
||||
climbType: ClimbType,
|
||||
difficultySystem: DifficultySystem
|
||||
difficultySystem: DifficultySystem,
|
||||
): Boolean {
|
||||
return when (climbType) {
|
||||
ClimbType.BOULDER ->
|
||||
@@ -550,7 +550,7 @@ class BusinessLogicTests {
|
||||
listOf(
|
||||
DifficultySystem.V_SCALE,
|
||||
DifficultySystem.FONT,
|
||||
DifficultySystem.CUSTOM
|
||||
DifficultySystem.CUSTOM,
|
||||
)
|
||||
ClimbType.ROPE ->
|
||||
difficultySystem in listOf(DifficultySystem.YDS, DifficultySystem.CUSTOM)
|
||||
@@ -571,14 +571,14 @@ class BusinessLogicTests {
|
||||
val totalAttempts: Int,
|
||||
val successfulAttempts: Int,
|
||||
val uniqueProblems: Int,
|
||||
val successRate: Double
|
||||
val successRate: Double,
|
||||
)
|
||||
|
||||
data class DifficultyProgression(
|
||||
val minGrade: String,
|
||||
val maxGrade: String,
|
||||
val averageGrade: Double,
|
||||
val showsProgression: Boolean
|
||||
val showsProgression: Boolean,
|
||||
)
|
||||
|
||||
data class AttemptSequence(val attempts: List<Attempt>) {
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.atridad.ascently
|
||||
|
||||
import com.atridad.ascently.data.format.*
|
||||
import com.atridad.ascently.data.model.*
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class DataModelTests {
|
||||
|
||||
@@ -150,7 +150,7 @@ class DataModelTests {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = "Great gym for beginners",
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
assertEquals("gym123", gym.id)
|
||||
@@ -181,7 +181,7 @@ class DataModelTests {
|
||||
dateSet = "2024-01-01",
|
||||
notes = "Watch the start holds",
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
assertEquals("problem123", problem.id)
|
||||
@@ -190,7 +190,7 @@ class DataModelTests {
|
||||
assertEquals(ClimbType.BOULDER, problem.climbType)
|
||||
assertEquals("V5", problem.difficulty.grade)
|
||||
assertTrue(problem.isActive)
|
||||
assertEquals(2, problem.tags.size)
|
||||
assertEquals(2, problem.tags?.size ?: 0)
|
||||
assertEquals(2, problem.imagePaths?.size ?: 0)
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ class DataModelTests {
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = "Great session today",
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T12:00:00Z"
|
||||
updatedAt = "2024-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
assertEquals("session123", session.id)
|
||||
@@ -231,7 +231,7 @@ class DataModelTests {
|
||||
restTime = 120,
|
||||
timestamp = "2024-01-01T10:30:00Z",
|
||||
createdAt = "2024-01-01T10:30:00Z",
|
||||
updatedAt = "2024-01-01T10:30:00Z"
|
||||
updatedAt = "2024-01-01T10:30:00Z",
|
||||
)
|
||||
|
||||
assertEquals("attempt123", attempt.id)
|
||||
@@ -253,7 +253,7 @@ class DataModelTests {
|
||||
gyms = emptyList(),
|
||||
problems = emptyList(),
|
||||
sessions = emptyList(),
|
||||
attempts = emptyList()
|
||||
attempts = emptyList(),
|
||||
)
|
||||
|
||||
assertEquals("2.0", backup.version)
|
||||
@@ -290,7 +290,7 @@ class DataModelTests {
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T12:00:00Z"
|
||||
updatedAt = "2024-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
assertEquals(7200L, session.duration)
|
||||
@@ -310,12 +310,12 @@ class DataModelTests {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
assertTrue(gym.supportedClimbTypes.isEmpty())
|
||||
assertTrue(gym.difficultySystems.isEmpty())
|
||||
assertTrue(gym.customDifficultyGrades.isEmpty())
|
||||
assertTrue(gym.supportedClimbTypes?.isEmpty() ?: true)
|
||||
assertTrue(gym.difficultySystems?.isEmpty() ?: true)
|
||||
assertTrue(gym.customDifficultyGrades?.isEmpty() ?: true)
|
||||
assertNull(gym.location)
|
||||
assertNull(gym.notes)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ class DataModelTests {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
assertNull(problem.name)
|
||||
@@ -345,7 +345,7 @@ class DataModelTests {
|
||||
assertNull(problem.location)
|
||||
assertNull(problem.dateSet)
|
||||
assertNull(problem.notes)
|
||||
assertTrue(problem.tags.isEmpty())
|
||||
assertTrue(problem.tags?.isEmpty() ?: true)
|
||||
assertNull(problem.imagePaths)
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ class DataModelTests {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
assertEquals(ClimbType.BOULDER, boulderProblem.climbType)
|
||||
@@ -433,7 +433,7 @@ class DataModelTests {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
assertEquals(ClimbType.ROPE, ropeProblem.climbType)
|
||||
@@ -477,7 +477,7 @@ class DataModelTests {
|
||||
AttemptResult.SUCCESS,
|
||||
AttemptResult.FALL,
|
||||
AttemptResult.NO_PROGRESS,
|
||||
AttemptResult.FLASH
|
||||
AttemptResult.FLASH,
|
||||
)
|
||||
|
||||
assertEquals(4, validResults.size)
|
||||
@@ -510,7 +510,7 @@ class DataModelTests {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
val problem =
|
||||
@@ -528,7 +528,7 @@ class DataModelTests {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T10:00:00Z"
|
||||
updatedAt = "2024-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
val session =
|
||||
@@ -542,7 +542,7 @@ class DataModelTests {
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00Z",
|
||||
updatedAt = "2024-01-01T11:00:00Z"
|
||||
updatedAt = "2024-01-01T11:00:00Z",
|
||||
)
|
||||
|
||||
val attempt =
|
||||
@@ -557,7 +557,7 @@ class DataModelTests {
|
||||
restTime = null,
|
||||
timestamp = "2024-01-01T10:30:00Z",
|
||||
createdAt = "2024-01-01T10:30:00Z",
|
||||
updatedAt = "2024-01-01T10:30:00Z"
|
||||
updatedAt = "2024-01-01T10:30:00Z",
|
||||
)
|
||||
|
||||
// Verify referential integrity
|
||||
|
||||
@@ -21,8 +21,8 @@ class SyncMergeLogicTest {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T10:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
val localProblems =
|
||||
@@ -41,8 +41,8 @@ class SyncMergeLogicTest {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T10:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
val localSessions =
|
||||
@@ -57,8 +57,8 @@ class SyncMergeLogicTest {
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T10:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
val localAttempts =
|
||||
@@ -74,8 +74,8 @@ class SyncMergeLogicTest {
|
||||
restTime = null,
|
||||
timestamp = "2024-01-01T10:30:00",
|
||||
createdAt = "2024-01-01T10:30:00",
|
||||
updatedAt = "2024-01-01T10:30:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T10:30:00",
|
||||
),
|
||||
)
|
||||
|
||||
val localBackup =
|
||||
@@ -86,7 +86,7 @@ class SyncMergeLogicTest {
|
||||
gyms = localGyms,
|
||||
problems = localProblems,
|
||||
sessions = localSessions,
|
||||
attempts = localAttempts
|
||||
attempts = localAttempts,
|
||||
)
|
||||
|
||||
// Create server data with some overlapping and some unique data
|
||||
@@ -103,7 +103,7 @@ class SyncMergeLogicTest {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = "Updated notes",
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T12:00:00" // Newer update
|
||||
updatedAt = "2024-01-01T12:00:00", // Newer update
|
||||
),
|
||||
// Unique server gym
|
||||
BackupGym(
|
||||
@@ -115,8 +115,8 @@ class SyncMergeLogicTest {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T11:00:00",
|
||||
updatedAt = "2024-01-01T11:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T11:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
val serverProblems =
|
||||
@@ -136,7 +136,7 @@ class SyncMergeLogicTest {
|
||||
dateSet = "2024-01-01",
|
||||
notes = "Updated notes",
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T11:00:00" // Newer update
|
||||
updatedAt = "2024-01-01T11:00:00", // Newer update
|
||||
),
|
||||
// Unique server problem
|
||||
BackupProblem(
|
||||
@@ -153,8 +153,8 @@ class SyncMergeLogicTest {
|
||||
dateSet = null,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T11:00:00",
|
||||
updatedAt = "2024-01-01T11:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T11:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
val serverSessions =
|
||||
@@ -170,8 +170,8 @@ class SyncMergeLogicTest {
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = "Server session",
|
||||
createdAt = "2024-01-02T14:00:00",
|
||||
updatedAt = "2024-01-02T14:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-02T14:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
val serverAttempts =
|
||||
@@ -188,8 +188,8 @@ class SyncMergeLogicTest {
|
||||
restTime = 60,
|
||||
timestamp = "2024-01-02T14:30:00",
|
||||
createdAt = "2024-01-02T14:30:00",
|
||||
updatedAt = "2024-01-02T14:30:00"
|
||||
)
|
||||
updatedAt = "2024-01-02T14:30:00",
|
||||
),
|
||||
)
|
||||
|
||||
val serverBackup =
|
||||
@@ -200,7 +200,7 @@ class SyncMergeLogicTest {
|
||||
gyms = serverGyms,
|
||||
problems = serverProblems,
|
||||
sessions = serverSessions,
|
||||
attempts = serverAttempts
|
||||
attempts = serverAttempts,
|
||||
)
|
||||
|
||||
// Simulate merge logic
|
||||
@@ -232,11 +232,11 @@ class SyncMergeLogicTest {
|
||||
// Images should be merged (both local and server images preserved)
|
||||
assertTrue(
|
||||
"Should contain local image",
|
||||
mergedProblem1.imagePaths!!.contains("local_image.jpg")
|
||||
mergedProblem1.imagePaths!!.contains("local_image.jpg"),
|
||||
)
|
||||
assertTrue(
|
||||
"Should contain server image",
|
||||
mergedProblem1.imagePaths!!.contains("server_image.jpg")
|
||||
mergedProblem1.imagePaths!!.contains("server_image.jpg"),
|
||||
)
|
||||
assertEquals("Should have 2 images total", 2, mergedProblem1.imagePaths!!.size)
|
||||
|
||||
@@ -247,21 +247,21 @@ class SyncMergeLogicTest {
|
||||
// Verify all sessions are preserved
|
||||
assertTrue(
|
||||
"Should contain local session",
|
||||
mergedBackup.sessions.any { it.id == "session1" }
|
||||
mergedBackup.sessions.any { it.id == "session1" },
|
||||
)
|
||||
assertTrue(
|
||||
"Should contain server session",
|
||||
mergedBackup.sessions.any { it.id == "session2" }
|
||||
mergedBackup.sessions.any { it.id == "session2" },
|
||||
)
|
||||
|
||||
// Verify all attempts are preserved
|
||||
assertTrue(
|
||||
"Should contain local attempt",
|
||||
mergedBackup.attempts.any { it.id == "attempt1" }
|
||||
mergedBackup.attempts.any { it.id == "attempt1" },
|
||||
)
|
||||
assertTrue(
|
||||
"Should contain server attempt",
|
||||
mergedBackup.attempts.any { it.id == "attempt2" }
|
||||
mergedBackup.attempts.any { it.id == "attempt2" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,15 +269,15 @@ class SyncMergeLogicTest {
|
||||
fun `test date comparison logic`() {
|
||||
assertTrue(
|
||||
"ISO instant should be newer",
|
||||
isNewerThan("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z")
|
||||
isNewerThan("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
)
|
||||
assertFalse(
|
||||
"ISO instant should be older",
|
||||
isNewerThan("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z")
|
||||
isNewerThan("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z"),
|
||||
)
|
||||
assertTrue(
|
||||
"String comparison should work as fallback",
|
||||
isNewerThan("2024-01-02T10:00:00", "2024-01-01T10:00:00")
|
||||
isNewerThan("2024-01-02T10:00:00", "2024-01-01T10:00:00"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ class SyncMergeLogicTest {
|
||||
gyms = emptyList(),
|
||||
problems = emptyList(),
|
||||
sessions = emptyList(),
|
||||
attempts = emptyList()
|
||||
attempts = emptyList(),
|
||||
)
|
||||
|
||||
val dataBackup =
|
||||
@@ -311,12 +311,12 @@ class SyncMergeLogicTest {
|
||||
customDifficultyGrades = emptyList(),
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
)
|
||||
updatedAt = "2024-01-01T10:00:00",
|
||||
),
|
||||
),
|
||||
problems = emptyList(),
|
||||
sessions = emptyList(),
|
||||
attempts = emptyList()
|
||||
attempts = emptyList(),
|
||||
)
|
||||
|
||||
// Test merging empty with data
|
||||
@@ -335,7 +335,7 @@ class SyncMergeLogicTest {
|
||||
// Helper methods that simulate the merge logic from SyncService
|
||||
private fun performIntelligentMerge(
|
||||
local: ClimbDataBackup,
|
||||
server: ClimbDataBackup
|
||||
server: ClimbDataBackup,
|
||||
): ClimbDataBackup {
|
||||
val mergedGyms = mergeGyms(local.gyms, server.gyms)
|
||||
val mergedProblems = mergeProblems(local.problems, server.problems)
|
||||
@@ -349,7 +349,7 @@ class SyncMergeLogicTest {
|
||||
gyms = mergedGyms,
|
||||
problems = mergedProblems,
|
||||
sessions = mergedSessions,
|
||||
attempts = mergedAttempts
|
||||
attempts = mergedAttempts,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ class SyncMergeLogicTest {
|
||||
|
||||
private fun mergeProblems(
|
||||
local: List<BackupProblem>,
|
||||
server: List<BackupProblem>
|
||||
server: List<BackupProblem>,
|
||||
): List<BackupProblem> {
|
||||
val merged = mutableMapOf<String, BackupProblem>()
|
||||
|
||||
@@ -399,7 +399,7 @@ class SyncMergeLogicTest {
|
||||
|
||||
private fun mergeSessions(
|
||||
local: List<BackupClimbSession>,
|
||||
server: List<BackupClimbSession>
|
||||
server: List<BackupClimbSession>,
|
||||
): List<BackupClimbSession> {
|
||||
val merged = mutableMapOf<String, BackupClimbSession>()
|
||||
|
||||
@@ -420,7 +420,7 @@ class SyncMergeLogicTest {
|
||||
|
||||
private fun mergeAttempts(
|
||||
local: List<BackupAttempt>,
|
||||
server: List<BackupAttempt>
|
||||
server: List<BackupAttempt>,
|
||||
): List<BackupAttempt> {
|
||||
val merged = mutableMapOf<String, BackupAttempt>()
|
||||
|
||||
@@ -433,7 +433,7 @@ class SyncMergeLogicTest {
|
||||
if (localAttempt == null ||
|
||||
isNewerThan(
|
||||
serverAttempt.updatedAt ?: serverAttempt.createdAt,
|
||||
localAttempt.updatedAt ?: localAttempt.createdAt
|
||||
localAttempt.updatedAt ?: localAttempt.createdAt,
|
||||
)
|
||||
) {
|
||||
merged[serverAttempt.id] = serverAttempt
|
||||
@@ -469,7 +469,7 @@ class SyncMergeLogicTest {
|
||||
status = SessionStatus.ACTIVE,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
updatedAt = "2024-01-01T10:00:00",
|
||||
),
|
||||
BackupClimbSession(
|
||||
id = "completed_session_1",
|
||||
@@ -481,8 +481,8 @@ class SyncMergeLogicTest {
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = "Previous session",
|
||||
createdAt = "2023-12-31T15:00:00",
|
||||
updatedAt = "2023-12-31T17:00:00"
|
||||
)
|
||||
updatedAt = "2023-12-31T17:00:00",
|
||||
),
|
||||
)
|
||||
|
||||
// Simulate filtering that would happen in createBackupFromRepository
|
||||
@@ -496,7 +496,7 @@ class SyncMergeLogicTest {
|
||||
"Should not contain active session in sync",
|
||||
sessionsForSync.any {
|
||||
it.id == "active_session_1" && it.status == SessionStatus.ACTIVE
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Completed session should be included
|
||||
@@ -504,7 +504,7 @@ class SyncMergeLogicTest {
|
||||
"Should contain completed session in sync",
|
||||
sessionsForSync.any {
|
||||
it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.atridad.ascently
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class UtilityTests {
|
||||
|
||||
@@ -80,7 +80,7 @@ class UtilityTests {
|
||||
AttemptData("FALL", 90),
|
||||
AttemptData("SUCCESS", 150),
|
||||
AttemptData("FLASH", 60),
|
||||
AttemptData("FALL", 110)
|
||||
AttemptData("FALL", 110),
|
||||
)
|
||||
|
||||
val stats = calculateAttemptStatistics(attempts)
|
||||
@@ -169,7 +169,7 @@ class UtilityTests {
|
||||
"Crimpy Problem",
|
||||
"BOULDER",
|
||||
"V5",
|
||||
listOf("crimpy", "overhang")
|
||||
listOf("crimpy", "overhang"),
|
||||
),
|
||||
ProblemData("id2", "Easy Route", "ROPE", "5.6", listOf("beginner", "slab")),
|
||||
ProblemData(
|
||||
@@ -177,8 +177,8 @@ class UtilityTests {
|
||||
"Hard Boulder",
|
||||
"BOULDER",
|
||||
"V10",
|
||||
listOf("powerful", "roof")
|
||||
)
|
||||
listOf("powerful", "roof"),
|
||||
),
|
||||
)
|
||||
|
||||
val boulderProblems = filterByClimbType(problems, "BOULDER")
|
||||
@@ -211,7 +211,7 @@ class UtilityTests {
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
exportedAt = "2024-01-01T10:00:00Z",
|
||||
dataCount = 5
|
||||
dataCount = 5,
|
||||
)
|
||||
|
||||
val invalidBackup =
|
||||
@@ -219,7 +219,7 @@ class UtilityTests {
|
||||
version = "1.0",
|
||||
formatVersion = "2.0",
|
||||
exportedAt = "invalid-date",
|
||||
dataCount = -1
|
||||
dataCount = -1,
|
||||
)
|
||||
|
||||
assertTrue(isValidBackup(validBackup))
|
||||
@@ -261,7 +261,7 @@ class UtilityTests {
|
||||
totalAttempts = attempts.size,
|
||||
successfulAttempts = successful,
|
||||
successRate = successRate,
|
||||
averageDuration = avgDuration
|
||||
averageDuration = avgDuration,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ class UtilityTests {
|
||||
|
||||
private fun filterByClimbType(
|
||||
problems: List<ProblemData>,
|
||||
climbType: String
|
||||
climbType: String,
|
||||
): List<ProblemData> {
|
||||
return problems.filter { it.climbType == climbType }
|
||||
}
|
||||
@@ -314,7 +314,7 @@ class UtilityTests {
|
||||
private fun filterByDifficultyRange(
|
||||
problems: List<ProblemData>,
|
||||
minGrade: String,
|
||||
maxGrade: String
|
||||
maxGrade: String,
|
||||
): List<ProblemData> {
|
||||
return problems.filter { problem ->
|
||||
if (problem.climbType == "BOULDER" && problem.difficulty.startsWith("V")) {
|
||||
@@ -330,7 +330,7 @@ class UtilityTests {
|
||||
|
||||
private fun mergeData(
|
||||
local: Map<String, String>,
|
||||
server: Map<String, String>
|
||||
server: Map<String, String>,
|
||||
): Map<String, String> {
|
||||
return (local.keys + server.keys).associateWith { key -> server[key] ?: local[key]!! }
|
||||
}
|
||||
@@ -350,7 +350,7 @@ class UtilityTests {
|
||||
val totalAttempts: Int,
|
||||
val successfulAttempts: Int,
|
||||
val successRate: Double,
|
||||
val averageDuration: Double
|
||||
val averageDuration: Double,
|
||||
)
|
||||
|
||||
data class ProblemData(
|
||||
@@ -358,13 +358,13 @@ class UtilityTests {
|
||||
val name: String,
|
||||
val climbType: String,
|
||||
val difficulty: String,
|
||||
val tags: List<String>
|
||||
val tags: List<String>,
|
||||
)
|
||||
|
||||
data class BackupData(
|
||||
val version: String,
|
||||
val formatVersion: String,
|
||||
val exportedAt: String,
|
||||
val dataCount: Int
|
||||
val dataCount: Int,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.detekt) apply false
|
||||
alias(libs.plugins.spotless) apply false
|
||||
}
|
||||
584
android/detekt.yml
Normal file
@@ -0,0 +1,584 @@
|
||||
build:
|
||||
maxIssues: -1
|
||||
excludeCorrectable: false
|
||||
|
||||
config:
|
||||
validation: true
|
||||
warningsAsErrors: false
|
||||
|
||||
processors:
|
||||
active: true
|
||||
exclude:
|
||||
- 'DetektProgressListener'
|
||||
|
||||
console-reports:
|
||||
active: true
|
||||
|
||||
output-reports:
|
||||
active: true
|
||||
exclude:
|
||||
- 'TxtOutputReport'
|
||||
|
||||
complexity:
|
||||
active: true
|
||||
ComplexCondition:
|
||||
active: true
|
||||
threshold: 5
|
||||
CyclomaticComplexMethod:
|
||||
active: true
|
||||
threshold: 50
|
||||
ignoreSingleWhenExpression: true
|
||||
ignoreSimpleWhenEntries: true
|
||||
ignoreNestingFunctions: false
|
||||
nestingFunctions:
|
||||
- 'also'
|
||||
- 'apply'
|
||||
- 'let'
|
||||
- 'run'
|
||||
- 'with'
|
||||
LabeledExpression:
|
||||
active: false
|
||||
LargeClass:
|
||||
active: true
|
||||
threshold: 800
|
||||
LongMethod:
|
||||
active: true
|
||||
threshold: 120
|
||||
ignoreAnnotated:
|
||||
- 'Composable'
|
||||
- 'Test'
|
||||
LongParameterList:
|
||||
active: true
|
||||
functionThreshold: 15
|
||||
constructorThreshold: 15
|
||||
ignoreDefaultParameters: true
|
||||
ignoreDataClasses: true
|
||||
ignoreAnnotatedParameter:
|
||||
- 'Composable'
|
||||
MethodOverloading:
|
||||
active: false
|
||||
NamedArguments:
|
||||
active: false
|
||||
NestedBlockDepth:
|
||||
active: true
|
||||
threshold: 8
|
||||
NestedScopeFunctions:
|
||||
active: true
|
||||
threshold: 2
|
||||
functions:
|
||||
- 'also'
|
||||
- 'apply'
|
||||
- 'let'
|
||||
- 'run'
|
||||
- 'with'
|
||||
ReplaceSafeCallChainWithRun:
|
||||
active: false
|
||||
StringLiteralDuplication:
|
||||
active: false
|
||||
threshold: 4
|
||||
ignoreAnnotation: true
|
||||
excludeStringsWithLessThan5Characters: true
|
||||
ignoreStringsRegex: '$^'
|
||||
TooManyFunctions:
|
||||
active: true
|
||||
thresholdInFiles: 40
|
||||
thresholdInClasses: 40
|
||||
thresholdInInterfaces: 25
|
||||
thresholdInObjects: 25
|
||||
thresholdInEnums: 15
|
||||
ignoreDeprecated: true
|
||||
ignorePrivate: true
|
||||
ignoreOverridden: true
|
||||
|
||||
coroutines:
|
||||
active: true
|
||||
GlobalCoroutineUsage:
|
||||
active: true
|
||||
InjectDispatcher:
|
||||
active: true
|
||||
dispatcherNames:
|
||||
- 'IO'
|
||||
- 'Default'
|
||||
- 'Unconfined'
|
||||
RedundantSuspendModifier:
|
||||
active: true
|
||||
SleepInsteadOfDelay:
|
||||
active: true
|
||||
SuspendFunSwallowedCancellation:
|
||||
active: true
|
||||
SuspendFunWithCoroutineScopeReceiver:
|
||||
active: true
|
||||
SuspendFunWithFlowReturnType:
|
||||
active: true
|
||||
|
||||
empty-blocks:
|
||||
active: true
|
||||
EmptyCatchBlock:
|
||||
active: true
|
||||
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||
EmptyClassBlock:
|
||||
active: true
|
||||
EmptyDefaultConstructor:
|
||||
active: true
|
||||
EmptyDoWhileBlock:
|
||||
active: true
|
||||
EmptyElseBlock:
|
||||
active: true
|
||||
EmptyFinallyBlock:
|
||||
active: true
|
||||
EmptyForBlock:
|
||||
active: true
|
||||
EmptyFunctionBlock:
|
||||
active: true
|
||||
ignoreOverridden: true
|
||||
EmptyIfBlock:
|
||||
active: true
|
||||
EmptyInitBlock:
|
||||
active: true
|
||||
EmptyKtFile:
|
||||
active: true
|
||||
EmptySecondaryConstructor:
|
||||
active: true
|
||||
EmptyTryBlock:
|
||||
active: true
|
||||
EmptyWhenBlock:
|
||||
active: true
|
||||
EmptyWhileBlock:
|
||||
active: true
|
||||
|
||||
exceptions:
|
||||
active: true
|
||||
ExceptionRaisedInUnexpectedLocation:
|
||||
active: true
|
||||
methodNames:
|
||||
- 'equals'
|
||||
- 'finalize'
|
||||
- 'hashCode'
|
||||
- 'toString'
|
||||
InstanceOfCheckForException:
|
||||
active: true
|
||||
NotImplementedDeclaration:
|
||||
active: false
|
||||
ObjectExtendsThrowable:
|
||||
active: false
|
||||
PrintStackTrace:
|
||||
active: true
|
||||
RethrowCaughtException:
|
||||
active: true
|
||||
ReturnFromFinally:
|
||||
active: true
|
||||
SwallowedException:
|
||||
active: false
|
||||
ignoredExceptionTypes:
|
||||
- 'InterruptedException'
|
||||
- 'MalformedURLException'
|
||||
- 'NumberFormatException'
|
||||
- 'ParseException'
|
||||
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||
ThrowingExceptionFromFinally:
|
||||
active: true
|
||||
ThrowingExceptionInMain:
|
||||
active: false
|
||||
ThrowingExceptionsWithoutMessageOrCause:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/*.Test.kt', '**/*.Spec.kt', '**/*.Spek.kt']
|
||||
exceptions:
|
||||
- 'ArrayIndexOutOfBoundsException'
|
||||
- 'Exception'
|
||||
- 'IllegalArgumentException'
|
||||
- 'IllegalMonitorStateException'
|
||||
- 'IllegalStateException'
|
||||
- 'IndexOutOfBoundsException'
|
||||
- 'NullPointerException'
|
||||
- 'RuntimeException'
|
||||
- 'Throwable'
|
||||
ThrowingNewInstanceOfSameException:
|
||||
active: true
|
||||
TooGenericExceptionCaught:
|
||||
active: false
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/*.Test.kt', '**/*.Spec.kt', '**/*.Spek.kt']
|
||||
exceptionNames:
|
||||
- 'ArrayIndexOutOfBoundsException'
|
||||
- 'Error'
|
||||
- 'Exception'
|
||||
- 'IllegalMonitorStateException'
|
||||
- 'IndexOutOfBoundsException'
|
||||
- 'NullPointerException'
|
||||
- 'RuntimeException'
|
||||
- 'Throwable'
|
||||
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||
TooGenericExceptionThrown:
|
||||
active: false
|
||||
exceptionNames:
|
||||
- 'Error'
|
||||
- 'Exception'
|
||||
- 'RuntimeException'
|
||||
- 'Throwable'
|
||||
|
||||
naming:
|
||||
active: true
|
||||
BooleanPropertyNaming:
|
||||
active: true
|
||||
allowedPattern: '^(is|has|are|should|can|will|does|did)'
|
||||
ClassNaming:
|
||||
active: true
|
||||
classPattern: '[A-Z][a-zA-Z0-9]*'
|
||||
ConstructorParameterNaming:
|
||||
active: true
|
||||
parameterPattern: '[a-z][A-Za-z0-9]*'
|
||||
privateParameterPattern: '[a-z][A-Za-z0-9]*'
|
||||
excludeClassPattern: '$^'
|
||||
EnumNaming:
|
||||
active: true
|
||||
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
|
||||
ForbiddenClassName:
|
||||
active: false
|
||||
FunctionMaxLength:
|
||||
active: false
|
||||
FunctionMinLength:
|
||||
active: false
|
||||
FunctionNaming:
|
||||
active: true
|
||||
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
|
||||
excludeClassPattern: '$^'
|
||||
ignoreAnnotated:
|
||||
- 'Composable'
|
||||
FunctionParameterNaming:
|
||||
active: true
|
||||
parameterPattern: '[a-z][A-Za-z0-9]*'
|
||||
excludeClassPattern: '$^'
|
||||
InvalidPackageDeclaration:
|
||||
active: true
|
||||
rootPackage: 'com.atridad.ascently'
|
||||
LambdaParameterNaming:
|
||||
active: true
|
||||
parameterPattern: '[a-z][A-Za-z0-9]*|_'
|
||||
MatchingDeclarationName:
|
||||
active: false
|
||||
mustBeFirst: true
|
||||
MemberNameEqualsClassName:
|
||||
active: true
|
||||
ignoreOverridden: true
|
||||
NoNameShadowing:
|
||||
active: true
|
||||
NonBooleanPropertyPrefixedWithIs:
|
||||
active: true
|
||||
ObjectPropertyNaming:
|
||||
active: false
|
||||
PackageNaming:
|
||||
active: true
|
||||
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||
TopLevelPropertyNaming:
|
||||
active: true
|
||||
constantPattern: '[A-Z][_A-Z0-9]*'
|
||||
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||
privatePropertyPattern: '_?[A-Za-z][A-Za-z0-9]*'
|
||||
VariableMaxLength:
|
||||
active: false
|
||||
VariableMinLength:
|
||||
active: false
|
||||
VariableNaming:
|
||||
active: true
|
||||
variablePattern: '[a-z][A-Za-z0-9]*'
|
||||
privateVariablePattern: '[_a-z][A-Za-z0-9]*'
|
||||
excludeClassPattern: '$^'
|
||||
|
||||
performance:
|
||||
active: true
|
||||
ArrayPrimitive:
|
||||
active: true
|
||||
CouldBeSequence:
|
||||
active: false
|
||||
ForEachOnRange:
|
||||
active: true
|
||||
SpreadOperator:
|
||||
active: false
|
||||
UnnecessaryPartOfBinaryExpression:
|
||||
active: true
|
||||
UnnecessaryTemporaryInstantiation:
|
||||
active: true
|
||||
|
||||
potential-bugs:
|
||||
active: true
|
||||
AvoidReferentialEquality:
|
||||
active: true
|
||||
CastNullableToNonNullableType:
|
||||
active: true
|
||||
CastToNullableType:
|
||||
active: false
|
||||
Deprecation:
|
||||
active: false
|
||||
DontDowncastCollectionTypes:
|
||||
active: true
|
||||
DoubleMutabilityForCollection:
|
||||
active: true
|
||||
ElseCaseInsteadOfExhaustiveWhen:
|
||||
active: false
|
||||
EqualsAlwaysReturnsTrueOrFalse:
|
||||
active: true
|
||||
EqualsWithHashCodeExist:
|
||||
active: true
|
||||
ExitOutsideMain:
|
||||
active: false
|
||||
ExplicitGarbageCollectionCall:
|
||||
active: true
|
||||
HasPlatformType:
|
||||
active: true
|
||||
IgnoredReturnValue:
|
||||
active: true
|
||||
ImplicitDefaultLocale:
|
||||
active: true
|
||||
ImplicitUnitReturnType:
|
||||
active: true
|
||||
allowExplicitReturnType: true
|
||||
InvalidRange:
|
||||
active: true
|
||||
IteratorHasNextCallsNextMethod:
|
||||
active: true
|
||||
IteratorNotThrowingNoSuchElementException:
|
||||
active: true
|
||||
LateinitUsage:
|
||||
active: false
|
||||
MapGetWithNotNullAssertionOperator:
|
||||
active: true
|
||||
MissingPackageDeclaration:
|
||||
active: true
|
||||
NullCheckOnMutableProperty:
|
||||
active: true
|
||||
NullableToStringCall:
|
||||
active: true
|
||||
PropertyUsedBeforeDeclaration:
|
||||
active: false
|
||||
UnconditionalJumpStatementInLoop:
|
||||
active: true
|
||||
UnnecessaryNotNullCheck:
|
||||
active: true
|
||||
UnnecessaryNotNullOperator:
|
||||
active: true
|
||||
UnnecessarySafeCall:
|
||||
active: true
|
||||
UnreachableCatchBlock:
|
||||
active: true
|
||||
UnreachableCode:
|
||||
active: true
|
||||
UnsafeCallOnNullableType:
|
||||
active: true
|
||||
UnsafeCast:
|
||||
active: true
|
||||
UnusedUnaryOperator:
|
||||
active: true
|
||||
UselessPostfixExpression:
|
||||
active: true
|
||||
WrongEqualsTypeParameter:
|
||||
active: true
|
||||
|
||||
style:
|
||||
active: true
|
||||
AlsoCouldBeApply:
|
||||
active: false
|
||||
BracesOnIfStatements:
|
||||
active: false
|
||||
singleLine: 'never'
|
||||
multiLine: 'always'
|
||||
BracesOnWhenStatements:
|
||||
active: false
|
||||
singleLine: 'necessary'
|
||||
multiLine: 'necessary'
|
||||
CanBeNonNullable:
|
||||
active: true
|
||||
CascadingCallWrapping:
|
||||
active: false
|
||||
ClassOrdering:
|
||||
active: false
|
||||
CollapsibleIfStatements:
|
||||
active: false
|
||||
DataClassContainsFunctions:
|
||||
active: false
|
||||
DataClassShouldBeImmutable:
|
||||
active: true
|
||||
DestructuringDeclarationWithTooManyEntries:
|
||||
active: true
|
||||
maxDestructuringEntries: 5
|
||||
EqualsNullCall:
|
||||
active: true
|
||||
EqualsOnSignatureLine:
|
||||
active: true
|
||||
ExplicitCollectionElementAccessMethod:
|
||||
active: true
|
||||
ExplicitItLambdaParameter:
|
||||
active: false
|
||||
ExpressionBodySyntax:
|
||||
active: false
|
||||
includeLineWrapping: false
|
||||
ForbiddenAnnotation:
|
||||
active: false
|
||||
ForbiddenComment:
|
||||
active: false
|
||||
ForbiddenImport:
|
||||
active: false
|
||||
ForbiddenMethodCall:
|
||||
active: false
|
||||
ForbiddenSuppress:
|
||||
active: false
|
||||
ForbiddenVoid:
|
||||
active: true
|
||||
ignoreOverridden: true
|
||||
ignoreUsageInGenerics: true
|
||||
FunctionOnlyReturningConstant:
|
||||
active: false
|
||||
ignoreOverridableFunction: true
|
||||
ignoreActualFunction: true
|
||||
excludedFunctions: []
|
||||
LoopWithTooManyJumpStatements:
|
||||
active: true
|
||||
maxJumpCount: 2
|
||||
MagicNumber:
|
||||
active: false
|
||||
MandatoryBracesLoops:
|
||||
active: true
|
||||
MaxChainedCallsOnSameLine:
|
||||
active: true
|
||||
maxChainedCalls: 5
|
||||
MaxLineLength:
|
||||
active: true
|
||||
maxLineLength: 220
|
||||
excludePackageStatements: true
|
||||
excludeImportStatements: true
|
||||
excludeCommentStatements: true
|
||||
excludeRawStrings: true
|
||||
MayBeConst:
|
||||
active: true
|
||||
ModifierOrder:
|
||||
active: true
|
||||
MultilineRawStringIndentation:
|
||||
active: false
|
||||
indentSize: 4
|
||||
MultilineLambdaItParameter:
|
||||
active: true
|
||||
NestedClassesVisibility:
|
||||
active: true
|
||||
NewLineAtEndOfFile:
|
||||
active: false
|
||||
NoTabs:
|
||||
active: true
|
||||
NullableBooleanCheck:
|
||||
active: true
|
||||
ObjectLiteralToLambda:
|
||||
active: true
|
||||
OptionalAbstractKeyword:
|
||||
active: true
|
||||
OptionalUnit:
|
||||
active: true
|
||||
PreferToOverPairSyntax:
|
||||
active: true
|
||||
ProtectedMemberInFinalClass:
|
||||
active: true
|
||||
RedundantExplicitType:
|
||||
active: true
|
||||
RedundantHigherOrderMapUsage:
|
||||
active: true
|
||||
RedundantVisibilityModifierRule:
|
||||
active: false
|
||||
ReturnCount:
|
||||
active: true
|
||||
max: 6
|
||||
excludedFunctions:
|
||||
- 'equals'
|
||||
excludeLabeled: true
|
||||
excludeReturnFromLambda: true
|
||||
excludeGuardClauses: true
|
||||
SafeCast:
|
||||
active: true
|
||||
SerialVersionUIDInSerializableClass:
|
||||
active: false
|
||||
SpacingBetweenPackageAndImports:
|
||||
active: true
|
||||
StringShouldBeRawString:
|
||||
active: false
|
||||
ThrowsCount:
|
||||
active: true
|
||||
max: 10
|
||||
excludeGuardClauses: true
|
||||
TrailingWhitespace:
|
||||
active: false
|
||||
TrimMultilineRawString:
|
||||
active: true
|
||||
UnderscoresInNumericLiterals:
|
||||
active: false
|
||||
acceptableLength: 4
|
||||
allowNonStandardGrouping: false
|
||||
UnnecessaryAbstractClass:
|
||||
active: true
|
||||
UnnecessaryAnnotationUseSiteTarget:
|
||||
active: true
|
||||
UnnecessaryApply:
|
||||
active: true
|
||||
UnnecessaryBackticks:
|
||||
active: true
|
||||
UnnecessaryBracesAroundTrailingLambda:
|
||||
active: true
|
||||
UnnecessaryFilter:
|
||||
active: true
|
||||
UnnecessaryInheritance:
|
||||
active: true
|
||||
UnnecessaryInnerClass:
|
||||
active: true
|
||||
UnnecessaryLet:
|
||||
active: true
|
||||
UnnecessaryParentheses:
|
||||
active: false
|
||||
allowForUnclearPrecedence: true
|
||||
UntilInsteadOfRangeTo:
|
||||
active: true
|
||||
UnusedImports:
|
||||
active: true
|
||||
UnusedParameter:
|
||||
active: true
|
||||
allowedNames: 'ignored|expected|_|session|startTime|endTime|xAxisFormatter'
|
||||
UnusedPrivateClass:
|
||||
active: true
|
||||
UnusedPrivateMember:
|
||||
active: true
|
||||
allowedNames: '(_|ignored|expected|serialVersionUID)'
|
||||
UnusedPrivateProperty:
|
||||
active: true
|
||||
allowedNames: '_|ignored|expected|serialVersionUID'
|
||||
UseAnyOrNoneInsteadOfFind:
|
||||
active: true
|
||||
UseArrayLiteralsInAnnotations:
|
||||
active: true
|
||||
UseCheckNotNull:
|
||||
active: true
|
||||
UseCheckOrError:
|
||||
active: true
|
||||
UseDataClass:
|
||||
active: false
|
||||
UseEmptyCounterpart:
|
||||
active: true
|
||||
UseIfEmptyOrIfBlank:
|
||||
active: true
|
||||
UseIfInsteadOfWhen:
|
||||
active: false
|
||||
UseIsNullOrEmpty:
|
||||
active: true
|
||||
UseLet:
|
||||
active: false
|
||||
UseOrEmpty:
|
||||
active: true
|
||||
UseRequire:
|
||||
active: true
|
||||
UseRequireNotNull:
|
||||
active: true
|
||||
UseSumOfInsteadOfFlatMapSize:
|
||||
active: true
|
||||
UselessCallOnNotNull:
|
||||
active: true
|
||||
UtilityClassWithPublicConstructor:
|
||||
active: true
|
||||
VarCouldBeVal:
|
||||
active: true
|
||||
WildcardImport:
|
||||
active: false
|
||||
excludeImports:
|
||||
- 'java.util.*'
|
||||
- 'kotlinx.android.synthetic.*'
|
||||
@@ -10,7 +10,11 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
org.gradle.parallel=true
|
||||
# Enable Gradle build caching for faster incremental builds
|
||||
org.gradle.caching=true
|
||||
# Enable configuration caching for faster configuration phase
|
||||
org.gradle.configuration-cache=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
agp = "8.12.3"
|
||||
kotlin = "2.2.21"
|
||||
kotlin = "2.3.0"
|
||||
coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
@@ -10,8 +10,8 @@ androidxTestExt = "1.3.0"
|
||||
androidxTestRunner = "1.7.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.12.0"
|
||||
composeBom = "2025.11.01"
|
||||
activityCompose = "1.12.2"
|
||||
composeBom = "2025.12.01"
|
||||
room = "2.8.4"
|
||||
navigation = "2.9.6"
|
||||
viewmodel = "2.10.0"
|
||||
@@ -19,7 +19,10 @@ kotlinxSerialization = "1.9.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
coil = "2.7.0"
|
||||
ksp = "2.2.20-2.0.3"
|
||||
exifinterface = "1.4.1"
|
||||
exifinterface = "1.4.2"
|
||||
healthConnect = "1.1.0"
|
||||
detekt = "1.23.8"
|
||||
spotless = "8.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -69,9 +72,14 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||
|
||||
# Health Connect
|
||||
health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
||||
|
||||
3
branding/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
*.tmp
|
||||
.DS_Store
|
||||
*.log
|
||||
BIN
branding/Android/Icon-Android-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 933 KiB |
BIN
branding/Balls.icon/Assets/AscentlyBlueBall.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
branding/Balls.icon/Assets/AscentlyGreenBall.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
branding/Balls.icon/Assets/AscentlyRedBall.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
branding/Balls.icon/Assets/AscentlyYellowBall.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
67
branding/Balls.icon/icon.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "AscentlyRedBall.png",
|
||||
"name" : "AscentlyRedBall",
|
||||
"position" : {
|
||||
"scale" : 0.4,
|
||||
"translation-in-points" : [
|
||||
90.60312499999992,
|
||||
127.86484375000009
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "AscentlyYellowBall.png",
|
||||
"name" : "AscentlyYellowBall",
|
||||
"position" : {
|
||||
"scale" : 0.3,
|
||||
"translation-in-points" : [
|
||||
90.50312500000001,
|
||||
-177.66484375
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "AscentlyBlueBall.png",
|
||||
"name" : "AscentlyBlueBall",
|
||||
"position" : {
|
||||
"scale" : 0.3,
|
||||
"translation-in-points" : [
|
||||
-138.20312500000006,
|
||||
177.3648437500001
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "AscentlyGreenBall.png",
|
||||
"name" : "AscentlyGreenBall",
|
||||
"position" : {
|
||||
"scale" : 0.2,
|
||||
"translation-in-points" : [
|
||||
-138.30312499999997,
|
||||
-43.08515625000001
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
BIN
branding/Icon.icon/Assets/AscetlyTriangle1.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
branding/Icon.icon/Assets/AscetlyTriangle2.png
Normal file
|
After Width: | Height: | Size: 66 KiB |