Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
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
|
||||||
77
.gitattributes
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 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
|
||||||
46
.gitignore
vendored
@@ -9,6 +9,11 @@ local.properties
|
|||||||
# Log/OS Files
|
# Log/OS Files
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Android Studio generated files and folders
|
# Android Studio generated files and folders
|
||||||
captures/
|
captures/
|
||||||
@@ -34,3 +39,44 @@ google-services.json
|
|||||||
|
|
||||||
# Android Profiling
|
# Android Profiling
|
||||||
*.hprof
|
*.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
|
# 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_
|
_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.
|
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.
|
This is the native Android app for Ascently, built with Kotlin and Jetpack Compose.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/ascently/`.
|
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.
|
- `data/`: Handles all the app's data.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.detekt)
|
||||||
|
alias(libs.plugins.spotless)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -16,8 +18,8 @@ android {
|
|||||||
applicationId = "com.atridad.ascently"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 48
|
versionCode = 51
|
||||||
versionName = "2.4.0"
|
versionName = "2.5.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -27,7 +29,7 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +85,7 @@ dependencies {
|
|||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
// Health Connect
|
// Health Connect
|
||||||
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
|
implementation(libs.health.connect)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
@@ -99,6 +101,51 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
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
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# 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
|
# If your project uses WebView with JS, uncomment the following
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
# class:
|
# class:
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
# public *;
|
# 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(
|
AscentlyApp(
|
||||||
shortcutAction = shortcutAction,
|
shortcutAction = shortcutAction,
|
||||||
lastUsedGymId = lastUsedGymId,
|
lastUsedGymId = lastUsedGymId,
|
||||||
onShortcutActionProcessed = { clearShortcutAction() }
|
onShortcutActionProcessed = { clearShortcutAction() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,5 +76,4 @@ class Converters {
|
|||||||
fun toSessionStatus(value: String): SessionStatus {
|
fun toSessionStatus(value: String): SessionStatus {
|
||||||
return SessionStatus.valueOf(value)
|
return SessionStatus.valueOf(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import com.atridad.ascently.data.model.*
|
|||||||
@Database(
|
@Database(
|
||||||
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
|
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
|
||||||
version = 6,
|
version = 6,
|
||||||
exportSchema = false
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AscentlyDatabase : RoomDatabase() {
|
abstract class AscentlyDatabase : RoomDatabase() {
|
||||||
@@ -46,15 +46,15 @@ abstract class AscentlyDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
if (!existingColumns.contains("status")) {
|
if (!existingColumns.contains("status")) {
|
||||||
db.execSQL(
|
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(
|
db.execSQL(
|
||||||
"UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL"
|
"UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL",
|
||||||
)
|
)
|
||||||
db.execSQL(
|
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")) {
|
if (!existingColumns.contains("updatedAt")) {
|
||||||
db.execSQL(
|
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
|
// Set updatedAt to createdAt for existing records
|
||||||
db.execSQL(
|
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(
|
Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AscentlyDatabase::class.java,
|
AscentlyDatabase::class.java,
|
||||||
"ascently_database"
|
"ascently_database",
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.enableMultiInstanceInvalidation()
|
.enableMultiInstanceInvalidation()
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ interface ProblemDao {
|
|||||||
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
||||||
fun getAllProblems(): Flow<List<Problem>>
|
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")
|
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
|
||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
@@ -20,7 +21,7 @@ interface ProblemDao {
|
|||||||
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
@Query(
|
@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>>
|
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")
|
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
|
||||||
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertProblems(problems: List<Problem>)
|
suspend fun insertProblems(problems: List<Problem>)
|
||||||
@@ -39,7 +41,8 @@ interface ProblemDao {
|
|||||||
|
|
||||||
@Delete suspend fun deleteProblem(problem: Problem)
|
@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")
|
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
|
||||||
suspend fun getProblemsCountByGym(gymId: String): Int
|
suspend fun getProblemsCountByGym(gymId: String): Int
|
||||||
@@ -54,11 +57,13 @@ interface ProblemDao {
|
|||||||
OR description LIKE '%' || :searchQuery || '%'
|
OR description LIKE '%' || :searchQuery || '%'
|
||||||
OR location LIKE '%' || :searchQuery || '%')
|
OR location LIKE '%' || :searchQuery || '%')
|
||||||
ORDER BY updatedAt DESC
|
ORDER BY updatedAt DESC
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
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 problems: List<BackupProblem>,
|
||||||
val sessions: List<BackupClimbSession>,
|
val sessions: List<BackupClimbSession>,
|
||||||
val attempts: List<BackupAttempt>,
|
val attempts: List<BackupAttempt>,
|
||||||
val deletedItems: List<DeletedItem> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeletedItem(
|
data class DeletedItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: String, // "gym", "problem", "session", "attempt"
|
val type: String, // "gym", "problem", "session", "attempt"
|
||||||
val deletedAt: String
|
val deletedAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Platform-neutral gym representation for backup/restore
|
// Platform-neutral gym representation for backup/restore
|
||||||
@@ -34,8 +33,9 @@ data class BackupGym(
|
|||||||
@kotlinx.serialization.SerialName("customDifficultyGrades")
|
@kotlinx.serialization.SerialName("customDifficultyGrades")
|
||||||
val customDifficultyGrades: List<String>? = null,
|
val customDifficultyGrades: List<String>? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
val isDeleted: Boolean = false,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromGym(gym: Gym): BackupGym {
|
fun fromGym(gym: Gym): BackupGym {
|
||||||
@@ -47,8 +47,24 @@ data class BackupGym(
|
|||||||
difficultySystems = gym.difficultySystems,
|
difficultySystems = gym.difficultySystems,
|
||||||
customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null },
|
customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null },
|
||||||
notes = gym.notes,
|
notes = gym.notes,
|
||||||
|
isDeleted = false,
|
||||||
createdAt = gym.createdAt,
|
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(),
|
customDifficultyGrades = customDifficultyGrades ?: emptyList(),
|
||||||
notes = notes,
|
notes = notes,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt
|
updatedAt = updatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,8 +99,9 @@ data class BackupProblem(
|
|||||||
val isActive: Boolean = true,
|
val isActive: Boolean = true,
|
||||||
val dateSet: String? = null,
|
val dateSet: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
val isDeleted: Boolean = false,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromProblem(problem: Problem): BackupProblem {
|
fun fromProblem(problem: Problem): BackupProblem {
|
||||||
@@ -98,13 +115,37 @@ data class BackupProblem(
|
|||||||
tags = problem.tags,
|
tags = problem.tags,
|
||||||
location = problem.location,
|
location = problem.location,
|
||||||
imagePaths =
|
imagePaths =
|
||||||
if (problem.imagePaths.isEmpty()) null
|
if (problem.imagePaths.isEmpty()) {
|
||||||
else problem.imagePaths.map { path -> path.substringAfterLast('/') },
|
null
|
||||||
|
} else {
|
||||||
|
problem.imagePaths.map { path -> path.substringAfterLast('/') }
|
||||||
|
},
|
||||||
isActive = problem.isActive,
|
isActive = problem.isActive,
|
||||||
dateSet = problem.dateSet,
|
dateSet = problem.dateSet,
|
||||||
notes = problem.notes,
|
notes = problem.notes,
|
||||||
|
isDeleted = false,
|
||||||
createdAt = problem.createdAt,
|
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,
|
dateSet = dateSet,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt
|
updatedAt = updatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +185,9 @@ data class BackupClimbSession(
|
|||||||
val duration: Long? = null,
|
val duration: Long? = null,
|
||||||
val status: SessionStatus,
|
val status: SessionStatus,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
val isDeleted: Boolean = false,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromClimbSession(session: ClimbSession): BackupClimbSession {
|
fun fromClimbSession(session: ClimbSession): BackupClimbSession {
|
||||||
@@ -158,8 +200,25 @@ data class BackupClimbSession(
|
|||||||
duration = session.duration,
|
duration = session.duration,
|
||||||
status = session.status,
|
status = session.status,
|
||||||
notes = session.notes,
|
notes = session.notes,
|
||||||
|
isDeleted = false,
|
||||||
createdAt = session.createdAt,
|
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,
|
status = status,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt
|
updatedAt = updatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,8 +251,9 @@ data class BackupAttempt(
|
|||||||
val duration: Long? = null,
|
val duration: Long? = null,
|
||||||
val restTime: Long? = null,
|
val restTime: Long? = null,
|
||||||
val timestamp: String,
|
val timestamp: String,
|
||||||
|
val isDeleted: Boolean = false,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String? = null
|
val updatedAt: String? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromAttempt(attempt: Attempt): BackupAttempt {
|
fun fromAttempt(attempt: Attempt): BackupAttempt {
|
||||||
@@ -207,8 +267,26 @@ data class BackupAttempt(
|
|||||||
duration = attempt.duration,
|
duration = attempt.duration,
|
||||||
restTime = attempt.restTime,
|
restTime = attempt.restTime,
|
||||||
timestamp = attempt.timestamp,
|
timestamp = attempt.timestamp,
|
||||||
|
isDeleted = false,
|
||||||
createdAt = attempt.createdAt,
|
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,
|
restTime = restTime,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt ?: createdAt
|
updatedAt = updatedAt ?: createdAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.atridad.ascently.data.health
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import com.atridad.ascently.utils.AppLogger
|
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.health.connect.client.HealthConnectClient
|
import androidx.health.connect.client.HealthConnectClient
|
||||||
import androidx.health.connect.client.PermissionController
|
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.ExerciseSessionRecord
|
||||||
import androidx.health.connect.client.records.HeartRateRecord
|
import androidx.health.connect.client.records.HeartRateRecord
|
||||||
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
||||||
|
|
||||||
import androidx.health.connect.client.units.Energy
|
import androidx.health.connect.client.units.Energy
|
||||||
import com.atridad.ascently.data.model.ClimbSession
|
import com.atridad.ascently.data.model.ClimbSession
|
||||||
import com.atridad.ascently.data.model.SessionStatus
|
import com.atridad.ascently.data.model.SessionStatus
|
||||||
|
import com.atridad.ascently.utils.AppLogger
|
||||||
import com.atridad.ascently.utils.DateFormatUtils
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
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,
|
* 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.getReadPermission(HeartRateRecord::class),
|
||||||
HealthPermission.getWritePermission(HeartRateRecord::class),
|
HealthPermission.getWritePermission(HeartRateRecord::class),
|
||||||
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::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 {
|
suspend fun isReady(): Boolean {
|
||||||
return try {
|
return try {
|
||||||
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
|
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val isAvailable =
|
val isAvailable =
|
||||||
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
|
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
|
||||||
@@ -157,18 +160,18 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
suspend fun syncCompletedSession(
|
suspend fun syncCompletedSession(
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
gymName: String,
|
gymName: String,
|
||||||
attemptCount: Int = 0
|
attemptCount: Int = 0,
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
if (!isReady() || !_autoSync.value) {
|
if (!isReady() || !_autoSync.value) {
|
||||||
return Result.failure(
|
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) {
|
if (session.status != SessionStatus.COMPLETED) {
|
||||||
return Result.failure(
|
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) {
|
if (startTime == null || endTime == null) {
|
||||||
return Result.failure(
|
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),
|
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
|
||||||
exerciseType =
|
exerciseType =
|
||||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
|
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)
|
records.add(exerciseSession)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -215,7 +219,8 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
endZoneOffset =
|
endZoneOffset =
|
||||||
ZoneOffset.systemDefault().rules.getOffset(endTime),
|
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)
|
records.add(caloriesRecord)
|
||||||
}
|
}
|
||||||
@@ -238,9 +243,9 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
preferences
|
preferences
|
||||||
.edit()
|
.edit {
|
||||||
.putString("last_sync_success", DateFormatUtils.nowISO8601())
|
putString("last_sync_success", DateFormatUtils.nowISO8601())
|
||||||
.apply()
|
}
|
||||||
} else {
|
} else {
|
||||||
val reason =
|
val reason =
|
||||||
when {
|
when {
|
||||||
@@ -262,7 +267,7 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
suspend fun autoSyncCompletedSession(
|
suspend fun autoSyncCompletedSession(
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
gymName: String,
|
gymName: String,
|
||||||
attemptCount: Int = 0
|
attemptCount: Int = 0,
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
|
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
|
||||||
AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
|
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(
|
private fun createHeartRateRecord(
|
||||||
startTime: Instant,
|
startTime: Instant,
|
||||||
endTime: Instant,
|
endTime: Instant,
|
||||||
attemptCount: Int
|
attemptCount: Int,
|
||||||
): HeartRateRecord? {
|
): HeartRateRecord? {
|
||||||
return try {
|
return try {
|
||||||
val samples = mutableListOf<HeartRateRecord.Sample>()
|
val samples = mutableListOf<HeartRateRecord.Sample>()
|
||||||
@@ -324,7 +329,8 @@ class HealthConnectManager(private val context: Context) {
|
|||||||
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
|
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
|
||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
|
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
|
||||||
samples = samples
|
samples = samples,
|
||||||
|
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
AppLogger.e(TAG, e) { "Error creating heart rate record" }
|
AppLogger.e(TAG, e) { "Error creating heart rate record" }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.atridad.ascently.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
@@ -12,7 +13,7 @@ enum class AttemptResult {
|
|||||||
SUCCESS,
|
SUCCESS,
|
||||||
FALL,
|
FALL,
|
||||||
NO_PROGRESS,
|
NO_PROGRESS,
|
||||||
FLASH
|
FLASH,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -23,16 +24,18 @@ enum class AttemptResult {
|
|||||||
entity = ClimbSession::class,
|
entity = ClimbSession::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["sessionId"],
|
childColumns = ["sessionId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
),
|
),
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = Problem::class,
|
entity = Problem::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["problemId"],
|
childColumns = ["problemId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
)],
|
),
|
||||||
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])]
|
],
|
||||||
|
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])],
|
||||||
)
|
)
|
||||||
|
@Immutable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Attempt(
|
data class Attempt(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
@@ -45,7 +48,7 @@ data class Attempt(
|
|||||||
val restTime: Long? = null,
|
val restTime: Long? = null,
|
||||||
val timestamp: String,
|
val timestamp: String,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(
|
fun create(
|
||||||
@@ -56,7 +59,7 @@ data class Attempt(
|
|||||||
notes: String? = null,
|
notes: String? = null,
|
||||||
duration: Long? = null,
|
duration: Long? = null,
|
||||||
restTime: Long? = null,
|
restTime: Long? = null,
|
||||||
timestamp: String = DateFormatUtils.nowISO8601()
|
timestamp: String = DateFormatUtils.nowISO8601(),
|
||||||
): Attempt {
|
): Attempt {
|
||||||
val now = DateFormatUtils.nowISO8601()
|
val now = DateFormatUtils.nowISO8601()
|
||||||
return Attempt(
|
return Attempt(
|
||||||
@@ -70,7 +73,7 @@ data class Attempt(
|
|||||||
restTime = restTime,
|
restTime = restTime,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
updatedAt = now
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.atridad.ascently.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
@@ -11,7 +12,7 @@ import kotlinx.serialization.Serializable
|
|||||||
enum class SessionStatus {
|
enum class SessionStatus {
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
COMPLETED,
|
COMPLETED,
|
||||||
PAUSED
|
PAUSED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -22,10 +23,12 @@ enum class SessionStatus {
|
|||||||
entity = Gym::class,
|
entity = Gym::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["gymId"],
|
childColumns = ["gymId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
)],
|
),
|
||||||
indices = [Index(value = ["gymId"])]
|
],
|
||||||
|
indices = [Index(value = ["gymId"])],
|
||||||
)
|
)
|
||||||
|
@Immutable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ClimbSession(
|
data class ClimbSession(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
@@ -37,7 +40,7 @@ data class ClimbSession(
|
|||||||
val status: SessionStatus = SessionStatus.ACTIVE,
|
val status: SessionStatus = SessionStatus.ACTIVE,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(gymId: String, notes: String? = null): ClimbSession {
|
fun create(gymId: String, notes: String? = null): ClimbSession {
|
||||||
@@ -50,7 +53,7 @@ data class ClimbSession(
|
|||||||
status = SessionStatus.ACTIVE,
|
status = SessionStatus.ACTIVE,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
updatedAt = now
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +66,21 @@ data class ClimbSession(
|
|||||||
val end = DateFormatUtils.parseISO8601(endTime)
|
val end = DateFormatUtils.parseISO8601(endTime)
|
||||||
if (start != null && end != null) {
|
if (start != null && end != null) {
|
||||||
java.time.Duration.between(start, end).toMinutes()
|
java.time.Duration.between(start, end).toMinutes()
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
return this.copy(
|
return this.copy(
|
||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
duration = durationMinutes,
|
duration = durationMinutes,
|
||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
updatedAt = DateFormatUtils.nowISO8601()
|
updatedAt = DateFormatUtils.nowISO8601(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
enum class ClimbType {
|
enum class ClimbType {
|
||||||
ROPE,
|
ROPE,
|
||||||
BOULDER;
|
BOULDER,
|
||||||
|
;
|
||||||
|
|
||||||
val displayName: String
|
val displayName: String
|
||||||
get() =
|
get() =
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ enum class DifficultySystem {
|
|||||||
|
|
||||||
// Rope
|
// Rope
|
||||||
YDS,
|
YDS,
|
||||||
CUSTOM;
|
CUSTOM,
|
||||||
|
;
|
||||||
|
|
||||||
val displayName: String
|
val displayName: String
|
||||||
get() =
|
get() =
|
||||||
@@ -60,7 +61,7 @@ enum class DifficultySystem {
|
|||||||
"V14",
|
"V14",
|
||||||
"V15",
|
"V15",
|
||||||
"V16",
|
"V16",
|
||||||
"V17"
|
"V17",
|
||||||
)
|
)
|
||||||
FONT ->
|
FONT ->
|
||||||
listOf(
|
listOf(
|
||||||
@@ -88,7 +89,7 @@ enum class DifficultySystem {
|
|||||||
"8B",
|
"8B",
|
||||||
"8B+",
|
"8B+",
|
||||||
"8C",
|
"8C",
|
||||||
"8C+"
|
"8C+",
|
||||||
)
|
)
|
||||||
YDS ->
|
YDS ->
|
||||||
listOf(
|
listOf(
|
||||||
@@ -125,7 +126,7 @@ enum class DifficultySystem {
|
|||||||
"5.15a",
|
"5.15a",
|
||||||
"5.15b",
|
"5.15b",
|
||||||
"5.15c",
|
"5.15c",
|
||||||
"5.15d"
|
"5.15d",
|
||||||
)
|
)
|
||||||
CUSTOM -> emptyList()
|
CUSTOM -> emptyList()
|
||||||
}
|
}
|
||||||
@@ -144,7 +145,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
system: DifficultySystem,
|
system: DifficultySystem,
|
||||||
grade: String
|
grade: String,
|
||||||
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
|
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -180,7 +181,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
|||||||
"8B" to 24,
|
"8B" to 24,
|
||||||
"8B+" to 25,
|
"8B+" to 25,
|
||||||
"8C" to 26,
|
"8C" to 26,
|
||||||
"8C+" to 27
|
"8C+" to 27,
|
||||||
)
|
)
|
||||||
fontMapping[grade] ?: 0
|
fontMapping[grade] ?: 0
|
||||||
}
|
}
|
||||||
@@ -220,7 +221,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
|||||||
"5.15a" to 80,
|
"5.15a" to 80,
|
||||||
"5.15b" to 81,
|
"5.15b" to 81,
|
||||||
"5.15c" to 82,
|
"5.15c" to 82,
|
||||||
"5.15d" to 83
|
"5.15d" to 83,
|
||||||
)
|
)
|
||||||
ydsMapping[grade] ?: 0
|
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
|
* Compare this grade with another grade of the same system Returns negative if this grade is
|
||||||
* easier, positive if harder, 0 if equal
|
* easier, positive if harder, 0 if equal
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.atridad.ascently.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.atridad.ascently.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
@Entity(tableName = "gyms")
|
@Entity(tableName = "gyms")
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Gym(
|
data class Gym(
|
||||||
@@ -16,7 +18,7 @@ data class Gym(
|
|||||||
val customDifficultyGrades: List<String> = emptyList(),
|
val customDifficultyGrades: List<String> = emptyList(),
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(
|
fun create(
|
||||||
@@ -25,7 +27,7 @@ data class Gym(
|
|||||||
supportedClimbTypes: List<ClimbType>,
|
supportedClimbTypes: List<ClimbType>,
|
||||||
difficultySystems: List<DifficultySystem>,
|
difficultySystems: List<DifficultySystem>,
|
||||||
customDifficultyGrades: List<String> = emptyList(),
|
customDifficultyGrades: List<String> = emptyList(),
|
||||||
notes: String? = null
|
notes: String? = null,
|
||||||
): Gym {
|
): Gym {
|
||||||
val now = DateFormatUtils.nowISO8601()
|
val now = DateFormatUtils.nowISO8601()
|
||||||
return Gym(
|
return Gym(
|
||||||
@@ -37,7 +39,7 @@ data class Gym(
|
|||||||
customDifficultyGrades = customDifficultyGrades,
|
customDifficultyGrades = customDifficultyGrades,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
updatedAt = now
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.atridad.ascently.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
@@ -7,6 +8,7 @@ import androidx.room.PrimaryKey
|
|||||||
import com.atridad.ascently.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "problems",
|
tableName = "problems",
|
||||||
foreignKeys =
|
foreignKeys =
|
||||||
@@ -15,9 +17,10 @@ import kotlinx.serialization.Serializable
|
|||||||
entity = Gym::class,
|
entity = Gym::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["gymId"],
|
childColumns = ["gymId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
)],
|
),
|
||||||
indices = [Index(value = ["gymId"])]
|
],
|
||||||
|
indices = [Index(value = ["gymId"])],
|
||||||
)
|
)
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Problem(
|
data class Problem(
|
||||||
@@ -34,7 +37,7 @@ data class Problem(
|
|||||||
val dateSet: String? = null,
|
val dateSet: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(
|
fun create(
|
||||||
@@ -47,7 +50,7 @@ data class Problem(
|
|||||||
location: String? = null,
|
location: String? = null,
|
||||||
imagePaths: List<String> = emptyList(),
|
imagePaths: List<String> = emptyList(),
|
||||||
dateSet: String? = null,
|
dateSet: String? = null,
|
||||||
notes: String? = null
|
notes: String? = null,
|
||||||
): Problem {
|
): Problem {
|
||||||
val now = DateFormatUtils.nowISO8601()
|
val now = DateFormatUtils.nowISO8601()
|
||||||
return Problem(
|
return Problem(
|
||||||
@@ -64,7 +67,7 @@ data class Problem(
|
|||||||
dateSet = dateSet,
|
dateSet = dateSet,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
createdAt = now,
|
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.format.DeletedItem
|
||||||
import com.atridad.ascently.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.ascently.data.state.DataStateManager
|
import com.atridad.ascently.data.state.DataStateManager
|
||||||
import com.atridad.ascently.utils.DateFormatUtils
|
|
||||||
import com.atridad.ascently.utils.AppLogger
|
import com.atridad.ascently.utils.AppLogger
|
||||||
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import com.atridad.ascently.utils.ZipExportImportUtils
|
import com.atridad.ascently.utils.ZipExportImportUtils
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
|
class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
|
||||||
private val gymDao = database.gymDao()
|
private val gymDao = database.gymDao()
|
||||||
@@ -38,6 +39,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
|
|
||||||
// Gym operations
|
// Gym operations
|
||||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
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 getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||||
suspend fun insertGym(gym: Gym) {
|
suspend fun insertGym(gym: Gym) {
|
||||||
gymDao.insertGym(gym)
|
gymDao.insertGym(gym)
|
||||||
@@ -60,6 +62,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
|
|
||||||
// Problem operations
|
// Problem operations
|
||||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||||
|
suspend fun getAllProblemsSync(): List<Problem> = problemDao.getAllProblems().first()
|
||||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||||
suspend fun insertProblem(problem: Problem) {
|
suspend fun insertProblem(problem: Problem) {
|
||||||
@@ -80,6 +83,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||||
|
suspend fun getAllSessionsSync(): List<ClimbSession> = sessionDao.getAllSessions().first()
|
||||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||||
sessionDao.getSessionsByGym(gymId)
|
sessionDao.getSessionsByGym(gymId)
|
||||||
@@ -122,6 +126,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
|
|
||||||
// Attempt operations
|
// Attempt operations
|
||||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
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>> =
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||||
attemptDao.getAttemptsBySession(sessionId)
|
attemptDao.getAttemptsBySession(sessionId)
|
||||||
|
|
||||||
@@ -161,7 +167,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
gyms = allGyms.map { BackupGym.fromGym(it) },
|
gyms = allGyms.map { BackupGym.fromGym(it) },
|
||||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
||||||
sessions = allSessions.map { BackupClimbSession.fromClimbSession(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()
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
@@ -172,7 +178,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
val imageFile =
|
val imageFile =
|
||||||
com.atridad.ascently.utils.ImageUtils.getImageFile(
|
com.atridad.ascently.utils.ImageUtils.getImageFile(
|
||||||
context,
|
context,
|
||||||
imagePath
|
imagePath,
|
||||||
)
|
)
|
||||||
imageFile.exists() && imageFile.length() > 0
|
imageFile.exists() && imageFile.length() > 0
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -185,7 +191,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
context = context,
|
context = context,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
exportData = backupData,
|
exportData = backupData,
|
||||||
referencedImagePaths = validImagePaths
|
referencedImagePaths = validImagePaths,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Export failed: ${e.message}")
|
throw Exception("Export failed: ${e.message}")
|
||||||
@@ -229,7 +235,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
val updatedBackupProblems =
|
val updatedBackupProblems =
|
||||||
ZipExportImportUtils.updateProblemImagePaths(
|
ZipExportImportUtils.updateProblemImagePaths(
|
||||||
importData.problems,
|
importData.problems,
|
||||||
importResult.importedImagePaths
|
importResult.importedImagePaths,
|
||||||
)
|
)
|
||||||
|
|
||||||
updatedBackupProblems.forEach { backupProblem ->
|
updatedBackupProblems.forEach { backupProblem ->
|
||||||
@@ -237,7 +243,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
problemDao.insertProblem(backupProblem.toProblem())
|
problemDao.insertProblem(backupProblem.toProblem())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw 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) {
|
fun trackDeletion(itemId: String, itemType: String) {
|
||||||
val currentDeletions = getDeletedItems().toMutableList()
|
cleanupOldDeletions()
|
||||||
val newDeletion =
|
val newDeletion =
|
||||||
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
|
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
|
||||||
currentDeletions.add(newDeletion)
|
|
||||||
|
|
||||||
val json = json.encodeToString(newDeletion)
|
val json = json.encodeToString(newDeletion)
|
||||||
deletionPreferences.edit { putString("deleted_${itemId}", json) }
|
deletionPreferences.edit { putString("deleted_$itemId", json) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDeletedItems(): List<DeletedItem> {
|
fun getDeletedItems(): List<DeletedItem> {
|
||||||
@@ -304,24 +309,45 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
|
|||||||
deletionPreferences.edit { clear() }
|
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(
|
private fun validateDataIntegrity(
|
||||||
gyms: List<Gym>,
|
gyms: List<Gym>,
|
||||||
problems: List<Problem>,
|
problems: List<Problem>,
|
||||||
sessions: List<ClimbSession>,
|
sessions: List<ClimbSession>,
|
||||||
attempts: List<Attempt>
|
attempts: List<Attempt>,
|
||||||
) {
|
) {
|
||||||
val gymIds = gyms.map { it.id }.toSet()
|
val gymIds = gyms.map { it.id }.toSet()
|
||||||
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
||||||
if (invalidProblems.isNotEmpty()) {
|
if (invalidProblems.isNotEmpty()) {
|
||||||
throw Exception(
|
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 }
|
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||||
if (invalidSessions.isNotEmpty()) {
|
if (invalidSessions.isNotEmpty()) {
|
||||||
throw Exception(
|
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 }
|
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
|
||||||
if (invalidAttempts.isNotEmpty()) {
|
if (invalidAttempts.isNotEmpty()) {
|
||||||
throw Exception(
|
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.BackupClimbSession
|
||||||
import com.atridad.ascently.data.format.BackupGym
|
import com.atridad.ascently.data.format.BackupGym
|
||||||
import com.atridad.ascently.data.format.BackupProblem
|
import com.atridad.ascently.data.format.BackupProblem
|
||||||
import com.atridad.ascently.data.format.DeletedItem
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/** Request structure for delta sync - sends only changes since last sync */
|
/** Request structure for delta sync - sends only changes since last sync */
|
||||||
@@ -15,16 +14,15 @@ data class DeltaSyncRequest(
|
|||||||
val problems: List<BackupProblem>,
|
val problems: List<BackupProblem>,
|
||||||
val sessions: List<BackupClimbSession>,
|
val sessions: List<BackupClimbSession>,
|
||||||
val attempts: List<BackupAttempt>,
|
val attempts: List<BackupAttempt>,
|
||||||
val deletedItems: List<DeletedItem>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Response structure for delta sync - receives only changes from server */
|
/** Response structure for delta sync - receives only changes from server */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeltaSyncResponse(
|
data class DeltaSyncResponse(
|
||||||
val serverTime: String,
|
val serverTime: String,
|
||||||
|
val requestFullSync: Boolean = false,
|
||||||
val gyms: List<BackupGym>,
|
val gyms: List<BackupGym>,
|
||||||
val problems: List<BackupProblem>,
|
val problems: List<BackupProblem>,
|
||||||
val sessions: List<BackupClimbSession>,
|
val sessions: List<BackupClimbSession>,
|
||||||
val attempts: List<BackupAttempt>,
|
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")
|
SyncException("Invalid server response: $details")
|
||||||
|
|
||||||
data class NetworkError(val details: String) : SyncException("Network error: $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 {
|
enum class SyncProviderType {
|
||||||
NONE,
|
NONE,
|
||||||
SERVER
|
SERVER,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import com.atridad.ascently.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
|
import com.atridad.ascently.data.state.DataStateManager
|
||||||
import com.atridad.ascently.utils.AppLogger
|
import com.atridad.ascently.utils.AppLogger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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
|
// 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
|
// State
|
||||||
private val _isSyncing = MutableStateFlow(false)
|
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)
|
// Update last sync time from shared prefs (provider updates it)
|
||||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_syncError.value = e.message
|
_syncError.value = e.message
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -11,26 +11,26 @@ val bottomNavigationItems =
|
|||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
screen = Screen.Sessions,
|
screen = Screen.Sessions,
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
label = "Sessions"
|
label = "Sessions",
|
||||||
),
|
),
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
screen = Screen.Problems,
|
screen = Screen.Problems,
|
||||||
icon = Icons.Default.Star,
|
icon = Icons.Default.Star,
|
||||||
label = "Problems"
|
label = "Problems",
|
||||||
),
|
),
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
screen = Screen.Analytics,
|
screen = Screen.Analytics,
|
||||||
icon = Icons.Default.Info,
|
icon = Icons.Default.Info,
|
||||||
label = "Analytics"
|
label = "Analytics",
|
||||||
),
|
),
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
screen = Screen.Gyms,
|
screen = Screen.Gyms,
|
||||||
icon = Icons.Default.LocationOn,
|
icon = Icons.Default.LocationOn,
|
||||||
label = "Gyms"
|
label = "Gyms",
|
||||||
),
|
),
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
screen = Screen.Settings,
|
screen = Screen.Settings,
|
||||||
icon = Icons.Default.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.database.AscentlyDatabase
|
||||||
import com.atridad.ascently.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import com.atridad.ascently.utils.AppLogger
|
import com.atridad.ascently.utils.AppLogger
|
||||||
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
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.*
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
class SessionTrackingService : Service() {
|
class SessionTrackingService : Service() {
|
||||||
|
|
||||||
@@ -224,12 +225,12 @@ class SessionTrackingService : Service() {
|
|||||||
.addAction(
|
.addAction(
|
||||||
R.drawable.ic_mountains,
|
R.drawable.ic_mountains,
|
||||||
"Open Session",
|
"Open Session",
|
||||||
createOpenAppIntent()
|
createOpenAppIntent(),
|
||||||
)
|
)
|
||||||
.addAction(
|
.addAction(
|
||||||
android.R.drawable.ic_menu_close_clear_cancel,
|
android.R.drawable.ic_menu_close_clear_cancel,
|
||||||
"End Session",
|
"End Session",
|
||||||
createStopPendingIntent(sessionId)
|
createStopPendingIntent(sessionId),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use Live Update
|
// Use Live Update
|
||||||
@@ -237,9 +238,8 @@ class SessionTrackingService : Service() {
|
|||||||
val startTimeMillis =
|
val startTimeMillis =
|
||||||
session.startTime?.let { startTime ->
|
session.startTime?.let { startTime ->
|
||||||
try {
|
try {
|
||||||
val start = LocalDateTime.parse(startTime)
|
DateFormatUtils.parseISO8601(startTime)?.toEpochMilli()
|
||||||
val zoneId = ZoneId.systemDefault()
|
?: System.currentTimeMillis()
|
||||||
start.atZone(zoneId).toInstant().toEpochMilli()
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
System.currentTimeMillis()
|
System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ class SessionTrackingService : Service() {
|
|||||||
notificationBuilder
|
notificationBuilder
|
||||||
.setContentTitle("Climbing Session Active")
|
.setContentTitle("Climbing Session Active")
|
||||||
.setContentText(
|
.setContentText(
|
||||||
"${gym?.name ?: "Gym"} • ${attempts.size} attempts"
|
"${gym?.name ?: "Gym"} • ${attempts.size} attempts",
|
||||||
)
|
)
|
||||||
.setWhen(startTimeMillis)
|
.setWhen(startTimeMillis)
|
||||||
.setUsesChronometer(true)
|
.setUsesChronometer(true)
|
||||||
@@ -263,9 +263,9 @@ class SessionTrackingService : Service() {
|
|||||||
val duration =
|
val duration =
|
||||||
session.startTime?.let { startTime ->
|
session.startTime?.let { startTime ->
|
||||||
try {
|
try {
|
||||||
val start = LocalDateTime.parse(startTime)
|
val start = DateFormatUtils.parseISO8601(startTime)
|
||||||
val now = LocalDateTime.now()
|
val now = java.time.Instant.now()
|
||||||
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
val totalSeconds = if (start != null) ChronoUnit.SECONDS.between(start, now) else 0L
|
||||||
val hours = totalSeconds / 3600
|
val hours = totalSeconds / 3600
|
||||||
val minutes = (totalSeconds % 3600) / 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
val seconds = totalSeconds % 60
|
val seconds = totalSeconds % 60
|
||||||
@@ -284,7 +284,7 @@ class SessionTrackingService : Service() {
|
|||||||
notificationBuilder
|
notificationBuilder
|
||||||
.setContentTitle("Climbing Session Active")
|
.setContentTitle("Climbing Session Active")
|
||||||
.setContentText(
|
.setContentText(
|
||||||
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts"
|
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ class SessionTrackingService : Service() {
|
|||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
intent,
|
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,
|
this,
|
||||||
1,
|
1,
|
||||||
intent,
|
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(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Session Tracking",
|
"Session Tracking",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
)
|
)
|
||||||
.apply {
|
.apply {
|
||||||
description = "Shows active climbing session information"
|
description = "Shows active climbing session information"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import com.atridad.ascently.utils.NotificationPermissionUtils
|
|||||||
fun AscentlyApp(
|
fun AscentlyApp(
|
||||||
shortcutAction: String? = null,
|
shortcutAction: String? = null,
|
||||||
lastUsedGymId: String? = null,
|
lastUsedGymId: String? = null,
|
||||||
onShortcutActionProcessed: () -> Unit = {}
|
onShortcutActionProcessed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -53,7 +53,7 @@ fun AscentlyApp(
|
|||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission()
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { isGranted: Boolean ->
|
) { isGranted: Boolean ->
|
||||||
if (!isGranted) {
|
if (!isGranted) {
|
||||||
showNotificationPermissionDialog = false
|
showNotificationPermissionDialog = false
|
||||||
@@ -90,7 +90,7 @@ fun AscentlyApp(
|
|||||||
context = context,
|
context = context,
|
||||||
hasActiveSession = activeSession != null,
|
hasActiveSession = activeSession != null,
|
||||||
hasGyms = gyms.isNotEmpty(),
|
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 (activeSession == null) {
|
||||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||||
!NotificationPermissionUtils.isNotificationPermissionGranted(
|
!NotificationPermissionUtils.isNotificationPermissionGranted(
|
||||||
context
|
context,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
|
AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
|
||||||
@@ -160,20 +160,20 @@ fun AscentlyApp(
|
|||||||
fabConfig?.let { config ->
|
fabConfig?.let { config ->
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = config.onClick,
|
onClick = config.onClick,
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = config.icon,
|
imageVector = config.icon,
|
||||||
contentDescription = config.contentDescription
|
contentDescription = config.contentDescription,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Sessions,
|
startDestination = Screen.Sessions,
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
composable<Screen.Sessions> {
|
composable<Screen.Sessions> {
|
||||||
LaunchedEffect(gyms, activeSession) {
|
LaunchedEffect(gyms, activeSession) {
|
||||||
@@ -187,14 +187,14 @@ fun AscentlyApp(
|
|||||||
.shouldRequestNotificationPermission() &&
|
.shouldRequestNotificationPermission() &&
|
||||||
!NotificationPermissionUtils
|
!NotificationPermissionUtils
|
||||||
.isNotificationPermissionGranted(
|
.isNotificationPermissionGranted(
|
||||||
context
|
context,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
showNotificationPermissionDialog = true
|
showNotificationPermissionDialog = true
|
||||||
} else {
|
} else {
|
||||||
navController.navigate(Screen.AddEditSession())
|
navController.navigate(Screen.AddEditSession())
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -204,7 +204,7 @@ fun AscentlyApp(
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToSessionDetail = { sessionId ->
|
onNavigateToSessionDetail = { sessionId ->
|
||||||
navController.navigate(Screen.SessionDetail(sessionId))
|
navController.navigate(Screen.SessionDetail(sessionId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ fun AscentlyApp(
|
|||||||
contentDescription = "Add Problem",
|
contentDescription = "Add Problem",
|
||||||
onClick = {
|
onClick = {
|
||||||
navController.navigate(Screen.AddEditProblem())
|
navController.navigate(Screen.AddEditProblem())
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -227,7 +227,7 @@ fun AscentlyApp(
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToProblemDetail = { problemId ->
|
onNavigateToProblemDetail = { problemId ->
|
||||||
navController.navigate(Screen.ProblemDetail(problemId))
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,14 +242,14 @@ fun AscentlyApp(
|
|||||||
FabConfig(
|
FabConfig(
|
||||||
icon = Icons.Default.Add,
|
icon = Icons.Default.Add,
|
||||||
contentDescription = "Add Gym",
|
contentDescription = "Add Gym",
|
||||||
onClick = { navController.navigate(Screen.AddEditGym()) }
|
onClick = { navController.navigate(Screen.AddEditGym()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GymsScreen(
|
GymsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToGymDetail = { gymId ->
|
onNavigateToGymDetail = { gymId ->
|
||||||
navController.navigate(Screen.GymDetail(gymId))
|
navController.navigate(Screen.GymDetail(gymId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ fun AscentlyApp(
|
|||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToProblemDetail = { problemId ->
|
onNavigateToProblemDetail = { problemId ->
|
||||||
navController.navigate(Screen.ProblemDetail(problemId))
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ fun AscentlyApp(
|
|||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToEdit = { problemId ->
|
onNavigateToEdit = { problemId ->
|
||||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ fun AscentlyApp(
|
|||||||
},
|
},
|
||||||
onNavigateToProblemDetail = { problemId ->
|
onNavigateToProblemDetail = { problemId ->
|
||||||
navController.navigate(Screen.ProblemDetail(problemId))
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ fun AscentlyApp(
|
|||||||
AddEditGymScreen(
|
AddEditGymScreen(
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ fun AscentlyApp(
|
|||||||
problemId = args.problemId,
|
problemId = args.problemId,
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ fun AscentlyApp(
|
|||||||
sessionId = args.sessionId,
|
sessionId = args.sessionId,
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,9 +341,9 @@ fun AscentlyApp(
|
|||||||
onDismiss = { showNotificationPermissionDialog = false },
|
onDismiss = { showNotificationPermissionDialog = false },
|
||||||
onRequestPermission = {
|
onRequestPermission = {
|
||||||
permissionLauncher.launch(
|
permissionLauncher.launch(
|
||||||
NotificationPermissionUtils.getNotificationPermissionString()
|
NotificationPermissionUtils.getNotificationPermissionString(),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
|
|||||||
// Don't restore state - always start fresh when switching tabs
|
// Don't restore state - always start fresh when switching tabs
|
||||||
restoreState = false
|
restoreState = false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,5 +386,5 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
|
|||||||
data class FabConfig(
|
data class FabConfig(
|
||||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
val contentDescription: String,
|
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.ClimbSession
|
||||||
import com.atridad.ascently.data.model.Gym
|
import com.atridad.ascently.data.model.Gym
|
||||||
import com.atridad.ascently.ui.theme.CustomIcons
|
import com.atridad.ascently.ui.theme.CustomIcons
|
||||||
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -22,102 +23,113 @@ fun ActiveSessionBanner(
|
|||||||
activeSession: ClimbSession?,
|
activeSession: ClimbSession?,
|
||||||
gym: Gym?,
|
gym: Gym?,
|
||||||
onSessionClick: () -> Unit,
|
onSessionClick: () -> Unit,
|
||||||
onEndSession: () -> Unit
|
onEndSession: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (activeSession != null) {
|
if (activeSession == null) return
|
||||||
// Add a timer that updates every second for real-time duration counting
|
|
||||||
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
|
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) {
|
while (true) {
|
||||||
delay(1000) // Update every second
|
elapsedSeconds = calculateElapsedSeconds(startTimeString)
|
||||||
currentTime = LocalDateTime.now()
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val durationText = remember(elapsedSeconds) {
|
||||||
|
formatDuration(elapsedSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onSessionClick() },
|
.clickable { onSessionClick() },
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.PlayArrow,
|
Icons.Default.PlayArrow,
|
||||||
contentDescription = "Active session",
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Active Session",
|
text = "Active Session",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = gym?.name ?: "Unknown Gym",
|
text = gymName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
)
|
)
|
||||||
|
|
||||||
activeSession.startTime?.let { startTime ->
|
if (startTimeString != null) {
|
||||||
val duration = calculateDuration(startTime, currentTime)
|
|
||||||
Text(
|
Text(
|
||||||
text = duration,
|
text = durationText,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
FilledIconButton(
|
||||||
onClick = onEndSession,
|
onClick = onEndSession,
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
||||||
contentDescription = "End session"
|
contentDescription = "End session",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun calculateElapsedSeconds(startTimeString: String): Long {
|
||||||
|
return try {
|
||||||
|
val startTime = DateFormatUtils.parseISO8601(startTimeString) ?: return 0L
|
||||||
|
val now = Instant.now()
|
||||||
|
ChronoUnit.SECONDS.between(startTime, now).coerceAtLeast(0)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
private fun formatDuration(totalSeconds: Long): String {
|
||||||
return try {
|
|
||||||
val startTime = LocalDateTime.parse(startTimeString)
|
|
||||||
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
|
||||||
val hours = totalSeconds / 3600
|
val hours = totalSeconds / 3600
|
||||||
val minutes = (totalSeconds % 3600) / 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
val seconds = totalSeconds % 60
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
when {
|
return when {
|
||||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
else -> "${totalSeconds}s"
|
else -> "${seconds}s"
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
"Active"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ data class BarChartStyle(
|
|||||||
val barColor: Color,
|
val barColor: Color,
|
||||||
val gridColor: Color,
|
val gridColor: Color,
|
||||||
val textColor: Color,
|
val textColor: Color,
|
||||||
val backgroundColor: Color
|
val backgroundColor: Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Custom Bar Chart for displaying grade distribution */
|
/** Custom Bar Chart for displaying grade distribution */
|
||||||
@@ -39,9 +39,9 @@ fun BarChart(
|
|||||||
barColor = MaterialTheme.colorScheme.primary,
|
barColor = MaterialTheme.colorScheme.primary,
|
||||||
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
backgroundColor = MaterialTheme.colorScheme.surface
|
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
showGrid: Boolean = true
|
showGrid: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val textMeasurer = rememberTextMeasurer()
|
val textMeasurer = rememberTextMeasurer()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -70,7 +70,7 @@ fun BarChart(
|
|||||||
drawRect(
|
drawRect(
|
||||||
color = style.backgroundColor,
|
color = style.backgroundColor,
|
||||||
topLeft = Offset(padding, padding),
|
topLeft = Offset(padding, padding),
|
||||||
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
|
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw grid
|
// Draw grid
|
||||||
@@ -82,7 +82,7 @@ fun BarChart(
|
|||||||
gridColor = style.gridColor,
|
gridColor = style.gridColor,
|
||||||
maxValue = maxValue,
|
maxValue = maxValue,
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
textColor = style.textColor
|
textColor = style.textColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,9 @@ fun BarChart(
|
|||||||
val barHeight =
|
val barHeight =
|
||||||
if (maxValue > 0) {
|
if (maxValue > 0) {
|
||||||
(dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
|
(dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
|
||||||
} else 0f
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
val barX =
|
val barX =
|
||||||
padding +
|
padding +
|
||||||
@@ -103,7 +105,7 @@ fun BarChart(
|
|||||||
drawRect(
|
drawRect(
|
||||||
color = style.barColor,
|
color = style.barColor,
|
||||||
topLeft = Offset(barX, barY),
|
topLeft = Offset(barX, barY),
|
||||||
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
|
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw value on bar
|
// Draw value on bar
|
||||||
@@ -131,7 +133,7 @@ fun BarChart(
|
|||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
text = valueText,
|
text = valueText,
|
||||||
style = textStyle.copy(color = textColor),
|
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 =
|
topLeft =
|
||||||
Offset(
|
Offset(
|
||||||
barX + barWidth / 2f - labelTextSize.size.width / 2f,
|
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,
|
gridColor: Color,
|
||||||
maxValue: Int,
|
maxValue: Int,
|
||||||
textMeasurer: TextMeasurer,
|
textMeasurer: TextMeasurer,
|
||||||
textColor: Color
|
textColor: Color,
|
||||||
) {
|
) {
|
||||||
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
|
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
|
||||||
|
|
||||||
@@ -186,7 +188,7 @@ private fun DrawScope.drawGrid(
|
|||||||
color = gridColor,
|
color = gridColor,
|
||||||
start = Offset(padding, y),
|
start = Offset(padding, y),
|
||||||
end = Offset(padding + chartWidth, y),
|
end = Offset(padding + chartWidth, y),
|
||||||
strokeWidth = 1.dp.toPx()
|
strokeWidth = 1.dp.toPx(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw Y-axis label
|
// Draw Y-axis label
|
||||||
@@ -200,8 +202,8 @@ private fun DrawScope.drawGrid(
|
|||||||
topLeft =
|
topLeft =
|
||||||
Offset(
|
Offset(
|
||||||
padding - textSize.size.width - 8.dp.toPx(),
|
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) {
|
if (imagePaths.size > 1) {
|
||||||
thumbnailListState.animateScrollToItem(
|
thumbnailListState.animateScrollToItem(
|
||||||
index = pagerState.currentPage,
|
index = pagerState.currentPage,
|
||||||
scrollOffset = -200
|
scrollOffset = -200,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
|||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties =
|
properties =
|
||||||
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
|
||||||
// Main image pager
|
// Main image pager
|
||||||
@@ -58,26 +58,26 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
|||||||
imagePath = imagePaths[page],
|
imagePath = imagePaths[page],
|
||||||
contentDescription = "Full screen image",
|
contentDescription = "Full screen image",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Fit
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top bar with back button and counter
|
// Top bar with back button and counter
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
|
modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
|
||||||
color = Color.Black.copy(alpha = 0.6f)
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Back button
|
// Back button
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Close",
|
contentDescription = "Close",
|
||||||
tint = Color.White
|
tint = Color.White,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
|||||||
Text(
|
Text(
|
||||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||||
color = Color.White,
|
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) {
|
if (imagePaths.size > 1) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
color = Color.Black.copy(alpha = 0.6f)
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = thumbnailListState,
|
state = thumbnailListState,
|
||||||
modifier = Modifier.padding(vertical = 12.dp),
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(imagePaths) { index, imagePath ->
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
val isSelected = index == pagerState.currentPage
|
val isSelected = index == pagerState.currentPage
|
||||||
@@ -119,13 +119,13 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
pagerState.animateScrollToPage(index)
|
pagerState.animateScrollToPage(index)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
OrientationAwareImage(
|
OrientationAwareImage(
|
||||||
imagePath = imagePath,
|
imagePath = imagePath,
|
||||||
contentDescription = "Thumbnail ${index + 1}",
|
contentDescription = "Thumbnail ${index + 1}",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Selection indicator
|
// Selection indicator
|
||||||
@@ -135,20 +135,20 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
|||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Color.White.copy(alpha = 0.3f),
|
Color.White.copy(alpha = 0.3f),
|
||||||
RoundedCornerShape(8.dp)
|
RoundedCornerShape(8.dp),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
RoundedCornerShape(8.dp)
|
RoundedCornerShape(8.dp),
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(
|
.background(
|
||||||
Color.White.copy(alpha = 0.2f)
|
Color.White.copy(alpha = 0.2f),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ fun ImageDisplay(
|
|||||||
imagePaths: List<String>,
|
imagePaths: List<String>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageSize: Int = 120,
|
imageSize: Int = 120,
|
||||||
onImageClick: ((Int) -> Unit)? = null
|
onImageClick: ((Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
LocalContext.current
|
LocalContext.current
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ fun ImageDisplay(
|
|||||||
.clickable(enabled = onImageClick != null) {
|
.clickable(enabled = onImageClick != null) {
|
||||||
onImageClick?.invoke(index)
|
onImageClick?.invoke(index)
|
||||||
},
|
},
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,14 +46,14 @@ fun ImageDisplaySection(
|
|||||||
imagePaths: List<String>,
|
imagePaths: List<String>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
title: String = "Photos",
|
title: String = "Photos",
|
||||||
onImageClick: ((Int) -> Unit)? = null
|
onImageClick: ((Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (imagePaths.isNotEmpty()) {
|
if (imagePaths.isNotEmpty()) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fun ImagePicker(
|
|||||||
imageUris: List<String>,
|
imageUris: List<String>,
|
||||||
onImagesChanged: (List<String>) -> Unit,
|
onImagesChanged: (List<String>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
maxImages: Int = 5
|
maxImages: Int = 5,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var tempImageUris by remember { mutableStateOf(imageUris) }
|
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||||
@@ -45,7 +45,7 @@ fun ImagePicker(
|
|||||||
// Image picker launcher
|
// Image picker launcher
|
||||||
val imagePickerLauncher =
|
val imagePickerLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetMultipleContents()
|
contract = ActivityResultContracts.GetMultipleContents(),
|
||||||
) { uris ->
|
) { uris ->
|
||||||
if (uris.isNotEmpty()) {
|
if (uris.isNotEmpty()) {
|
||||||
val currentCount = tempImageUris.size
|
val currentCount = tempImageUris.size
|
||||||
@@ -88,7 +88,7 @@ fun ImagePicker(
|
|||||||
// Camera permission launcher
|
// Camera permission launcher
|
||||||
val cameraPermissionLauncher =
|
val cameraPermissionLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission()
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { isGranted ->
|
) { isGranted ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
// Create image file for camera
|
// Create image file for camera
|
||||||
@@ -97,7 +97,7 @@ fun ImagePicker(
|
|||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"${context.packageName}.fileprovider",
|
"${context.packageName}.fileprovider",
|
||||||
imageFile
|
imageFile,
|
||||||
)
|
)
|
||||||
cameraImageUri = uri
|
cameraImageUri = uri
|
||||||
cameraLauncher.launch(uri)
|
cameraLauncher.launch(uri)
|
||||||
@@ -108,11 +108,11 @@ fun ImagePicker(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Photos (${tempImageUris.size}/$maxImages)",
|
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (tempImageUris.size < maxImages) {
|
if (tempImageUris.size < maxImages) {
|
||||||
@@ -120,7 +120,7 @@ fun ImagePicker(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Add Photos")
|
Text("Add Photos")
|
||||||
@@ -142,7 +142,7 @@ fun ImagePicker(
|
|||||||
|
|
||||||
// Delete the image file
|
// Delete the image file
|
||||||
ImageUtils.deleteImage(context, imagePath)
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,22 +154,22 @@ fun ImagePicker(
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Add photos of this problem",
|
text = "Add photos of this problem",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,12 +188,12 @@ fun ImagePicker(
|
|||||||
onClick = {
|
onClick = {
|
||||||
showImageSourceDialog = false
|
showImageSourceDialog = false
|
||||||
imagePickerLauncher.launch("image/*")
|
imagePickerLauncher.launch("image/*")
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.PhotoLibrary,
|
Icons.Default.PhotoLibrary,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Gallery")
|
Text("Gallery")
|
||||||
@@ -202,9 +202,10 @@ fun ImagePicker(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showImageSourceDialog = false
|
showImageSourceDialog = false
|
||||||
when (ContextCompat.checkSelfPermission(
|
when (
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.CAMERA
|
Manifest.permission.CAMERA,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
PackageManager.PERMISSION_GRANTED -> {
|
PackageManager.PERMISSION_GRANTED -> {
|
||||||
@@ -214,23 +215,23 @@ fun ImagePicker(
|
|||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"${context.packageName}.fileprovider",
|
"${context.packageName}.fileprovider",
|
||||||
imageFile
|
imageFile,
|
||||||
)
|
)
|
||||||
cameraImageUri = uri
|
cameraImageUri = uri
|
||||||
cameraLauncher.launch(uri)
|
cameraLauncher.launch(uri)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
cameraPermissionLauncher.launch(
|
cameraPermissionLauncher.launch(
|
||||||
Manifest.permission.CAMERA
|
Manifest.permission.CAMERA,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CameraAlt,
|
Icons.Default.CameraAlt,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Camera")
|
Text("Camera")
|
||||||
@@ -239,7 +240,7 @@ fun ImagePicker(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
|
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +263,7 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
|
|||||||
imagePath = imagePath,
|
imagePath = imagePath,
|
||||||
contentDescription = "Problem photo",
|
contentDescription = "Problem photo",
|
||||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|
||||||
IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
|
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),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
Icons.Default.Close,
|
||||||
contentDescription = "Remove photo",
|
contentDescription = "Remove photo",
|
||||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
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(
|
data class ChartDataPoint(
|
||||||
val x: Float,
|
val x: Float,
|
||||||
val y: Float,
|
val y: Float,
|
||||||
val label: String? = null
|
val label: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +37,7 @@ data class ChartStyle(
|
|||||||
val lineWidth: Float = 3f,
|
val lineWidth: Float = 3f,
|
||||||
val gridColor: Color,
|
val gridColor: Color,
|
||||||
val textColor: Color,
|
val textColor: Color,
|
||||||
val backgroundColor: Color
|
val backgroundColor: Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,11 +52,11 @@ fun LineChart(
|
|||||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||||
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
backgroundColor = MaterialTheme.colorScheme.surface
|
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
showGrid: Boolean = true,
|
showGrid: Boolean = true,
|
||||||
xAxisFormatter: (Float) -> String = { it.toString() },
|
xAxisFormatter: (Float) -> String = { it.toString() },
|
||||||
yAxisFormatter: (Float) -> String = { it.toString() }
|
yAxisFormatter: (Float) -> String = { it.toString() },
|
||||||
) {
|
) {
|
||||||
val textMeasurer = rememberTextMeasurer()
|
val textMeasurer = rememberTextMeasurer()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -65,7 +65,7 @@ fun LineChart(
|
|||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
if (data.isEmpty()) return@Canvas
|
if (data.isEmpty()) return@Canvas
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ fun LineChart(
|
|||||||
drawRect(
|
drawRect(
|
||||||
color = style.backgroundColor,
|
color = style.backgroundColor,
|
||||||
topLeft = Offset(padding, padding),
|
topLeft = Offset(padding, padding),
|
||||||
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
|
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw grid
|
// Draw grid
|
||||||
@@ -120,7 +120,7 @@ fun LineChart(
|
|||||||
textColor = style.textColor,
|
textColor = style.textColor,
|
||||||
xAxisFormatter = xAxisFormatter,
|
xAxisFormatter = xAxisFormatter,
|
||||||
yAxisFormatter = yAxisFormatter,
|
yAxisFormatter = yAxisFormatter,
|
||||||
actualDataPoints = data
|
actualDataPoints = data,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ fun LineChart(
|
|||||||
points = screenPoints,
|
points = screenPoints,
|
||||||
padding = padding,
|
padding = padding,
|
||||||
chartHeight = chartHeight,
|
chartHeight = chartHeight,
|
||||||
fillColor = style.fillColor
|
fillColor = style.fillColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ fun LineChart(
|
|||||||
drawLine(
|
drawLine(
|
||||||
points = screenPoints,
|
points = screenPoints,
|
||||||
lineColor = style.lineColor,
|
lineColor = style.lineColor,
|
||||||
lineWidth = style.lineWidth
|
lineWidth = style.lineWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,20 +149,20 @@ fun LineChart(
|
|||||||
drawCircle(
|
drawCircle(
|
||||||
color = style.lineColor,
|
color = style.lineColor,
|
||||||
radius = 8f,
|
radius = 8f,
|
||||||
center = point
|
center = point,
|
||||||
)
|
)
|
||||||
// Draw inner circle (white center)
|
// Draw inner circle (white center)
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = style.backgroundColor,
|
color = style.backgroundColor,
|
||||||
radius = 5f,
|
radius = 5f,
|
||||||
center = point
|
center = point,
|
||||||
)
|
)
|
||||||
// Draw border for better visibility
|
// Draw border for better visibility
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = style.lineColor,
|
color = style.lineColor,
|
||||||
radius = 8f,
|
radius = 8f,
|
||||||
center = point,
|
center = point,
|
||||||
style = Stroke(width = 2f)
|
style = Stroke(width = 2f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,11 +182,11 @@ private fun DrawScope.drawGrid(
|
|||||||
textColor: Color,
|
textColor: Color,
|
||||||
xAxisFormatter: (Float) -> String,
|
xAxisFormatter: (Float) -> String,
|
||||||
yAxisFormatter: (Float) -> String,
|
yAxisFormatter: (Float) -> String,
|
||||||
actualDataPoints: List<ChartDataPoint>
|
actualDataPoints: List<ChartDataPoint>,
|
||||||
) {
|
) {
|
||||||
val textStyle = TextStyle(
|
val textStyle = TextStyle(
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 10.sp
|
fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw vertical grid lines (X-axis) - only at integer values for sessions
|
// Draw vertical grid lines (X-axis) - only at integer values for sessions
|
||||||
@@ -203,7 +203,7 @@ private fun DrawScope.drawGrid(
|
|||||||
color = gridColor,
|
color = gridColor,
|
||||||
start = Offset(x, padding),
|
start = Offset(x, padding),
|
||||||
end = Offset(x, padding + chartHeight),
|
end = Offset(x, padding + chartHeight),
|
||||||
strokeWidth = 1.dp.toPx()
|
strokeWidth = 1.dp.toPx(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// X-axis labels removed per user request
|
// X-axis labels removed per user request
|
||||||
@@ -226,7 +226,7 @@ private fun DrawScope.drawGrid(
|
|||||||
color = gridColor,
|
color = gridColor,
|
||||||
start = Offset(padding, y),
|
start = Offset(padding, y),
|
||||||
end = Offset(padding + chartWidth, y),
|
end = Offset(padding + chartWidth, y),
|
||||||
strokeWidth = 1.dp.toPx()
|
strokeWidth = 1.dp.toPx(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw label
|
// Draw label
|
||||||
@@ -238,8 +238,8 @@ private fun DrawScope.drawGrid(
|
|||||||
style = textStyle,
|
style = textStyle,
|
||||||
topLeft = Offset(
|
topLeft = Offset(
|
||||||
padding - textSize.size.width - 8.dp.toPx(),
|
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>,
|
points: List<Offset>,
|
||||||
padding: Float,
|
padding: Float,
|
||||||
chartHeight: Float,
|
chartHeight: Float,
|
||||||
fillColor: Color
|
fillColor: Color,
|
||||||
) {
|
) {
|
||||||
val bottomY = padding + chartHeight // This represents the bottom of the chart area
|
val bottomY = padding + chartHeight // This represents the bottom of the chart area
|
||||||
|
|
||||||
@@ -274,14 +274,14 @@ private fun DrawScope.drawAreaFill(
|
|||||||
|
|
||||||
drawPath(
|
drawPath(
|
||||||
path = path,
|
path = path,
|
||||||
color = fillColor
|
color = fillColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawLine(
|
private fun DrawScope.drawLine(
|
||||||
points: List<Offset>,
|
points: List<Offset>,
|
||||||
lineColor: Color,
|
lineColor: Color,
|
||||||
lineWidth: Float
|
lineWidth: Float,
|
||||||
) {
|
) {
|
||||||
val path = Path().apply {
|
val path = Path().apply {
|
||||||
moveTo(points.first().x, points.first().y)
|
moveTo(points.first().x, points.first().y)
|
||||||
@@ -296,7 +296,7 @@ private fun DrawScope.drawLine(
|
|||||||
style = Stroke(
|
style = Stroke(
|
||||||
width = lineWidth,
|
width = lineWidth,
|
||||||
cap = StrokeCap.Round,
|
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) {
|
fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: () -> Unit) {
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
shape = MaterialTheme.shapes.medium
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(24.dp),
|
modifier = Modifier.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Notifications,
|
imageVector = Icons.Default.Notifications,
|
||||||
contentDescription = "Notifications",
|
contentDescription = "Notifications",
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -39,7 +39,7 @@ fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: ()
|
|||||||
text = "Enable Notifications",
|
text = "Enable Notifications",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
|
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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.",
|
"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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) {
|
TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) {
|
||||||
Text("Not Now")
|
Text("Not Now")
|
||||||
@@ -67,7 +67,7 @@ fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: ()
|
|||||||
onRequestPermission()
|
onRequestPermission()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
) { Text("Enable") }
|
) { Text("Enable") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.atridad.ascently.utils.ImageUtils
|
import com.atridad.ascently.utils.ImageUtils
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OrientationAwareImage(
|
fun OrientationAwareImage(
|
||||||
imagePath: String,
|
imagePath: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentDescription: String? = null,
|
contentDescription: String? = null,
|
||||||
contentScale: ContentScale = ContentScale.Fit
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var imageBitmap by
|
var imageBitmap by
|
||||||
@@ -62,7 +62,7 @@ fun OrientationAwareImage(
|
|||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = contentScale
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,14 +71,14 @@ fun OrientationAwareImage(
|
|||||||
|
|
||||||
private fun correctImageOrientation(
|
private fun correctImageOrientation(
|
||||||
imageFile: File,
|
imageFile: File,
|
||||||
bitmap: android.graphics.Bitmap
|
bitmap: android.graphics.Bitmap,
|
||||||
): android.graphics.Bitmap {
|
): android.graphics.Bitmap {
|
||||||
return try {
|
return try {
|
||||||
val exif = ExifInterface(imageFile.absolutePath)
|
val exif = ExifInterface(imageFile.absolutePath)
|
||||||
val orientation =
|
val orientation =
|
||||||
exif.getAttributeInt(
|
exif.getAttributeInt(
|
||||||
ExifInterface.TAG_ORIENTATION,
|
ExifInterface.TAG_ORIENTATION,
|
||||||
ExifInterface.ORIENTATION_NORMAL
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
val matrix = Matrix()
|
val matrix = Matrix()
|
||||||
@@ -131,7 +131,7 @@ private fun correctImageOrientation(
|
|||||||
bitmap.width,
|
bitmap.width,
|
||||||
bitmap.height,
|
bitmap.height,
|
||||||
matrix,
|
matrix,
|
||||||
true
|
true,
|
||||||
)
|
)
|
||||||
if (rotatedBitmap != bitmap) {
|
if (rotatedBitmap != bitmap) {
|
||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier)
|
|||||||
visible = syncing,
|
visible = syncing,
|
||||||
enter = scaleIn() + fadeIn(),
|
enter = scaleIn() + fadeIn(),
|
||||||
exit = scaleOut() + fadeOut(),
|
exit = scaleOut() + fadeOut(),
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -37,12 +37,12 @@ fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier)
|
|||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
// Permission launcher
|
// Permission launcher
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = healthConnectManager.getPermissionRequestContract()
|
contract = healthConnectManager.getPermissionRequestContract(),
|
||||||
) { _ ->
|
) { _ ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val allGranted = healthConnectManager.hasAllPermissions()
|
val allGranted = healthConnectManager.hasAllPermissions()
|
||||||
@@ -77,17 +77,17 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
// Header with icon and title
|
// Header with icon and title
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.HealthAndSafety,
|
imageVector = Icons.Default.HealthAndSafety,
|
||||||
@@ -98,7 +98,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
@@ -108,7 +108,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
text = "Health Connect",
|
text = "Health Connect",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
@@ -126,7 +126,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
when {
|
when {
|
||||||
isLoading ->
|
isLoading ->
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
alpha = 0.7f
|
alpha = 0.7f,
|
||||||
)
|
)
|
||||||
|
|
||||||
!isCompatible -> MaterialTheme.colorScheme.error
|
!isCompatible -> MaterialTheme.colorScheme.error
|
||||||
@@ -139,9 +139,9 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
|
|
||||||
else ->
|
else ->
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!hasPermissions) {
|
if (!hasPermissions) {
|
||||||
@@ -190,9 +190,9 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -200,7 +200,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
imageVector = Icons.Default.Warning,
|
imageVector = Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
@@ -209,7 +209,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
text = "Permissions needed",
|
text = "Permissions needed",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +221,8 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color =
|
color =
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
alpha = 0.8f
|
alpha = 0.8f,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -243,7 +243,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Grant Permissions") }
|
) { 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.",
|
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
errorMessage?.let { error ->
|
errorMessage?.let { error ->
|
||||||
@@ -266,19 +266,19 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
alpha = 0.5f
|
alpha = 0.5f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Warning,
|
imageVector = Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
@@ -286,7 +286,7 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.data.model.*
|
||||||
import com.atridad.ascently.ui.components.ImagePicker
|
import com.atridad.ascently.ui.components.ImagePicker
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -72,7 +72,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
location,
|
location,
|
||||||
selectedClimbTypes.toList(),
|
selectedClimbTypes.toList(),
|
||||||
selectedDifficultySystems.toList(),
|
selectedDifficultySystems.toList(),
|
||||||
notes = notes
|
notes = notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -98,15 +98,15 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
enabled =
|
enabled =
|
||||||
name.isNotBlank() &&
|
name.isNotBlank() &&
|
||||||
selectedClimbTypes.isNotEmpty() &&
|
selectedClimbTypes.isNotEmpty() &&
|
||||||
selectedDifficultySystems.isNotEmpty()
|
selectedDifficultySystems.isNotEmpty(),
|
||||||
) { Text("Save") }
|
) { Text("Save") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Name field
|
// Name field
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -114,7 +114,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Gym Name") },
|
label = { Text("Gym Name") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Location field
|
// Location field
|
||||||
@@ -123,7 +123,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onValueChange = { location = it },
|
onValueChange = { location = it },
|
||||||
label = { Text("Location (Optional)") },
|
label = { Text("Location (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Climb Types
|
// Climb Types
|
||||||
@@ -132,7 +132,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
Text(
|
Text(
|
||||||
text = "Supported Climb Types",
|
text = "Supported Climb Types",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -156,12 +156,12 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
climbType
|
climbType
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
role = Role.Checkbox
|
role = Role.Checkbox,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = climbType in selectedClimbTypes,
|
checked = climbType in selectedClimbTypes,
|
||||||
onCheckedChange = null
|
onCheckedChange = null,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(climbType.displayName)
|
Text(climbType.displayName)
|
||||||
@@ -176,7 +176,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
Text(
|
Text(
|
||||||
text = "Difficulty Systems",
|
text = "Difficulty Systems",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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",
|
"Select climb types first to see available difficulty systems",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
availableDifficultySystems.forEach { system ->
|
availableDifficultySystems.forEach { system ->
|
||||||
@@ -211,12 +211,12 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
system
|
system
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
role = Role.Checkbox
|
role = Role.Checkbox,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = system in selectedDifficultySystems,
|
checked = system in selectedDifficultySystems,
|
||||||
onCheckedChange = null
|
onCheckedChange = null,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(system.displayName)
|
Text(system.displayName)
|
||||||
@@ -232,7 +232,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onValueChange = { notes = it },
|
onValueChange = { notes = it },
|
||||||
label = { Text("Notes (Optional)") },
|
label = { Text("Notes (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 3
|
minLines = 3,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ fun AddEditProblemScreen(
|
|||||||
problemId: String?,
|
problemId: String?,
|
||||||
gymId: String?,
|
gymId: String?,
|
||||||
viewModel: ClimbViewModel,
|
viewModel: ClimbViewModel,
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isEditing = problemId != null
|
val isEditing = problemId != null
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
@@ -337,7 +337,7 @@ fun AddEditProblemScreen(
|
|||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -350,12 +350,13 @@ fun AddEditProblemScreen(
|
|||||||
system = selectedDifficultySystem,
|
system = selectedDifficultySystem,
|
||||||
grade = difficultyGrade,
|
grade = difficultyGrade,
|
||||||
numericValue =
|
numericValue =
|
||||||
when (selectedDifficultySystem
|
when (
|
||||||
|
selectedDifficultySystem
|
||||||
) {
|
) {
|
||||||
DifficultySystem.V_SCALE ->
|
DifficultySystem.V_SCALE ->
|
||||||
difficultyGrade
|
difficultyGrade
|
||||||
.removePrefix(
|
.removePrefix(
|
||||||
"V"
|
"V",
|
||||||
)
|
)
|
||||||
.toIntOrNull()
|
.toIntOrNull()
|
||||||
?: 0
|
?: 0
|
||||||
@@ -363,7 +364,7 @@ fun AddEditProblemScreen(
|
|||||||
difficultyGrade
|
difficultyGrade
|
||||||
.hashCode() %
|
.hashCode() %
|
||||||
100 // Simple mapping for other systems
|
100 // Simple mapping for other systems
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
val problem =
|
val problem =
|
||||||
@@ -382,7 +383,7 @@ fun AddEditProblemScreen(
|
|||||||
},
|
},
|
||||||
location = location.ifBlank { null },
|
location = location.ifBlank { null },
|
||||||
imagePaths = imagePaths,
|
imagePaths = imagePaths,
|
||||||
notes = notes.ifBlank { null }
|
notes = notes.ifBlank { null },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -395,15 +396,15 @@ fun AddEditProblemScreen(
|
|||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = selectedGym != null && difficultyGrade.isNotBlank()
|
enabled = selectedGym != null && difficultyGrade.isNotBlank(),
|
||||||
) { Text("Save") }
|
) { Text("Save") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Gym Selection
|
// Gym Selection
|
||||||
item {
|
item {
|
||||||
@@ -412,7 +413,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Select Gym",
|
text = "Select Gym",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -421,7 +422,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "No gyms available. Add a gym first.",
|
text = "No gyms available. Add a gym first.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -429,7 +430,7 @@ fun AddEditProblemScreen(
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedGym = gym },
|
onClick = { selectedGym = gym },
|
||||||
label = { Text(gym.name) },
|
label = { Text(gym.name) },
|
||||||
selected = selectedGym?.id == gym.id
|
selected = selectedGym?.id == gym.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,7 +446,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Problem Details",
|
text = "Problem Details",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -456,7 +457,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Problem Name (Optional)") },
|
label = { Text("Problem Name (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -467,7 +468,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Description (Optional)") },
|
label = { Text("Description (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
placeholder = { Text("Describe the problem, holds, style, etc.") }
|
placeholder = { Text("Describe the problem, holds, style, etc.") },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -480,7 +481,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Location (Optional)") },
|
label = { Text("Location (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
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(
|
||||||
text = "Climb Type",
|
text = "Climb Type",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -504,7 +505,7 @@ fun AddEditProblemScreen(
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.displayName) },
|
label = { Text(climbType.displayName) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,7 +522,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Difficulty",
|
text = "Difficulty",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -529,7 +530,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Difficulty System",
|
text = "Difficulty System",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -537,7 +538,7 @@ fun AddEditProblemScreen(
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedDifficultySystem = system },
|
onClick = { selectedDifficultySystem = system },
|
||||||
label = { Text(system.displayName) },
|
label = { Text(system.displayName) },
|
||||||
selected = selectedDifficultySystem == system
|
selected = selectedDifficultySystem == system,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,7 +565,7 @@ fun AddEditProblemScreen(
|
|||||||
Text("Custom grades must be whole numbers")
|
Text("Custom grades must be whole numbers")
|
||||||
},
|
},
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
@@ -573,7 +574,7 @@ fun AddEditProblemScreen(
|
|||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = !expanded },
|
onExpandedChange = { expanded = !expanded },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = difficultyGrade,
|
value = difficultyGrade,
|
||||||
@@ -582,7 +583,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Grade *") },
|
label = { Text("Grade *") },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||||
expanded = expanded
|
expanded = expanded,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
@@ -592,13 +593,13 @@ fun AddEditProblemScreen(
|
|||||||
Modifier.menuAnchor(
|
Modifier.menuAnchor(
|
||||||
ExposedDropdownMenuAnchorType
|
ExposedDropdownMenuAnchorType
|
||||||
.PrimaryNotEditable,
|
.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true,
|
||||||
)
|
)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = { expanded = false }
|
onDismissRequest = { expanded = false },
|
||||||
) {
|
) {
|
||||||
availableGrades.forEach { grade ->
|
availableGrades.forEach { grade ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -606,7 +607,7 @@ fun AddEditProblemScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
difficultyGrade = grade
|
difficultyGrade = grade
|
||||||
expanded = false
|
expanded = false
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,7 +625,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Photos",
|
text = "Photos",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -632,7 +633,7 @@ fun AddEditProblemScreen(
|
|||||||
ImagePicker(
|
ImagePicker(
|
||||||
imageUris = imagePaths,
|
imageUris = imagePaths,
|
||||||
onImagesChanged = { imagePaths = it },
|
onImagesChanged = { imagePaths = it },
|
||||||
maxImages = 5
|
maxImages = 5,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,7 +645,7 @@ fun AddEditProblemScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Additional Info",
|
text = "Additional Info",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -655,7 +656,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Tags (Optional)") },
|
label = { Text("Tags (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") }
|
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -666,7 +667,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Notes (Optional)") },
|
label = { Text("Notes (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 3,
|
minLines = 3,
|
||||||
placeholder = { Text("Any additional notes about this problem") }
|
placeholder = { Text("Any additional notes about this problem") },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -678,14 +679,14 @@ fun AddEditProblemScreen(
|
|||||||
.selectable(
|
.selectable(
|
||||||
selected = isActive,
|
selected = isActive,
|
||||||
onClick = { isActive = !isActive },
|
onClick = { isActive = !isActive },
|
||||||
role = Role.Checkbox
|
role = Role.Checkbox,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Checkbox(checked = isActive, onCheckedChange = null)
|
Checkbox(checked = isActive, onCheckedChange = null)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Problem is currently active",
|
text = "Problem is currently active",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -701,7 +702,7 @@ fun AddEditSessionScreen(
|
|||||||
sessionId: String?,
|
sessionId: String?,
|
||||||
gymId: String?,
|
gymId: String?,
|
||||||
viewModel: ClimbViewModel,
|
viewModel: ClimbViewModel,
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isEditing = sessionId != null
|
val isEditing = sessionId != null
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
@@ -745,7 +746,7 @@ fun AddEditSessionScreen(
|
|||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -760,7 +761,7 @@ fun AddEditSessionScreen(
|
|||||||
notes =
|
notes =
|
||||||
sessionNotes.ifBlank {
|
sessionNotes.ifBlank {
|
||||||
null
|
null
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
sessionId.let { id ->
|
sessionId.let { id ->
|
||||||
viewModel.updateSession(session.copy(id = id))
|
viewModel.updateSession(session.copy(id = id))
|
||||||
@@ -769,21 +770,21 @@ fun AddEditSessionScreen(
|
|||||||
viewModel.startSession(
|
viewModel.startSession(
|
||||||
context,
|
context,
|
||||||
gym.id,
|
gym.id,
|
||||||
sessionNotes.ifBlank { null }
|
sessionNotes.ifBlank { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = selectedGym != null
|
enabled = selectedGym != null,
|
||||||
) { Text("Save") }
|
) { Text("Save") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Gym Selection
|
// Gym Selection
|
||||||
item {
|
item {
|
||||||
@@ -792,7 +793,7 @@ fun AddEditSessionScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Select Gym",
|
text = "Select Gym",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -801,7 +802,7 @@ fun AddEditSessionScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "No gyms available. Add a gym first.",
|
text = "No gyms available. Add a gym first.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -809,7 +810,7 @@ fun AddEditSessionScreen(
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedGym = gym },
|
onClick = { selectedGym = gym },
|
||||||
label = { Text(gym.name) },
|
label = { Text(gym.name) },
|
||||||
selected = selectedGym?.id == gym.id
|
selected = selectedGym?.id == gym.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -825,7 +826,7 @@ fun AddEditSessionScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Session Details",
|
text = "Session Details",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -835,7 +836,7 @@ fun AddEditSessionScreen(
|
|||||||
onValueChange = { sessionDate = it },
|
onValueChange = { sessionDate = it },
|
||||||
label = { Text("Date (YYYY-MM-DD)") },
|
label = { Text("Date (YYYY-MM-DD)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -847,7 +848,7 @@ fun AddEditSessionScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -857,7 +858,7 @@ fun AddEditSessionScreen(
|
|||||||
onValueChange = { sessionNotes = it },
|
onValueChange = { sessionNotes = it },
|
||||||
label = { Text("Session Notes (Optional)") },
|
label = { Text("Session Notes (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 3
|
minLines = 3,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,27 +27,37 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
|||||||
val attempts by viewModel.attempts.collectAsState()
|
val attempts by viewModel.attempts.collectAsState()
|
||||||
val gyms by viewModel.gyms.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(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "Ascently Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Analytics",
|
text = "Analytics",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
@@ -59,27 +69,20 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
|||||||
totalSessions = sessions.size,
|
totalSessions = sessions.size,
|
||||||
totalProblems = problems.size,
|
totalProblems = problems.size,
|
||||||
totalAttempts = attempts.size,
|
totalAttempts = attempts.size,
|
||||||
totalGyms = gyms.size
|
totalGyms = gyms.size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grade Distribution Chart
|
// Grade Distribution Chart
|
||||||
item {
|
item {
|
||||||
val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
|
|
||||||
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
|
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorite Gym
|
// Favorite Gym
|
||||||
item {
|
item {
|
||||||
val favoriteGym =
|
|
||||||
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
|
|
||||||
(gymId, sessions) ->
|
|
||||||
gyms.find { it.id == gymId }?.name to sessions.size
|
|
||||||
}
|
|
||||||
|
|
||||||
FavoriteGymCard(
|
FavoriteGymCard(
|
||||||
gymName = favoriteGym?.first ?: "No sessions yet",
|
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(
|
||||||
text = "Overall Stats",
|
text = "Overall Stats",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
StatItem(label = "Sessions", value = totalSessions.toString())
|
StatItem(label = "Sessions", value = totalSessions.toString())
|
||||||
StatItem(label = "Problems", value = totalProblems.toString())
|
StatItem(label = "Problems", value = totalProblems.toString())
|
||||||
@@ -137,7 +140,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
Text(
|
Text(
|
||||||
text = "Grade Distribution",
|
text = "Grade Distribution",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -146,7 +149,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Time period toggle
|
// Time period toggle
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -161,8 +164,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
FilterChipDefaults.filterChipColors(
|
FilterChipDefaults.filterChipColors(
|
||||||
selectedContainerColor =
|
selectedContainerColor =
|
||||||
MaterialTheme.colorScheme.primary,
|
MaterialTheme.colorScheme.primary,
|
||||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 7 Days button
|
// 7 Days button
|
||||||
@@ -174,8 +177,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
FilterChipDefaults.filterChipColors(
|
FilterChipDefaults.filterChipColors(
|
||||||
selectedContainerColor =
|
selectedContainerColor =
|
||||||
MaterialTheme.colorScheme.primary,
|
MaterialTheme.colorScheme.primary,
|
||||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +186,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
if (usedSystems.size > 1) {
|
if (usedSystems.size > 1) {
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = !expanded }
|
onExpandedChange = { expanded = !expanded },
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value =
|
value =
|
||||||
@@ -203,14 +206,14 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
type =
|
type =
|
||||||
ExposedDropdownMenuAnchorType
|
ExposedDropdownMenuAnchorType
|
||||||
.PrimaryNotEditable,
|
.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true,
|
||||||
)
|
)
|
||||||
.width(120.dp),
|
.width(120.dp),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = { expanded = false }
|
onDismissRequest = { expanded = false },
|
||||||
) {
|
) {
|
||||||
usedSystems.forEach { system ->
|
usedSystems.forEach { system ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -221,13 +224,13 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
DifficultySystem.FONT -> "Font"
|
DifficultySystem.FONT -> "Font"
|
||||||
DifficultySystem.YDS -> "YDS"
|
DifficultySystem.YDS -> "YDS"
|
||||||
DifficultySystem.CUSTOM -> "Custom"
|
DifficultySystem.CUSTOM -> "Custom"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedSystem = system
|
selectedSystem = system
|
||||||
expanded = false
|
expanded = false
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +278,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
BarChartDataPoint(
|
BarChartDataPoint(
|
||||||
label = grade,
|
label = grade,
|
||||||
value = count,
|
value = count,
|
||||||
gradeNumeric = firstDataPoint.gradeNumeric
|
gradeNumeric = firstDataPoint.gradeNumeric,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,19 +295,19 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
DifficultySystem.CUSTOM -> "custom grade"
|
DifficultySystem.CUSTOM -> "custom grade"
|
||||||
}}",
|
}}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().height(220.dp),
|
modifier = Modifier.fillMaxWidth().height(220.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "No data",
|
contentDescription = "No data",
|
||||||
modifier = Modifier.size(48.dp),
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -312,16 +315,18 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
Text(
|
Text(
|
||||||
text = "No data available.",
|
text = "No data available.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
if (showAllTime)
|
if (showAllTime) {
|
||||||
"Complete some climbs to see your grade distribution!"
|
"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,
|
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(
|
||||||
text = "Favorite Gym",
|
text = "Favorite Gym",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -344,14 +349,14 @@ fun FavoriteGymCard(gymName: String, sessionCount: Int) {
|
|||||||
Text(
|
Text(
|
||||||
text = gymName,
|
text = gymName,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (sessionCount > 0) {
|
if (sessionCount > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "$sessionCount sessions",
|
text = "$sessionCount sessions",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +370,7 @@ fun RecentActivityCard(recentSessions: Int) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Recent Activity",
|
text = "Recent Activity",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -377,7 +382,7 @@ fun RecentActivityCard(recentSessions: Int) {
|
|||||||
} else {
|
} else {
|
||||||
"No recent activity"
|
"No recent activity"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,13 +394,13 @@ data class GradeDistributionDataPoint(
|
|||||||
val gradeNumeric: Int,
|
val gradeNumeric: Int,
|
||||||
val count: Int,
|
val count: Int,
|
||||||
val climbType: ClimbType,
|
val climbType: ClimbType,
|
||||||
val difficultySystem: DifficultySystem
|
val difficultySystem: DifficultySystem,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun calculateGradeDistribution(
|
fun calculateGradeDistribution(
|
||||||
sessions: List<com.atridad.ascently.data.model.ClimbSession>,
|
sessions: List<com.atridad.ascently.data.model.ClimbSession>,
|
||||||
problems: List<com.atridad.ascently.data.model.Problem>,
|
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> {
|
): List<GradeDistributionDataPoint> {
|
||||||
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
|
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
@@ -432,11 +437,11 @@ fun calculateGradeDistribution(
|
|||||||
gradeNumeric =
|
gradeNumeric =
|
||||||
gradeToNumeric(
|
gradeToNumeric(
|
||||||
problem.difficulty.system,
|
problem.difficulty.system,
|
||||||
problem.difficulty.grade
|
problem.difficulty.grade,
|
||||||
),
|
),
|
||||||
count = 1,
|
count = 1,
|
||||||
climbType = problem.climbType,
|
climbType = problem.climbType,
|
||||||
difficultySystem = problem.difficulty.system
|
difficultySystem = problem.difficulty.system,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,19 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "Ascently Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Climbing Gyms",
|
text = "Climbing Gyms",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,11 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
|
|||||||
title = "No Gyms Added",
|
title = "No Gyms Added",
|
||||||
message = "Add your favorite climbing gyms to start tracking your progress!",
|
message = "Add your favorite climbing gyms to start tracking your progress!",
|
||||||
onActionClick = {},
|
onActionClick = {},
|
||||||
actionText = ""
|
actionText = "",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(gyms) { gym ->
|
items(gyms, key = { it.id }) { gym ->
|
||||||
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
text = gym.name,
|
text = gym.name,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
gym.location?.let { location ->
|
gym.location?.let { location ->
|
||||||
@@ -77,7 +77,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
text = location,
|
text = location,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = { Text(climbType.displayName) },
|
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 =
|
text =
|
||||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
|
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
text = notes,
|
text = notes,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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) }
|
var selectedGym by remember { mutableStateOf<Gym?>(null) }
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
val filteredProblems =
|
val filteredProblems = remember(problems, selectedClimbType, selectedGym) {
|
||||||
problems.filter { problem ->
|
problems.filter { problem ->
|
||||||
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
|
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
|
||||||
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
|
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
|
||||||
climbTypeMatch && gymMatch
|
climbTypeMatch && gymMatch
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Separate active and inactive problems
|
// Separate active and inactive problems
|
||||||
val activeProblems = filteredProblems.filter { it.isActive }
|
val activeProblems = remember(filteredProblems) { filteredProblems.filter { it.isActive } }
|
||||||
val inactiveProblems = filteredProblems.filter { !it.isActive }
|
val inactiveProblems = remember(filteredProblems) { filteredProblems.filter { !it.isActive } }
|
||||||
val sortedProblems = activeProblems + inactiveProblems
|
val sortedProblems = remember(activeProblems, inactiveProblems) { activeProblems + inactiveProblems }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "Ascently Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Climbing Problems",
|
text = "Climbing Problems",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
Text(
|
Text(
|
||||||
text = "Filters",
|
text = "Filters",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -86,7 +87,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
Text(
|
Text(
|
||||||
text = "Climb Type",
|
text = "Climb Type",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -96,14 +97,14 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = null },
|
onClick = { selectedClimbType = null },
|
||||||
label = { Text("All Types") },
|
label = { Text("All Types") },
|
||||||
selected = selectedClimbType == null
|
selected = selectedClimbType == null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(ClimbType.entries) { climbType ->
|
items(ClimbType.entries) { climbType ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.displayName) },
|
label = { Text(climbType.displayName) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
Text(
|
Text(
|
||||||
text = "Gym",
|
text = "Gym",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -124,14 +125,14 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedGym = null },
|
onClick = { selectedGym = null },
|
||||||
label = { Text("All Gyms") },
|
label = { Text("All Gyms") },
|
||||||
selected = selectedGym == null
|
selected = selectedGym == null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(gyms) { gym ->
|
items(gyms) { gym ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedGym = gym },
|
onClick = { selectedGym = gym },
|
||||||
label = { Text(gym.name) },
|
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 =
|
text =
|
||||||
"Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)",
|
"Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,18 +163,20 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
},
|
},
|
||||||
message =
|
message =
|
||||||
if (problems.isEmpty()) {
|
if (problems.isEmpty()) {
|
||||||
if (gyms.isEmpty())
|
if (gyms.isEmpty()) {
|
||||||
"Add a gym first to start tracking problems and routes!"
|
"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 {
|
} else {
|
||||||
"Try adjusting your filters to see more problems."
|
"Try adjusting your filters to see more problems."
|
||||||
},
|
},
|
||||||
onActionClick = {},
|
onActionClick = {},
|
||||||
actionText = ""
|
actionText = "",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(sortedProblems) { problem ->
|
items(sortedProblems, key = { it.id }) { problem ->
|
||||||
ProblemCard(
|
ProblemCard(
|
||||||
problem = problem,
|
problem = problem,
|
||||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||||
@@ -182,7 +185,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
onToggleActive = {
|
onToggleActive = {
|
||||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||||
viewModel.updateProblem(updatedProblem)
|
viewModel.updateProblem(updatedProblem)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -198,13 +201,15 @@ fun ProblemCard(
|
|||||||
gymName: String,
|
gymName: String,
|
||||||
attempts: List<Attempt>,
|
attempts: List<Attempt>,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggleActive: (() -> Unit)? = null
|
onToggleActive: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val isCompleted =
|
val isCompleted =
|
||||||
attempts.any { attempt ->
|
attempts.any { attempt ->
|
||||||
attempt.problemId == problem.id &&
|
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()) {
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -212,7 +217,7 @@ fun ProblemCard(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -220,8 +225,11 @@ fun ProblemCard(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color =
|
color =
|
||||||
if (problem.isActive) MaterialTheme.colorScheme.onSurface
|
if (problem.isActive) {
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
@@ -229,22 +237,22 @@ fun ProblemCard(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color =
|
color =
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
alpha = if (problem.isActive) 1f else 0.6f
|
alpha = if (problem.isActive) 1f else 0.6f,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (problem.imagePaths.isNotEmpty()) {
|
if (problem.imagePaths.isNotEmpty()) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Image,
|
imageVector = Icons.Default.Image,
|
||||||
contentDescription = "Has images",
|
contentDescription = "Has images",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +261,7 @@ fun ProblemCard(
|
|||||||
imageVector = Icons.Default.CheckCircle,
|
imageVector = Icons.Default.CheckCircle,
|
||||||
contentDescription = "Completed",
|
contentDescription = "Completed",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,14 +269,14 @@ fun ProblemCard(
|
|||||||
text = problem.difficulty.grade,
|
text = problem.difficulty.grade,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = problem.climbType.displayName,
|
text = problem.climbType.displayName,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +286,7 @@ fun ProblemCard(
|
|||||||
Text(
|
Text(
|
||||||
text = "Location: $location",
|
text = "Location: $location",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +297,7 @@ fun ProblemCard(
|
|||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = { Text(tag) },
|
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",
|
text = "Reset / No Longer Set",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,15 +321,17 @@ fun ProblemCard(
|
|||||||
colors =
|
colors =
|
||||||
ButtonDefaults.outlinedButtonColors(
|
ButtonDefaults.outlinedButtonColors(
|
||||||
contentColor =
|
contentColor =
|
||||||
if (problem.isActive)
|
if (problem.isActive) {
|
||||||
MaterialTheme.colorScheme.tertiary
|
MaterialTheme.colorScheme.tertiary
|
||||||
else MaterialTheme.colorScheme.primary
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (problem.isActive) "Mark as Reset" else "Mark as Active",
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.edit
|
||||||
import com.atridad.ascently.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.ascently.data.model.ClimbSession
|
import com.atridad.ascently.data.model.ClimbSession
|
||||||
import com.atridad.ascently.data.model.SessionStatus
|
import com.atridad.ascently.data.model.SessionStatus
|
||||||
@@ -36,11 +37,10 @@ import java.time.YearMonth
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.TextStyle
|
import java.time.format.TextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
enum class ViewMode {
|
enum class ViewMode {
|
||||||
LIST,
|
LIST,
|
||||||
CALENDAR
|
CALENDAR,
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -61,26 +61,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||||
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
|
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
|
||||||
|
|
||||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
val completedSessions = remember(sessions) {
|
||||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
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)) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "Ascently Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Climbing Sessions",
|
text = "Climbing Sessions",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -89,15 +93,18 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
||||||
selectedDate = null
|
selectedDate = null
|
||||||
sharedPreferences.edit { putString("view_mode", viewMode.name) }
|
sharedPreferences.edit { putString("view_mode", viewMode.name) }
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector =
|
||||||
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
|
if (viewMode == ViewMode.LIST) {
|
||||||
else Icons.AutoMirrored.Filled.List,
|
Icons.Default.CalendarMonth
|
||||||
|
} else {
|
||||||
|
Icons.AutoMirrored.Filled.List
|
||||||
|
},
|
||||||
contentDescription =
|
contentDescription =
|
||||||
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
|
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,
|
activeSession = activeSession,
|
||||||
gym = activeSessionGym,
|
gym = activeSessionGym,
|
||||||
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
|
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) {
|
if (activeSession != null) {
|
||||||
@@ -121,22 +128,24 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
EmptyStateMessage(
|
EmptyStateMessage(
|
||||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||||
message =
|
message =
|
||||||
if (gyms.isEmpty())
|
if (gyms.isEmpty()) {
|
||||||
"Add a gym first to start tracking your climbing sessions!"
|
"Add a gym first to start tracking your climbing sessions!"
|
||||||
else "Start your first climbing session!",
|
} else {
|
||||||
|
"Start your first climbing session!"
|
||||||
|
},
|
||||||
onActionClick = {},
|
onActionClick = {},
|
||||||
actionText = ""
|
actionText = "",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
when (viewMode) {
|
when (viewMode) {
|
||||||
ViewMode.LIST -> {
|
ViewMode.LIST -> {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(completedSessions) { session ->
|
items(completedSessions, key = { it.id }) { session ->
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
gymName = gyms.find { it.id == session.gymId }?.name
|
gymName = gyms.find { it.id == session.gymId }?.name
|
||||||
?: "Unknown Gym",
|
?: "Unknown Gym",
|
||||||
onClick = { onNavigateToSessionDetail(session.id) }
|
onClick = { onNavigateToSessionDetail(session.id) },
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -150,7 +159,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
onMonthChange = { selectedMonth = it },
|
onMonthChange = { selectedMonth = it },
|
||||||
selectedDate = selectedDate,
|
selectedDate = selectedDate,
|
||||||
onDateSelected = { selectedDate = it },
|
onDateSelected = { selectedDate = it },
|
||||||
onNavigateToSessionDetail = onNavigateToSessionDetail
|
onNavigateToSessionDetail = onNavigateToSessionDetail,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,24 +176,24 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.CheckCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Warning,
|
Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||||
|
val formattedDate = remember(session.date) {
|
||||||
|
DateFormatUtils.formatDateForDisplay(session.date)
|
||||||
|
}
|
||||||
|
|
||||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = gymName,
|
text = gymName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatDate(session.date),
|
text = formattedDate,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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 ->
|
session.duration?.let { duration ->
|
||||||
Text(
|
Text(
|
||||||
text = "Duration: $duration minutes",
|
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,
|
text = notes,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,18 +287,18 @@ fun EmptyStateMessage(
|
|||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String,
|
||||||
onActionClick: () -> Unit,
|
onActionClick: () -> Unit,
|
||||||
actionText: String
|
actionText: String,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -294,7 +307,7 @@ fun EmptyStateMessage(
|
|||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (actionText.isNotEmpty()) {
|
if (actionText.isNotEmpty()) {
|
||||||
@@ -313,7 +326,7 @@ fun CalendarView(
|
|||||||
onMonthChange: (YearMonth) -> Unit,
|
onMonthChange: (YearMonth) -> Unit,
|
||||||
selectedDate: LocalDate?,
|
selectedDate: LocalDate?,
|
||||||
onDateSelected: (LocalDate?) -> Unit,
|
onDateSelected: (LocalDate?) -> Unit,
|
||||||
onNavigateToSessionDetail: (String) -> Unit
|
onNavigateToSessionDetail: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val sessionsByDate =
|
val sessionsByDate =
|
||||||
remember(sessions) {
|
remember(sessions) {
|
||||||
@@ -343,18 +356,18 @@ fun CalendarView(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
||||||
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
||||||
@@ -364,7 +377,7 @@ fun CalendarView(
|
|||||||
text =
|
text =
|
||||||
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
||||||
@@ -383,14 +396,14 @@ fun CalendarView(
|
|||||||
shape = RoundedCornerShape(50),
|
shape = RoundedCornerShape(50),
|
||||||
colors =
|
colors =
|
||||||
ButtonDefaults.buttonColors(
|
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(
|
||||||
text = "Today",
|
text = "Today",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,7 +419,7 @@ fun CalendarView(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +449,7 @@ fun CalendarView(
|
|||||||
if (sessionsOnDate.isNotEmpty()) {
|
if (sessionsOnDate.isNotEmpty()) {
|
||||||
onDateSelected(if (isSelected) null else date)
|
onDateSelected(if (isSelected) null else date)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.aspectRatio(1f))
|
Spacer(modifier = Modifier.aspectRatio(1f))
|
||||||
@@ -457,7 +470,7 @@ fun CalendarView(
|
|||||||
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +478,7 @@ fun CalendarView(
|
|||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||||
onClick = { onNavigateToSessionDetail(session.id) }
|
onClick = { onNavigateToSessionDetail(session.id) },
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -481,7 +494,7 @@ fun CalendarDay(
|
|||||||
hasSession: Boolean,
|
hasSession: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -493,14 +506,14 @@ fun CalendarDay(
|
|||||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||||
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.clickable(enabled = hasSession, onClick = onClick),
|
.clickable(enabled = hasSession, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = day.toString(),
|
text = day.toString(),
|
||||||
@@ -512,7 +525,7 @@ fun CalendarDay(
|
|||||||
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
|
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasSession) {
|
if (hasSession) {
|
||||||
@@ -521,18 +534,16 @@ fun CalendarDay(
|
|||||||
Modifier.size(6.dp)
|
Modifier.size(6.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (isSelected) MaterialTheme.colorScheme.primary
|
if (isSelected) {
|
||||||
else
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
MaterialTheme.colorScheme.primary.copy(
|
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.components.SyncIndicator
|
||||||
import com.atridad.ascently.ui.health.HealthConnectCard
|
import com.atridad.ascently.ui.health.HealthConnectCard
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -64,7 +64,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
|
|
||||||
// File picker launcher for import - only accepts ZIP files
|
// File picker launcher for import - only accepts ZIP files
|
||||||
val importLauncher =
|
val importLauncher =
|
||||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri
|
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri,
|
||||||
->
|
->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
try {
|
try {
|
||||||
@@ -75,18 +75,20 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
cursor ->
|
cursor ->
|
||||||
val nameIndex =
|
val nameIndex =
|
||||||
cursor.getColumnIndex(
|
cursor.getColumnIndex(
|
||||||
android.provider.OpenableColumns.DISPLAY_NAME
|
android.provider.OpenableColumns.DISPLAY_NAME,
|
||||||
)
|
)
|
||||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||||
cursor.getString(nameIndex)
|
cursor.getString(nameIndex)
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?: "import_file"
|
?: "import_file"
|
||||||
|
|
||||||
// Only allow ZIP files
|
// Only allow ZIP files
|
||||||
if (!fileName.lowercase().endsWith(".zip")) {
|
if (!fileName.lowercase().endsWith(".zip")) {
|
||||||
viewModel.setError(
|
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
|
return@let
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
// File picker launcher for export - ZIP format with images
|
// File picker launcher for export - ZIP format with images
|
||||||
val exportZipLauncher =
|
val exportZipLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/zip")
|
contract = ActivityResultContracts.CreateDocument("application/zip"),
|
||||||
) { uri ->
|
) { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
try {
|
try {
|
||||||
@@ -119,25 +121,25 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "Ascently Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Settings",
|
text = "Settings",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Sync",
|
text = "Sync",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -162,23 +164,27 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
if (isConnected)
|
if (isConnected) {
|
||||||
MaterialTheme.colorScheme
|
MaterialTheme.colorScheme
|
||||||
.primaryContainer.copy(
|
.primaryContainer.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
)
|
||||||
else
|
} else {
|
||||||
MaterialTheme.colorScheme
|
MaterialTheme.colorScheme
|
||||||
.surfaceVariant.copy(
|
.surfaceVariant.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) "Connected to Server"
|
if (isConnected) {
|
||||||
else "Server Configured"
|
"Connected to Server"
|
||||||
|
} else {
|
||||||
|
"Server Configured"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
@@ -193,22 +199,26 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
time
|
time
|
||||||
}
|
}
|
||||||
}",
|
}",
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
if (isConnected) Icons.Default.CloudDone
|
if (isConnected) {
|
||||||
else Icons.Default.Cloud,
|
Icons.Default.CloudDone
|
||||||
|
} else {
|
||||||
|
Icons.Default.Cloud
|
||||||
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint =
|
tint =
|
||||||
if (isConnected)
|
if (isConnected) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
else
|
} else {
|
||||||
MaterialTheme.colorScheme
|
MaterialTheme.colorScheme
|
||||||
.onSurfaceVariant
|
.onSurfaceVariant
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
@@ -218,12 +228,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
viewModel.performManualSync()
|
viewModel.performManualSync()
|
||||||
},
|
},
|
||||||
enabled = isConnected && !isSyncing
|
enabled = isConnected && !isSyncing,
|
||||||
) {
|
) {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Sync")
|
Text("Sync")
|
||||||
@@ -235,7 +245,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text("Configure")
|
Text("Configure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,21 +258,21 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
.copy(alpha = 0.3f)
|
.copy(alpha = 0.3f),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Sync Mode",
|
text = "Sync Mode",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("Auto-sync")
|
Text("Auto-sync")
|
||||||
@@ -272,9 +282,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color =
|
color =
|
||||||
MaterialTheme.colorScheme.onSurface.copy(
|
MaterialTheme.colorScheme.onSurface.copy(
|
||||||
alpha = 0.7f
|
alpha = 0.7f,
|
||||||
),
|
),
|
||||||
maxLines = 2
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
@@ -282,7 +292,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
checked = isAutoSyncEnabled,
|
checked = isAutoSyncEnabled,
|
||||||
onCheckedChange = { enabled ->
|
onCheckedChange = { enabled ->
|
||||||
syncService.setAutoSyncEnabled(enabled)
|
syncService.setAutoSyncEnabled(enabled)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,8 +307,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer
|
MaterialTheme.colorScheme.errorContainer
|
||||||
.copy(alpha = 0.3f)
|
.copy(alpha = 0.3f),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Disconnect") },
|
headlineContent = { Text("Disconnect") },
|
||||||
@@ -307,17 +317,17 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CloudOff,
|
Icons.Default.CloudOff,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
TextButton(onClick = { showDisconnectDialog = true }) {
|
TextButton(onClick = { showDisconnectDialog = true }) {
|
||||||
Text(
|
Text(
|
||||||
"Disconnect",
|
"Disconnect",
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -328,8 +338,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
.copy(alpha = 0.3f)
|
.copy(alpha = 0.3f),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Setup Sync") },
|
headlineContent = { Text("Setup Sync") },
|
||||||
@@ -343,7 +353,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
TextButton(onClick = { showSyncConfigDialog = true }) {
|
TextButton(onClick = { showSyncConfigDialog = true }) {
|
||||||
Text("Setup")
|
Text("Setup")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,23 +366,23 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer
|
MaterialTheme.colorScheme.errorContainer,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Warning,
|
Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,7 +399,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Data Management",
|
text = "Data Management",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -401,15 +411,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Export Data with Images") },
|
headlineContent = { Text("Export Data with Images") },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
"Export all your climbing data and images to ZIP file (recommended)"
|
"Export all your climbing data and images to ZIP file (recommended)",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
@@ -427,18 +437,18 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}.zip"
|
}.zip"
|
||||||
exportZipLauncher.launch(defaultFileName)
|
exportZipLauncher.launch(defaultFileName)
|
||||||
},
|
},
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading,
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Export ZIP")
|
Text("Export ZIP")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,9 +460,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Import Data") },
|
headlineContent = { Text("Import Data") },
|
||||||
@@ -465,18 +475,18 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
trailingContent = {
|
trailingContent = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { importLauncher.launch("application/zip") },
|
onClick = { importLauncher.launch("application/zip") },
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading,
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Import")
|
Text("Import")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,9 +498,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Delete All Images") },
|
headlineContent = { Text("Delete All Images") },
|
||||||
@@ -501,24 +511,24 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { showDeleteImagesDialog = true },
|
onClick = { showDeleteImagesDialog = true },
|
||||||
enabled = !isDeletingImages && !uiState.isLoading
|
enabled = !isDeletingImages && !uiState.isLoading,
|
||||||
) {
|
) {
|
||||||
if (isDeletingImages) {
|
if (isDeletingImages) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,39 +540,39 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Reset All Data") },
|
headlineContent = { Text("Reset All Data") },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
"Permanently delete all gyms, problems, sessions, attempts, and images",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { showResetDialog = true },
|
onClick = { showResetDialog = true },
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading,
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,7 +586,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "App Information",
|
text = "App Information",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -587,16 +597,16 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Version") },
|
headlineContent = { Text("Version") },
|
||||||
supportingContent = { Text(appVersion ?: "Unknown") },
|
supportingContent = { Text(appVersion ?: "Unknown") },
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(Icons.Default.Info, contentDescription = null)
|
Icon(Icons.Default.Info, contentDescription = null)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,24 +631,24 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.CheckCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Warning,
|
Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -689,14 +699,14 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "This will permanently delete:",
|
text = "This will permanently delete:",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data",
|
"• 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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -704,7 +714,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
"This action cannot be undone. Consider exporting your data first.",
|
"This action cannot be undone. Consider exporting your data first.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -713,12 +723,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
viewModel.resetAllData()
|
viewModel.resetAllData()
|
||||||
showResetDialog = false
|
showResetDialog = false
|
||||||
}
|
},
|
||||||
) { Text("Reset All Data", color = MaterialTheme.colorScheme.error) }
|
) { Text("Reset All Data", color = MaterialTheme.colorScheme.error) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showResetDialog = false }) { Text("Cancel") }
|
TextButton(onClick = { showResetDialog = false }) { Text("Cancel") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +745,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
label = { Text("Server URL") },
|
label = { Text("Server URL") },
|
||||||
placeholder = { Text("https://your-server.com") },
|
placeholder = { Text("https://your-server.com") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -746,7 +756,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
label = { Text("Auth Token") },
|
label = { Text("Auth Token") },
|
||||||
placeholder = { Text("your-secret-token") },
|
placeholder = { Text("your-secret-token") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -755,66 +765,66 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
if (isTesting) {
|
if (isTesting) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Testing connection...",
|
text = "Testing connection...",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (isConnected && isConfigured) {
|
} else if (isConnected && isConfigured) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.CheckCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Connection successful",
|
text = "Connection successful",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (syncError != null && isConfigured) {
|
} else if (syncError != null && isConfigured) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Error,
|
Icons.Default.Error,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Connection failed: $syncError",
|
text = "Connection failed: $syncError",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (isConfigured) {
|
} else if (isConfigured) {
|
||||||
Text(
|
Text(
|
||||||
text = "Test connection before enabling sync features",
|
text = "Test connection before enabling sync features",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "Enter your server URL and auth token to enable sync",
|
text = "Enter your server URL and auth token to enable sync",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -835,7 +845,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
showSyncConfigDialog = false
|
showSyncConfigDialog = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
viewModel.setError(
|
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 =
|
enabled =
|
||||||
!isTesting &&
|
!isTesting &&
|
||||||
serverUrl.isNotBlank() &&
|
serverUrl.isNotBlank() &&
|
||||||
authToken.isNotBlank()
|
authToken.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
if (isTesting) {
|
if (isTesting) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Save & Test")
|
Text("Save & Test")
|
||||||
@@ -879,7 +889,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
viewModel.setError(
|
viewModel.setError(
|
||||||
"Connection test failed: ${e.message}"
|
"Connection test failed: ${e.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,7 +897,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
enabled =
|
enabled =
|
||||||
!isTesting &&
|
!isTesting &&
|
||||||
serverUrl.isNotBlank() &&
|
serverUrl.isNotBlank() &&
|
||||||
authToken.isNotBlank()
|
authToken.isNotBlank(),
|
||||||
) { Text("Test Only") }
|
) { Text("Test Only") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -898,9 +908,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
serverUrl = syncService.serverUrl
|
serverUrl = syncService.serverUrl
|
||||||
authToken = syncService.authToken
|
authToken = syncService.authToken
|
||||||
showSyncConfigDialog = false
|
showSyncConfigDialog = false
|
||||||
}
|
},
|
||||||
) { Text("Cancel") }
|
) { Text("Cancel") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,7 +921,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
title = { Text("Disconnect from Sync") },
|
title = { Text("Disconnect from Sync") },
|
||||||
text = {
|
text = {
|
||||||
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 = {
|
confirmButton = {
|
||||||
@@ -919,12 +929,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
viewModel.syncService.clearConfiguration()
|
viewModel.syncService.clearConfiguration()
|
||||||
showDisconnectDialog = false
|
showDisconnectDialog = false
|
||||||
}
|
},
|
||||||
) { Text("Disconnect") }
|
) { Text("Disconnect") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -935,7 +945,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
title = { Text("Delete All Images") },
|
title = { Text("Delete All Images") },
|
||||||
text = {
|
text = {
|
||||||
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 = {
|
confirmButton = {
|
||||||
@@ -948,12 +961,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
isDeletingImages = false
|
isDeletingImages = false
|
||||||
viewModel.setMessage("All images deleted successfully!")
|
viewModel.setMessage("All images deleted successfully!")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
|
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ object CustomIcons {
|
|||||||
defaultWidth = 24.dp,
|
defaultWidth = 24.dp,
|
||||||
defaultHeight = 24.dp,
|
defaultHeight = 24.dp,
|
||||||
viewportWidth = 24f,
|
viewportWidth = 24f,
|
||||||
viewportHeight = 24f
|
viewportHeight = 24f,
|
||||||
).path(
|
).path(
|
||||||
fill = SolidColor(color)
|
fill = SolidColor(color),
|
||||||
) {
|
) {
|
||||||
moveTo(6f, 6f)
|
moveTo(6f, 6f)
|
||||||
horizontalLineTo(18f)
|
horizontalLineTo(18f)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
surfaceContainerLow = ClimbNeutral10,
|
surfaceContainerLow = ClimbNeutral10,
|
||||||
surfaceContainer = ClimbNeutral12,
|
surfaceContainer = ClimbNeutral12,
|
||||||
surfaceContainerHigh = ClimbNeutral17,
|
surfaceContainerHigh = ClimbNeutral17,
|
||||||
surfaceContainerHighest = ClimbNeutral22
|
surfaceContainerHighest = ClimbNeutral22,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Climbing-themed light color scheme with full Material You compatibility
|
// Climbing-themed light color scheme with full Material You compatibility
|
||||||
@@ -84,7 +84,7 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
surfaceContainerLow = ClimbNeutral96,
|
surfaceContainerLow = ClimbNeutral96,
|
||||||
surfaceContainer = ClimbNeutral94,
|
surfaceContainer = ClimbNeutral94,
|
||||||
surfaceContainerHigh = ClimbNeutral92,
|
surfaceContainerHigh = ClimbNeutral92,
|
||||||
surfaceContainerHighest = ClimbNeutral90
|
surfaceContainerHighest = ClimbNeutral90,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -93,7 +93,7 @@ fun AscentlyTheme(
|
|||||||
// Dynamic color is available on Android 12+ and provides full Material You theming
|
// Dynamic color is available on Android 12+ and provides full Material You theming
|
||||||
// When enabled, it adapts to the user's system wallpaper colors
|
// When enabled, it adapts to the user's system wallpaper colors
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && true -> {
|
dynamicColor && true -> {
|
||||||
@@ -116,6 +116,6 @@ fun AscentlyTheme(
|
|||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,6 @@ val Typography = Typography(
|
|||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.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.AppLogger
|
||||||
import com.atridad.ascently.utils.ImageUtils
|
import com.atridad.ascently.utils.ImageUtils
|
||||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class ClimbViewModel(
|
class ClimbViewModel(
|
||||||
private val repository: ClimbRepository,
|
private val repository: ClimbRepository,
|
||||||
val syncService: SyncService,
|
val syncService: SyncService,
|
||||||
private val context: Context
|
private val context: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// Health Connect manager
|
// Health Connect manager
|
||||||
@@ -35,7 +35,7 @@ class ClimbViewModel(
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(),
|
started = SharingStarted.WhileSubscribed(),
|
||||||
initialValue = emptyList()
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val problems =
|
val problems =
|
||||||
@@ -44,7 +44,7 @@ class ClimbViewModel(
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(),
|
started = SharingStarted.WhileSubscribed(),
|
||||||
initialValue = emptyList()
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val sessions =
|
val sessions =
|
||||||
@@ -53,7 +53,7 @@ class ClimbViewModel(
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(),
|
started = SharingStarted.WhileSubscribed(),
|
||||||
initialValue = emptyList()
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val activeSession =
|
val activeSession =
|
||||||
@@ -62,7 +62,7 @@ class ClimbViewModel(
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(),
|
started = SharingStarted.WhileSubscribed(),
|
||||||
initialValue = null
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
val attempts =
|
val attempts =
|
||||||
@@ -71,7 +71,7 @@ class ClimbViewModel(
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(),
|
started = SharingStarted.WhileSubscribed(),
|
||||||
initialValue = emptyList()
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gym operations
|
// Gym operations
|
||||||
@@ -243,7 +243,7 @@ class ClimbViewModel(
|
|||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
error =
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ class ClimbViewModel(
|
|||||||
AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
|
AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_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
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -281,7 +281,7 @@ class ClimbViewModel(
|
|||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
error =
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -354,20 +354,20 @@ class ClimbViewModel(
|
|||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
message = "Creating ZIP file with images..."
|
message = "Creating ZIP file with images...",
|
||||||
)
|
)
|
||||||
repository.exportAllDataToZipUri(context, uri)
|
repository.exportAllDataToZipUri(context, uri)
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
message =
|
message =
|
||||||
"Export complete! Your climbing data and images have been saved."
|
"Export complete! Your climbing data and images have been saved.",
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Export failed: ${e.message}"
|
error = "Export failed: ${e.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ class ClimbViewModel(
|
|||||||
|
|
||||||
if (!file.name.lowercase().endsWith(".zip")) {
|
if (!file.name.lowercase().endsWith(".zip")) {
|
||||||
throw Exception(
|
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 =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
message = "Data imported successfully from ${file.name}"
|
message = "Data imported successfully from ${file.name}",
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Import failed: ${e.message}"
|
error = "Import failed: ${e.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ class ClimbViewModel(
|
|||||||
_uiState.value =
|
_uiState.value =
|
||||||
_uiState.value.copy(
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
message = "All data has been reset successfully"
|
message = "All data has been reset successfully",
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
@@ -469,7 +469,7 @@ class ClimbViewModel(
|
|||||||
healthConnectManager.autoSyncCompletedSession(
|
healthConnectManager.autoSyncCompletedSession(
|
||||||
session,
|
session,
|
||||||
gymName,
|
gymName,
|
||||||
attemptCount
|
attemptCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
result.onFailure { error ->
|
result.onFailure { error ->
|
||||||
@@ -489,5 +489,5 @@ class ClimbViewModel(
|
|||||||
data class ClimbUiState(
|
data class ClimbUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val message: String? = null,
|
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(
|
class ClimbViewModelFactory(
|
||||||
private val repository: ClimbRepository,
|
private val repository: ClimbRepository,
|
||||||
private val syncService: SyncService,
|
private val syncService: SyncService,
|
||||||
private val context: Context
|
private val context: Context,
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ object AppLogger {
|
|||||||
DEBUG(Log.DEBUG),
|
DEBUG(Log.DEBUG),
|
||||||
INFO(Log.INFO),
|
INFO(Log.INFO),
|
||||||
WARN(Log.WARN),
|
WARN(Log.WARN),
|
||||||
ERROR(Log.ERROR)
|
ERROR(Log.ERROR),
|
||||||
}
|
}
|
||||||
|
|
||||||
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
|
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
|
||||||
@@ -34,7 +34,7 @@ object AppLogger {
|
|||||||
level: Level,
|
level: Level,
|
||||||
tag: String,
|
tag: String,
|
||||||
messageProvider: () -> String,
|
messageProvider: () -> String,
|
||||||
throwable: Throwable? = null
|
throwable: Throwable? = null,
|
||||||
) {
|
) {
|
||||||
if (!BuildConfig.DEBUG) return
|
if (!BuildConfig.DEBUG) return
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ object DateFormatUtils {
|
|||||||
return try {
|
return try {
|
||||||
Instant.from(ISO_FORMATTER.parse(dateString))
|
Instant.from(ISO_FORMATTER.parse(dateString))
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Instant.parse(dateString)
|
Instant.parse(dateString)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ object ImageNamingUtils {
|
|||||||
|
|
||||||
/** Generates a deterministic filename for a problem image */
|
/** Generates a deterministic filename for a problem image */
|
||||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||||
val input = "${problemId}_${imageIndex}"
|
val input = "${problemId}_$imageIndex"
|
||||||
val hash = createHash(input)
|
val hash = createHash(input)
|
||||||
|
|
||||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ object ImageUtils {
|
|||||||
context: Context,
|
context: Context,
|
||||||
imageUri: Uri,
|
imageUri: Uri,
|
||||||
originalBitmap: Bitmap,
|
originalBitmap: Bitmap,
|
||||||
outputFile: File
|
outputFile: File,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return try {
|
return try {
|
||||||
// Get EXIF data from original image
|
// Get EXIF data from original image
|
||||||
@@ -147,12 +147,16 @@ object ImageUtils {
|
|||||||
val imagesDir = getImagesDirectory(context)
|
val imagesDir = getImagesDirectory(context)
|
||||||
imagesDir.listFiles()?.mapNotNull { file ->
|
imagesDir.listFiles()?.mapNotNull { file ->
|
||||||
if (file.isFile &&
|
if (file.isFile &&
|
||||||
(file.extension == "jpg" ||
|
(
|
||||||
|
file.extension == "jpg" ||
|
||||||
file.extension == "jpeg" ||
|
file.extension == "jpeg" ||
|
||||||
file.extension == "png")
|
file.extension == "png"
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
"$IMAGES_DIR/${file.name}"
|
"$IMAGES_DIR/${file.name}"
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -187,7 +191,7 @@ object ImageUtils {
|
|||||||
context: Context,
|
context: Context,
|
||||||
tempFilename: String,
|
tempFilename: String,
|
||||||
problemId: String,
|
problemId: String,
|
||||||
imageIndex: Int
|
imageIndex: Int,
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val tempFile = File(getImagesDirectory(context), tempFilename)
|
val tempFile = File(getImagesDirectory(context), tempFilename)
|
||||||
@@ -217,7 +221,7 @@ object ImageUtils {
|
|||||||
fun saveImageFromBytesWithFilename(
|
fun saveImageFromBytesWithFilename(
|
||||||
context: Context,
|
context: Context,
|
||||||
imageData: ByteArray,
|
imageData: ByteArray,
|
||||||
filename: String
|
filename: String,
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class MigrationManager(private val context: Context) {
|
|||||||
"openclimb_data_state" to "ascently_data_state",
|
"openclimb_data_state" to "ascently_data_state",
|
||||||
"health_connect_prefs" to "health_connect_prefs", // Keep same name
|
"health_connect_prefs" to "health_connect_prefs", // Keep same name
|
||||||
"deleted_items" to "deleted_items", // 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) {
|
for ((oldFileName, newFileName) in preferenceFileMigrations) {
|
||||||
@@ -82,7 +82,8 @@ class MigrationManager(private val context: Context) {
|
|||||||
is Float -> putFloat(key, value)
|
is Float -> putFloat(key, value)
|
||||||
is Boolean -> putBoolean(key, value)
|
is Boolean -> putBoolean(key, value)
|
||||||
is Set<*> -> {
|
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",
|
"ascently_data_state",
|
||||||
"health_connect_prefs",
|
"health_connect_prefs",
|
||||||
"deleted_items",
|
"deleted_items",
|
||||||
"sync_preferences"
|
"sync_preferences",
|
||||||
)
|
)
|
||||||
|
|
||||||
for (prefFileName in preferencesToCheck) {
|
for (prefFileName in preferencesToCheck) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ object NotificationPermissionUtils {
|
|||||||
fun isNotificationPermissionGranted(context: Context): Boolean {
|
fun isNotificationPermissionGranted(context: Context): Boolean {
|
||||||
return ContextCompat.checkSelfPermission(
|
return ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ object AppShortcutManager {
|
|||||||
context: Context,
|
context: Context,
|
||||||
hasActiveSession: Boolean,
|
hasActiveSession: Boolean,
|
||||||
hasGyms: 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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
||||||
@@ -45,7 +45,7 @@ object AppShortcutManager {
|
|||||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||||
private fun createStartSessionShortcut(
|
private fun createStartSessionShortcut(
|
||||||
context: Context,
|
context: Context,
|
||||||
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
|
lastUsedGym: com.atridad.ascently.data.model.Gym? = null,
|
||||||
): ShortcutInfo {
|
): ShortcutInfo {
|
||||||
val startIntent =
|
val startIntent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.atridad.ascently.utils
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.atridad.ascently.data.format.BackupProblem
|
import com.atridad.ascently.data.format.BackupProblem
|
||||||
import com.atridad.ascently.data.format.ClimbDataBackup
|
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -11,8 +13,6 @@ import java.time.LocalDateTime
|
|||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
object ZipExportImportUtils {
|
object ZipExportImportUtils {
|
||||||
|
|
||||||
@@ -25,15 +25,15 @@ object ZipExportImportUtils {
|
|||||||
context: Context,
|
context: Context,
|
||||||
exportData: ClimbDataBackup,
|
exportData: ClimbDataBackup,
|
||||||
referencedImagePaths: Set<String>,
|
referencedImagePaths: Set<String>,
|
||||||
directory: File? = null
|
directory: File? = null,
|
||||||
): File {
|
): File {
|
||||||
val exportDir =
|
val exportDir =
|
||||||
directory
|
directory
|
||||||
?: File(
|
?: File(
|
||||||
context.getExternalFilesDir(
|
context.getExternalFilesDir(
|
||||||
android.os.Environment.DIRECTORY_DOCUMENTS
|
android.os.Environment.DIRECTORY_DOCUMENTS,
|
||||||
),
|
),
|
||||||
"Ascently"
|
"Ascently",
|
||||||
)
|
)
|
||||||
if (!exportDir.exists()) {
|
if (!exportDir.exists()) {
|
||||||
exportDir.mkdirs()
|
exportDir.mkdirs()
|
||||||
@@ -92,7 +92,7 @@ object ZipExportImportUtils {
|
|||||||
|
|
||||||
// Log export summary
|
// Log export summary
|
||||||
AppLogger.i("ZipExportImportUtils") {
|
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,
|
context: Context,
|
||||||
uri: android.net.Uri,
|
uri: android.net.Uri,
|
||||||
exportData: ClimbDataBackup,
|
exportData: ClimbDataBackup,
|
||||||
referencedImagePaths: Set<String>
|
referencedImagePaths: Set<String>,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
@@ -164,7 +164,7 @@ object ZipExportImportUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.i("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(
|
private fun createMetadata(
|
||||||
exportData: ClimbDataBackup,
|
exportData: ClimbDataBackup,
|
||||||
referencedImagePaths: Set<String>
|
referencedImagePaths: Set<String>,
|
||||||
): String {
|
): String {
|
||||||
return buildString {
|
return buildString {
|
||||||
appendLine("Ascently Export Metadata")
|
appendLine("Ascently Export Metadata")
|
||||||
@@ -195,7 +195,7 @@ object ZipExportImportUtils {
|
|||||||
/** Data class to hold extraction results */
|
/** Data class to hold extraction results */
|
||||||
data class ImportResult(
|
data class ImportResult(
|
||||||
val jsonContent: String,
|
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 */
|
/** Extracts a ZIP file and returns the JSON content and imported image paths */
|
||||||
@@ -235,7 +235,7 @@ object ZipExportImportUtils {
|
|||||||
File.createTempFile(
|
File.createTempFile(
|
||||||
"import_image_",
|
"import_image_",
|
||||||
"_$originalFilename",
|
"_$originalFilename",
|
||||||
context.cacheDir
|
context.cacheDir,
|
||||||
)
|
)
|
||||||
|
|
||||||
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
|
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
|
||||||
@@ -306,7 +306,7 @@ object ZipExportImportUtils {
|
|||||||
*/
|
*/
|
||||||
fun updateProblemImagePaths(
|
fun updateProblemImagePaths(
|
||||||
problems: List<BackupProblem>,
|
problems: List<BackupProblem>,
|
||||||
imagePathMapping: Map<String, String>
|
imagePathMapping: Map<String, String>,
|
||||||
): List<BackupProblem> {
|
): List<BackupProblem> {
|
||||||
return problems.map { problem ->
|
return problems.map { problem ->
|
||||||
val updatedImagePaths =
|
val updatedImagePaths =
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import com.atridad.ascently.MainActivity
|
|||||||
import com.atridad.ascently.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.ascently.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import java.time.LocalDate
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetIds: IntArray
|
appWidgetIds: IntArray,
|
||||||
) {
|
) {
|
||||||
for (appWidgetId in appWidgetIds) {
|
for (appWidgetId in appWidgetIds) {
|
||||||
updateAppWidget(context, appWidgetManager, appWidgetId)
|
updateAppWidget(context, appWidgetManager, appWidgetId)
|
||||||
@@ -42,7 +42,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
private fun updateAppWidget(
|
private fun updateAppWidget(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetId: Int
|
appWidgetId: Int,
|
||||||
) {
|
) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +96,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
0,
|
0,
|
||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
0,
|
0,
|
||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,74 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#3DDC84"
|
||||||
<!-- Clean white background -->
|
|
||||||
<path android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M0,0h108v108h-108z"/>
|
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>
|
</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.format.*
|
||||||
import com.atridad.ascently.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
class BusinessLogicTests {
|
class BusinessLogicTests {
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ class BusinessLogicTests {
|
|||||||
session.copy(
|
session.copy(
|
||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
endTime = getCurrentTimestamp(),
|
endTime = getCurrentTimestamp(),
|
||||||
duration = 7200L
|
duration = 7200L,
|
||||||
)
|
)
|
||||||
assertEquals(SessionStatus.COMPLETED, completedSession.status)
|
assertEquals(SessionStatus.COMPLETED, completedSession.status)
|
||||||
assertNotNull(completedSession.endTime)
|
assertNotNull(completedSession.endTime)
|
||||||
@@ -42,7 +42,7 @@ class BusinessLogicTests {
|
|||||||
sessionId = session.id,
|
sessionId = session.id,
|
||||||
problemId = problem.id,
|
problemId = problem.id,
|
||||||
result = AttemptResult.SUCCESS,
|
result = AttemptResult.SUCCESS,
|
||||||
notes = "Clean send!"
|
notes = "Clean send!",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(session.id, attempt.sessionId)
|
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.SUCCESS),
|
||||||
Attempt.create(session.id, problem1.id, AttemptResult.FALL),
|
Attempt.create(session.id, problem1.id, AttemptResult.FALL),
|
||||||
Attempt.create(session.id, problem2.id, AttemptResult.FLASH),
|
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)
|
val sessionStats = calculateSessionStatistics(session, attempts)
|
||||||
@@ -101,7 +101,7 @@ class BusinessLogicTests {
|
|||||||
createTestProblemWithGrade(gym.id, "V3"),
|
createTestProblemWithGrade(gym.id, "V3"),
|
||||||
createTestProblemWithGrade(gym.id, "V4"),
|
createTestProblemWithGrade(gym.id, "V4"),
|
||||||
createTestProblemWithGrade(gym.id, "V5"),
|
createTestProblemWithGrade(gym.id, "V5"),
|
||||||
createTestProblemWithGrade(gym.id, "V6")
|
createTestProblemWithGrade(gym.id, "V6"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val attempts =
|
val attempts =
|
||||||
@@ -132,7 +132,7 @@ class BusinessLogicTests {
|
|||||||
gyms = listOf(gym),
|
gyms = listOf(gym),
|
||||||
problems = problems,
|
problems = problems,
|
||||||
sessions = listOf(session),
|
sessions = listOf(session),
|
||||||
attempts = attempts
|
attempts = attempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
validateBackupIntegrity(backup)
|
validateBackupIntegrity(backup)
|
||||||
@@ -155,7 +155,7 @@ class BusinessLogicTests {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = getCurrentTimestamp(),
|
createdAt = getCurrentTimestamp(),
|
||||||
updatedAt = getCurrentTimestamp()
|
updatedAt = getCurrentTimestamp(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val ropeGym =
|
val ropeGym =
|
||||||
@@ -168,7 +168,7 @@ class BusinessLogicTests {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = getCurrentTimestamp(),
|
createdAt = getCurrentTimestamp(),
|
||||||
updatedAt = getCurrentTimestamp()
|
updatedAt = getCurrentTimestamp(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Boulder gym should support boulder problems with V-Scale
|
// Boulder gym should support boulder problems with V-Scale
|
||||||
@@ -202,20 +202,20 @@ class BusinessLogicTests {
|
|||||||
session.id,
|
session.id,
|
||||||
problem.id,
|
problem.id,
|
||||||
"2024-01-01T10:00:00Z",
|
"2024-01-01T10:00:00Z",
|
||||||
AttemptResult.FALL
|
AttemptResult.FALL,
|
||||||
),
|
),
|
||||||
createAttemptWithTimestamp(
|
createAttemptWithTimestamp(
|
||||||
session.id,
|
session.id,
|
||||||
problem.id,
|
problem.id,
|
||||||
"2024-01-01T10:05:00Z",
|
"2024-01-01T10:05:00Z",
|
||||||
AttemptResult.FALL
|
AttemptResult.FALL,
|
||||||
),
|
),
|
||||||
createAttemptWithTimestamp(
|
createAttemptWithTimestamp(
|
||||||
session.id,
|
session.id,
|
||||||
problem.id,
|
problem.id,
|
||||||
"2024-01-01T10:10:00Z",
|
"2024-01-01T10:10:00Z",
|
||||||
AttemptResult.SUCCESS
|
AttemptResult.SUCCESS,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val sequence = AttemptSequence(attempts)
|
val sequence = AttemptSequence(attempts)
|
||||||
@@ -235,27 +235,27 @@ class BusinessLogicTests {
|
|||||||
Pair(ClimbType.BOULDER, DifficultySystem.FONT),
|
Pair(ClimbType.BOULDER, DifficultySystem.FONT),
|
||||||
Pair(ClimbType.ROPE, DifficultySystem.YDS),
|
Pair(ClimbType.ROPE, DifficultySystem.YDS),
|
||||||
Pair(ClimbType.BOULDER, DifficultySystem.CUSTOM),
|
Pair(ClimbType.BOULDER, DifficultySystem.CUSTOM),
|
||||||
Pair(ClimbType.ROPE, DifficultySystem.CUSTOM)
|
Pair(ClimbType.ROPE, DifficultySystem.CUSTOM),
|
||||||
)
|
)
|
||||||
|
|
||||||
val invalidCombinations =
|
val invalidCombinations =
|
||||||
listOf(
|
listOf(
|
||||||
Pair(ClimbType.BOULDER, DifficultySystem.YDS),
|
Pair(ClimbType.BOULDER, DifficultySystem.YDS),
|
||||||
Pair(ClimbType.ROPE, DifficultySystem.V_SCALE),
|
Pair(ClimbType.ROPE, DifficultySystem.V_SCALE),
|
||||||
Pair(ClimbType.ROPE, DifficultySystem.FONT)
|
Pair(ClimbType.ROPE, DifficultySystem.FONT),
|
||||||
)
|
)
|
||||||
|
|
||||||
validCombinations.forEach { (climbType, difficultySystem) ->
|
validCombinations.forEach { (climbType, difficultySystem) ->
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"$climbType should be compatible with $difficultySystem",
|
"$climbType should be compatible with $difficultySystem",
|
||||||
isValidGradeCombination(climbType, difficultySystem)
|
isValidGradeCombination(climbType, difficultySystem),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidCombinations.forEach { (climbType, difficultySystem) ->
|
invalidCombinations.forEach { (climbType, difficultySystem) ->
|
||||||
assertFalse(
|
assertFalse(
|
||||||
"$climbType should not be compatible with $difficultySystem",
|
"$climbType should not be compatible with $difficultySystem",
|
||||||
isValidGradeCombination(climbType, difficultySystem)
|
isValidGradeCombination(climbType, difficultySystem),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ class BusinessLogicTests {
|
|||||||
listOf(
|
listOf(
|
||||||
"/storage/images/problem1.jpg",
|
"/storage/images/problem1.jpg",
|
||||||
"/data/cache/problem2.png",
|
"/data/cache/problem2.png",
|
||||||
"relative/path/problem3.jpeg"
|
"relative/path/problem3.jpeg",
|
||||||
)
|
)
|
||||||
|
|
||||||
val relativePaths = convertToRelativePaths(originalPaths)
|
val relativePaths = convertToRelativePaths(originalPaths)
|
||||||
@@ -303,13 +303,13 @@ class BusinessLogicTests {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = "Test gym for unit testing",
|
notes = "Test gym for unit testing",
|
||||||
createdAt = getCurrentTimestamp(),
|
createdAt = getCurrentTimestamp(),
|
||||||
updatedAt = getCurrentTimestamp()
|
updatedAt = getCurrentTimestamp(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTestProblem(
|
private fun createTestProblem(
|
||||||
gymId: String,
|
gymId: String,
|
||||||
climbType: ClimbType = ClimbType.BOULDER
|
climbType: ClimbType = ClimbType.BOULDER,
|
||||||
): Problem {
|
): Problem {
|
||||||
val difficulty =
|
val difficulty =
|
||||||
when (climbType) {
|
when (climbType) {
|
||||||
@@ -331,7 +331,7 @@ class BusinessLogicTests {
|
|||||||
dateSet = "2024-01-01",
|
dateSet = "2024-01-01",
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = getCurrentTimestamp(),
|
createdAt = getCurrentTimestamp(),
|
||||||
updatedAt = getCurrentTimestamp()
|
updatedAt = getCurrentTimestamp(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ class BusinessLogicTests {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = getCurrentTimestamp(),
|
createdAt = getCurrentTimestamp(),
|
||||||
updatedAt = getCurrentTimestamp()
|
updatedAt = getCurrentTimestamp(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,13 +358,13 @@ class BusinessLogicTests {
|
|||||||
sessionId: String,
|
sessionId: String,
|
||||||
problemId: String,
|
problemId: String,
|
||||||
timestamp: String,
|
timestamp: String,
|
||||||
result: AttemptResult
|
result: AttemptResult,
|
||||||
): Attempt {
|
): Attempt {
|
||||||
return Attempt.create(
|
return Attempt.create(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
problemId = problemId,
|
problemId = problemId,
|
||||||
result = result,
|
result = result,
|
||||||
timestamp = timestamp
|
timestamp = timestamp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ class BusinessLogicTests {
|
|||||||
|
|
||||||
private fun calculateSessionStatistics(
|
private fun calculateSessionStatistics(
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
attempts: List<Attempt>
|
attempts: List<Attempt>,
|
||||||
): SessionStatistics {
|
): SessionStatistics {
|
||||||
val successful =
|
val successful =
|
||||||
attempts.count {
|
attempts.count {
|
||||||
@@ -387,13 +387,13 @@ class BusinessLogicTests {
|
|||||||
totalAttempts = attempts.size,
|
totalAttempts = attempts.size,
|
||||||
successfulAttempts = successful,
|
successfulAttempts = successful,
|
||||||
uniqueProblems = uniqueProblems,
|
uniqueProblems = uniqueProblems,
|
||||||
successRate = successRate
|
successRate = successRate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateDifficultyProgression(
|
private fun calculateDifficultyProgression(
|
||||||
attempts: List<Attempt>,
|
attempts: List<Attempt>,
|
||||||
problems: List<Problem>
|
problems: List<Problem>,
|
||||||
): DifficultyProgression {
|
): DifficultyProgression {
|
||||||
val problemMap = problems.associateBy { it.id }
|
val problemMap = problems.associateBy { it.id }
|
||||||
val grades =
|
val grades =
|
||||||
@@ -423,7 +423,7 @@ class BusinessLogicTests {
|
|||||||
gyms: List<Gym>,
|
gyms: List<Gym>,
|
||||||
problems: List<Problem>,
|
problems: List<Problem>,
|
||||||
sessions: List<ClimbSession>,
|
sessions: List<ClimbSession>,
|
||||||
attempts: List<Attempt>
|
attempts: List<Attempt>,
|
||||||
): ClimbDataBackup {
|
): ClimbDataBackup {
|
||||||
return ClimbDataBackup(
|
return ClimbDataBackup(
|
||||||
exportedAt = getCurrentTimestamp(),
|
exportedAt = getCurrentTimestamp(),
|
||||||
@@ -440,7 +440,7 @@ class BusinessLogicTests {
|
|||||||
customDifficultyGrades = gym.customDifficultyGrades,
|
customDifficultyGrades = gym.customDifficultyGrades,
|
||||||
notes = gym.notes,
|
notes = gym.notes,
|
||||||
createdAt = gym.createdAt,
|
createdAt = gym.createdAt,
|
||||||
updatedAt = gym.updatedAt
|
updatedAt = gym.updatedAt,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
problems =
|
problems =
|
||||||
@@ -459,7 +459,7 @@ class BusinessLogicTests {
|
|||||||
dateSet = problem.dateSet,
|
dateSet = problem.dateSet,
|
||||||
notes = problem.notes,
|
notes = problem.notes,
|
||||||
createdAt = problem.createdAt,
|
createdAt = problem.createdAt,
|
||||||
updatedAt = problem.updatedAt
|
updatedAt = problem.updatedAt,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
sessions =
|
sessions =
|
||||||
@@ -474,7 +474,7 @@ class BusinessLogicTests {
|
|||||||
status = session.status,
|
status = session.status,
|
||||||
notes = session.notes,
|
notes = session.notes,
|
||||||
createdAt = session.createdAt,
|
createdAt = session.createdAt,
|
||||||
updatedAt = session.updatedAt
|
updatedAt = session.updatedAt,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
attempts =
|
attempts =
|
||||||
@@ -492,7 +492,7 @@ class BusinessLogicTests {
|
|||||||
createdAt = attempt.createdAt,
|
createdAt = attempt.createdAt,
|
||||||
updatedAt = attempt.updatedAt,
|
updatedAt = attempt.updatedAt,
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@ class BusinessLogicTests {
|
|||||||
backup.problems.forEach { problem ->
|
backup.problems.forEach { problem ->
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Problem ${problem.id} references non-existent gym ${problem.gymId}",
|
"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 ->
|
backup.attempts.forEach { attempt ->
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Attempt ${attempt.id} references non-existent session ${attempt.sessionId}",
|
"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 ->
|
backup.attempts.forEach { attempt ->
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Attempt ${attempt.id} references non-existent problem ${attempt.problemId}",
|
"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(
|
private fun isCompatibleClimbType(
|
||||||
gym: Gym,
|
gym: Gym,
|
||||||
climbType: ClimbType,
|
climbType: ClimbType,
|
||||||
difficultySystem: DifficultySystem
|
difficultySystem: DifficultySystem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return gym.supportedClimbTypes.contains(climbType) &&
|
return gym.supportedClimbTypes.contains(climbType) &&
|
||||||
gym.difficultySystems.contains(difficultySystem)
|
gym.difficultySystems.contains(difficultySystem)
|
||||||
@@ -542,7 +542,7 @@ class BusinessLogicTests {
|
|||||||
|
|
||||||
private fun isValidGradeCombination(
|
private fun isValidGradeCombination(
|
||||||
climbType: ClimbType,
|
climbType: ClimbType,
|
||||||
difficultySystem: DifficultySystem
|
difficultySystem: DifficultySystem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return when (climbType) {
|
return when (climbType) {
|
||||||
ClimbType.BOULDER ->
|
ClimbType.BOULDER ->
|
||||||
@@ -550,7 +550,7 @@ class BusinessLogicTests {
|
|||||||
listOf(
|
listOf(
|
||||||
DifficultySystem.V_SCALE,
|
DifficultySystem.V_SCALE,
|
||||||
DifficultySystem.FONT,
|
DifficultySystem.FONT,
|
||||||
DifficultySystem.CUSTOM
|
DifficultySystem.CUSTOM,
|
||||||
)
|
)
|
||||||
ClimbType.ROPE ->
|
ClimbType.ROPE ->
|
||||||
difficultySystem in listOf(DifficultySystem.YDS, DifficultySystem.CUSTOM)
|
difficultySystem in listOf(DifficultySystem.YDS, DifficultySystem.CUSTOM)
|
||||||
@@ -571,14 +571,14 @@ class BusinessLogicTests {
|
|||||||
val totalAttempts: Int,
|
val totalAttempts: Int,
|
||||||
val successfulAttempts: Int,
|
val successfulAttempts: Int,
|
||||||
val uniqueProblems: Int,
|
val uniqueProblems: Int,
|
||||||
val successRate: Double
|
val successRate: Double,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DifficultyProgression(
|
data class DifficultyProgression(
|
||||||
val minGrade: String,
|
val minGrade: String,
|
||||||
val maxGrade: String,
|
val maxGrade: String,
|
||||||
val averageGrade: Double,
|
val averageGrade: Double,
|
||||||
val showsProgression: Boolean
|
val showsProgression: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AttemptSequence(val attempts: List<Attempt>) {
|
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.format.*
|
||||||
import com.atridad.ascently.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import java.time.Instant
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
class DataModelTests {
|
class DataModelTests {
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ class DataModelTests {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = "Great gym for beginners",
|
notes = "Great gym for beginners",
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("gym123", gym.id)
|
assertEquals("gym123", gym.id)
|
||||||
@@ -181,7 +181,7 @@ class DataModelTests {
|
|||||||
dateSet = "2024-01-01",
|
dateSet = "2024-01-01",
|
||||||
notes = "Watch the start holds",
|
notes = "Watch the start holds",
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("problem123", problem.id)
|
assertEquals("problem123", problem.id)
|
||||||
@@ -190,7 +190,7 @@ class DataModelTests {
|
|||||||
assertEquals(ClimbType.BOULDER, problem.climbType)
|
assertEquals(ClimbType.BOULDER, problem.climbType)
|
||||||
assertEquals("V5", problem.difficulty.grade)
|
assertEquals("V5", problem.difficulty.grade)
|
||||||
assertTrue(problem.isActive)
|
assertTrue(problem.isActive)
|
||||||
assertEquals(2, problem.tags.size)
|
assertEquals(2, problem.tags?.size ?: 0)
|
||||||
assertEquals(2, problem.imagePaths?.size ?: 0)
|
assertEquals(2, problem.imagePaths?.size ?: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ class DataModelTests {
|
|||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
notes = "Great session today",
|
notes = "Great session today",
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T12:00:00Z"
|
updatedAt = "2024-01-01T12:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("session123", session.id)
|
assertEquals("session123", session.id)
|
||||||
@@ -231,7 +231,7 @@ class DataModelTests {
|
|||||||
restTime = 120,
|
restTime = 120,
|
||||||
timestamp = "2024-01-01T10:30:00Z",
|
timestamp = "2024-01-01T10:30:00Z",
|
||||||
createdAt = "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)
|
assertEquals("attempt123", attempt.id)
|
||||||
@@ -253,7 +253,7 @@ class DataModelTests {
|
|||||||
gyms = emptyList(),
|
gyms = emptyList(),
|
||||||
problems = emptyList(),
|
problems = emptyList(),
|
||||||
sessions = emptyList(),
|
sessions = emptyList(),
|
||||||
attempts = emptyList()
|
attempts = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("2.0", backup.version)
|
assertEquals("2.0", backup.version)
|
||||||
@@ -290,7 +290,7 @@ class DataModelTests {
|
|||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T12:00:00Z"
|
updatedAt = "2024-01-01T12:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(7200L, session.duration)
|
assertEquals(7200L, session.duration)
|
||||||
@@ -310,12 +310,12 @@ class DataModelTests {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertTrue(gym.supportedClimbTypes.isEmpty())
|
assertTrue(gym.supportedClimbTypes?.isEmpty() ?: true)
|
||||||
assertTrue(gym.difficultySystems.isEmpty())
|
assertTrue(gym.difficultySystems?.isEmpty() ?: true)
|
||||||
assertTrue(gym.customDifficultyGrades.isEmpty())
|
assertTrue(gym.customDifficultyGrades?.isEmpty() ?: true)
|
||||||
assertNull(gym.location)
|
assertNull(gym.location)
|
||||||
assertNull(gym.notes)
|
assertNull(gym.notes)
|
||||||
}
|
}
|
||||||
@@ -337,7 +337,7 @@ class DataModelTests {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertNull(problem.name)
|
assertNull(problem.name)
|
||||||
@@ -345,7 +345,7 @@ class DataModelTests {
|
|||||||
assertNull(problem.location)
|
assertNull(problem.location)
|
||||||
assertNull(problem.dateSet)
|
assertNull(problem.dateSet)
|
||||||
assertNull(problem.notes)
|
assertNull(problem.notes)
|
||||||
assertTrue(problem.tags.isEmpty())
|
assertTrue(problem.tags?.isEmpty() ?: true)
|
||||||
assertNull(problem.imagePaths)
|
assertNull(problem.imagePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ class DataModelTests {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(ClimbType.BOULDER, boulderProblem.climbType)
|
assertEquals(ClimbType.BOULDER, boulderProblem.climbType)
|
||||||
@@ -433,7 +433,7 @@ class DataModelTests {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(ClimbType.ROPE, ropeProblem.climbType)
|
assertEquals(ClimbType.ROPE, ropeProblem.climbType)
|
||||||
@@ -477,7 +477,7 @@ class DataModelTests {
|
|||||||
AttemptResult.SUCCESS,
|
AttemptResult.SUCCESS,
|
||||||
AttemptResult.FALL,
|
AttemptResult.FALL,
|
||||||
AttemptResult.NO_PROGRESS,
|
AttemptResult.NO_PROGRESS,
|
||||||
AttemptResult.FLASH
|
AttemptResult.FLASH,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(4, validResults.size)
|
assertEquals(4, validResults.size)
|
||||||
@@ -510,7 +510,7 @@ class DataModelTests {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
val problem =
|
val problem =
|
||||||
@@ -528,7 +528,7 @@ class DataModelTests {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T10:00:00Z"
|
updatedAt = "2024-01-01T10:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
val session =
|
val session =
|
||||||
@@ -542,7 +542,7 @@ class DataModelTests {
|
|||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00Z",
|
createdAt = "2024-01-01T10:00:00Z",
|
||||||
updatedAt = "2024-01-01T11:00:00Z"
|
updatedAt = "2024-01-01T11:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
val attempt =
|
val attempt =
|
||||||
@@ -557,7 +557,7 @@ class DataModelTests {
|
|||||||
restTime = null,
|
restTime = null,
|
||||||
timestamp = "2024-01-01T10:30:00Z",
|
timestamp = "2024-01-01T10:30:00Z",
|
||||||
createdAt = "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
|
// Verify referential integrity
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ class SyncMergeLogicTest {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00",
|
createdAt = "2024-01-01T10:00:00",
|
||||||
updatedAt = "2024-01-01T10:00:00"
|
updatedAt = "2024-01-01T10:00:00",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val localProblems =
|
val localProblems =
|
||||||
@@ -41,8 +41,8 @@ class SyncMergeLogicTest {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00",
|
createdAt = "2024-01-01T10:00:00",
|
||||||
updatedAt = "2024-01-01T10:00:00"
|
updatedAt = "2024-01-01T10:00:00",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val localSessions =
|
val localSessions =
|
||||||
@@ -57,8 +57,8 @@ class SyncMergeLogicTest {
|
|||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00",
|
createdAt = "2024-01-01T10:00:00",
|
||||||
updatedAt = "2024-01-01T10:00:00"
|
updatedAt = "2024-01-01T10:00:00",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val localAttempts =
|
val localAttempts =
|
||||||
@@ -74,8 +74,8 @@ class SyncMergeLogicTest {
|
|||||||
restTime = null,
|
restTime = null,
|
||||||
timestamp = "2024-01-01T10:30:00",
|
timestamp = "2024-01-01T10:30:00",
|
||||||
createdAt = "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 =
|
val localBackup =
|
||||||
@@ -86,7 +86,7 @@ class SyncMergeLogicTest {
|
|||||||
gyms = localGyms,
|
gyms = localGyms,
|
||||||
problems = localProblems,
|
problems = localProblems,
|
||||||
sessions = localSessions,
|
sessions = localSessions,
|
||||||
attempts = localAttempts
|
attempts = localAttempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create server data with some overlapping and some unique data
|
// Create server data with some overlapping and some unique data
|
||||||
@@ -103,7 +103,7 @@ class SyncMergeLogicTest {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = "Updated notes",
|
notes = "Updated notes",
|
||||||
createdAt = "2024-01-01T10:00:00",
|
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
|
// Unique server gym
|
||||||
BackupGym(
|
BackupGym(
|
||||||
@@ -115,8 +115,8 @@ class SyncMergeLogicTest {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T11:00:00",
|
createdAt = "2024-01-01T11:00:00",
|
||||||
updatedAt = "2024-01-01T11:00:00"
|
updatedAt = "2024-01-01T11:00:00",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val serverProblems =
|
val serverProblems =
|
||||||
@@ -136,7 +136,7 @@ class SyncMergeLogicTest {
|
|||||||
dateSet = "2024-01-01",
|
dateSet = "2024-01-01",
|
||||||
notes = "Updated notes",
|
notes = "Updated notes",
|
||||||
createdAt = "2024-01-01T10:00:00",
|
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
|
// Unique server problem
|
||||||
BackupProblem(
|
BackupProblem(
|
||||||
@@ -153,8 +153,8 @@ class SyncMergeLogicTest {
|
|||||||
dateSet = null,
|
dateSet = null,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T11:00:00",
|
createdAt = "2024-01-01T11:00:00",
|
||||||
updatedAt = "2024-01-01T11:00:00"
|
updatedAt = "2024-01-01T11:00:00",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val serverSessions =
|
val serverSessions =
|
||||||
@@ -170,8 +170,8 @@ class SyncMergeLogicTest {
|
|||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
notes = "Server session",
|
notes = "Server session",
|
||||||
createdAt = "2024-01-02T14:00:00",
|
createdAt = "2024-01-02T14:00:00",
|
||||||
updatedAt = "2024-01-02T14:00:00"
|
updatedAt = "2024-01-02T14:00:00",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val serverAttempts =
|
val serverAttempts =
|
||||||
@@ -188,8 +188,8 @@ class SyncMergeLogicTest {
|
|||||||
restTime = 60,
|
restTime = 60,
|
||||||
timestamp = "2024-01-02T14:30:00",
|
timestamp = "2024-01-02T14:30:00",
|
||||||
createdAt = "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 =
|
val serverBackup =
|
||||||
@@ -200,7 +200,7 @@ class SyncMergeLogicTest {
|
|||||||
gyms = serverGyms,
|
gyms = serverGyms,
|
||||||
problems = serverProblems,
|
problems = serverProblems,
|
||||||
sessions = serverSessions,
|
sessions = serverSessions,
|
||||||
attempts = serverAttempts
|
attempts = serverAttempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Simulate merge logic
|
// Simulate merge logic
|
||||||
@@ -232,11 +232,11 @@ class SyncMergeLogicTest {
|
|||||||
// Images should be merged (both local and server images preserved)
|
// Images should be merged (both local and server images preserved)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Should contain local image",
|
"Should contain local image",
|
||||||
mergedProblem1.imagePaths!!.contains("local_image.jpg")
|
mergedProblem1.imagePaths!!.contains("local_image.jpg"),
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Should contain server image",
|
"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)
|
assertEquals("Should have 2 images total", 2, mergedProblem1.imagePaths!!.size)
|
||||||
|
|
||||||
@@ -247,21 +247,21 @@ class SyncMergeLogicTest {
|
|||||||
// Verify all sessions are preserved
|
// Verify all sessions are preserved
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Should contain local session",
|
"Should contain local session",
|
||||||
mergedBackup.sessions.any { it.id == "session1" }
|
mergedBackup.sessions.any { it.id == "session1" },
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Should contain server session",
|
"Should contain server session",
|
||||||
mergedBackup.sessions.any { it.id == "session2" }
|
mergedBackup.sessions.any { it.id == "session2" },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verify all attempts are preserved
|
// Verify all attempts are preserved
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Should contain local attempt",
|
"Should contain local attempt",
|
||||||
mergedBackup.attempts.any { it.id == "attempt1" }
|
mergedBackup.attempts.any { it.id == "attempt1" },
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Should contain server attempt",
|
"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`() {
|
fun `test date comparison logic`() {
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"ISO instant should be newer",
|
"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(
|
assertFalse(
|
||||||
"ISO instant should be older",
|
"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(
|
assertTrue(
|
||||||
"String comparison should work as fallback",
|
"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(),
|
gyms = emptyList(),
|
||||||
problems = emptyList(),
|
problems = emptyList(),
|
||||||
sessions = emptyList(),
|
sessions = emptyList(),
|
||||||
attempts = emptyList()
|
attempts = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val dataBackup =
|
val dataBackup =
|
||||||
@@ -311,12 +311,12 @@ class SyncMergeLogicTest {
|
|||||||
customDifficultyGrades = emptyList(),
|
customDifficultyGrades = emptyList(),
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00",
|
createdAt = "2024-01-01T10:00:00",
|
||||||
updatedAt = "2024-01-01T10:00:00"
|
updatedAt = "2024-01-01T10:00:00",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
problems = emptyList(),
|
problems = emptyList(),
|
||||||
sessions = emptyList(),
|
sessions = emptyList(),
|
||||||
attempts = emptyList()
|
attempts = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Test merging empty with data
|
// Test merging empty with data
|
||||||
@@ -335,7 +335,7 @@ class SyncMergeLogicTest {
|
|||||||
// Helper methods that simulate the merge logic from SyncService
|
// Helper methods that simulate the merge logic from SyncService
|
||||||
private fun performIntelligentMerge(
|
private fun performIntelligentMerge(
|
||||||
local: ClimbDataBackup,
|
local: ClimbDataBackup,
|
||||||
server: ClimbDataBackup
|
server: ClimbDataBackup,
|
||||||
): ClimbDataBackup {
|
): ClimbDataBackup {
|
||||||
val mergedGyms = mergeGyms(local.gyms, server.gyms)
|
val mergedGyms = mergeGyms(local.gyms, server.gyms)
|
||||||
val mergedProblems = mergeProblems(local.problems, server.problems)
|
val mergedProblems = mergeProblems(local.problems, server.problems)
|
||||||
@@ -349,7 +349,7 @@ class SyncMergeLogicTest {
|
|||||||
gyms = mergedGyms,
|
gyms = mergedGyms,
|
||||||
problems = mergedProblems,
|
problems = mergedProblems,
|
||||||
sessions = mergedSessions,
|
sessions = mergedSessions,
|
||||||
attempts = mergedAttempts
|
attempts = mergedAttempts,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ class SyncMergeLogicTest {
|
|||||||
|
|
||||||
private fun mergeProblems(
|
private fun mergeProblems(
|
||||||
local: List<BackupProblem>,
|
local: List<BackupProblem>,
|
||||||
server: List<BackupProblem>
|
server: List<BackupProblem>,
|
||||||
): List<BackupProblem> {
|
): List<BackupProblem> {
|
||||||
val merged = mutableMapOf<String, BackupProblem>()
|
val merged = mutableMapOf<String, BackupProblem>()
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ class SyncMergeLogicTest {
|
|||||||
|
|
||||||
private fun mergeSessions(
|
private fun mergeSessions(
|
||||||
local: List<BackupClimbSession>,
|
local: List<BackupClimbSession>,
|
||||||
server: List<BackupClimbSession>
|
server: List<BackupClimbSession>,
|
||||||
): List<BackupClimbSession> {
|
): List<BackupClimbSession> {
|
||||||
val merged = mutableMapOf<String, BackupClimbSession>()
|
val merged = mutableMapOf<String, BackupClimbSession>()
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ class SyncMergeLogicTest {
|
|||||||
|
|
||||||
private fun mergeAttempts(
|
private fun mergeAttempts(
|
||||||
local: List<BackupAttempt>,
|
local: List<BackupAttempt>,
|
||||||
server: List<BackupAttempt>
|
server: List<BackupAttempt>,
|
||||||
): List<BackupAttempt> {
|
): List<BackupAttempt> {
|
||||||
val merged = mutableMapOf<String, BackupAttempt>()
|
val merged = mutableMapOf<String, BackupAttempt>()
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ class SyncMergeLogicTest {
|
|||||||
if (localAttempt == null ||
|
if (localAttempt == null ||
|
||||||
isNewerThan(
|
isNewerThan(
|
||||||
serverAttempt.updatedAt ?: serverAttempt.createdAt,
|
serverAttempt.updatedAt ?: serverAttempt.createdAt,
|
||||||
localAttempt.updatedAt ?: localAttempt.createdAt
|
localAttempt.updatedAt ?: localAttempt.createdAt,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
merged[serverAttempt.id] = serverAttempt
|
merged[serverAttempt.id] = serverAttempt
|
||||||
@@ -469,7 +469,7 @@ class SyncMergeLogicTest {
|
|||||||
status = SessionStatus.ACTIVE,
|
status = SessionStatus.ACTIVE,
|
||||||
notes = null,
|
notes = null,
|
||||||
createdAt = "2024-01-01T10:00:00",
|
createdAt = "2024-01-01T10:00:00",
|
||||||
updatedAt = "2024-01-01T10:00:00"
|
updatedAt = "2024-01-01T10:00:00",
|
||||||
),
|
),
|
||||||
BackupClimbSession(
|
BackupClimbSession(
|
||||||
id = "completed_session_1",
|
id = "completed_session_1",
|
||||||
@@ -481,8 +481,8 @@ class SyncMergeLogicTest {
|
|||||||
status = SessionStatus.COMPLETED,
|
status = SessionStatus.COMPLETED,
|
||||||
notes = "Previous session",
|
notes = "Previous session",
|
||||||
createdAt = "2023-12-31T15:00:00",
|
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
|
// Simulate filtering that would happen in createBackupFromRepository
|
||||||
@@ -496,7 +496,7 @@ class SyncMergeLogicTest {
|
|||||||
"Should not contain active session in sync",
|
"Should not contain active session in sync",
|
||||||
sessionsForSync.any {
|
sessionsForSync.any {
|
||||||
it.id == "active_session_1" && it.status == SessionStatus.ACTIVE
|
it.id == "active_session_1" && it.status == SessionStatus.ACTIVE
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Completed session should be included
|
// Completed session should be included
|
||||||
@@ -504,7 +504,7 @@ class SyncMergeLogicTest {
|
|||||||
"Should contain completed session in sync",
|
"Should contain completed session in sync",
|
||||||
sessionsForSync.any {
|
sessionsForSync.any {
|
||||||
it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED
|
it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.atridad.ascently
|
package com.atridad.ascently
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class UtilityTests {
|
class UtilityTests {
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class UtilityTests {
|
|||||||
AttemptData("FALL", 90),
|
AttemptData("FALL", 90),
|
||||||
AttemptData("SUCCESS", 150),
|
AttemptData("SUCCESS", 150),
|
||||||
AttemptData("FLASH", 60),
|
AttemptData("FLASH", 60),
|
||||||
AttemptData("FALL", 110)
|
AttemptData("FALL", 110),
|
||||||
)
|
)
|
||||||
|
|
||||||
val stats = calculateAttemptStatistics(attempts)
|
val stats = calculateAttemptStatistics(attempts)
|
||||||
@@ -169,7 +169,7 @@ class UtilityTests {
|
|||||||
"Crimpy Problem",
|
"Crimpy Problem",
|
||||||
"BOULDER",
|
"BOULDER",
|
||||||
"V5",
|
"V5",
|
||||||
listOf("crimpy", "overhang")
|
listOf("crimpy", "overhang"),
|
||||||
),
|
),
|
||||||
ProblemData("id2", "Easy Route", "ROPE", "5.6", listOf("beginner", "slab")),
|
ProblemData("id2", "Easy Route", "ROPE", "5.6", listOf("beginner", "slab")),
|
||||||
ProblemData(
|
ProblemData(
|
||||||
@@ -177,8 +177,8 @@ class UtilityTests {
|
|||||||
"Hard Boulder",
|
"Hard Boulder",
|
||||||
"BOULDER",
|
"BOULDER",
|
||||||
"V10",
|
"V10",
|
||||||
listOf("powerful", "roof")
|
listOf("powerful", "roof"),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val boulderProblems = filterByClimbType(problems, "BOULDER")
|
val boulderProblems = filterByClimbType(problems, "BOULDER")
|
||||||
@@ -211,7 +211,7 @@ class UtilityTests {
|
|||||||
version = "2.0",
|
version = "2.0",
|
||||||
formatVersion = "2.0",
|
formatVersion = "2.0",
|
||||||
exportedAt = "2024-01-01T10:00:00Z",
|
exportedAt = "2024-01-01T10:00:00Z",
|
||||||
dataCount = 5
|
dataCount = 5,
|
||||||
)
|
)
|
||||||
|
|
||||||
val invalidBackup =
|
val invalidBackup =
|
||||||
@@ -219,7 +219,7 @@ class UtilityTests {
|
|||||||
version = "1.0",
|
version = "1.0",
|
||||||
formatVersion = "2.0",
|
formatVersion = "2.0",
|
||||||
exportedAt = "invalid-date",
|
exportedAt = "invalid-date",
|
||||||
dataCount = -1
|
dataCount = -1,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertTrue(isValidBackup(validBackup))
|
assertTrue(isValidBackup(validBackup))
|
||||||
@@ -261,7 +261,7 @@ class UtilityTests {
|
|||||||
totalAttempts = attempts.size,
|
totalAttempts = attempts.size,
|
||||||
successfulAttempts = successful,
|
successfulAttempts = successful,
|
||||||
successRate = successRate,
|
successRate = successRate,
|
||||||
averageDuration = avgDuration
|
averageDuration = avgDuration,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ class UtilityTests {
|
|||||||
|
|
||||||
private fun filterByClimbType(
|
private fun filterByClimbType(
|
||||||
problems: List<ProblemData>,
|
problems: List<ProblemData>,
|
||||||
climbType: String
|
climbType: String,
|
||||||
): List<ProblemData> {
|
): List<ProblemData> {
|
||||||
return problems.filter { it.climbType == climbType }
|
return problems.filter { it.climbType == climbType }
|
||||||
}
|
}
|
||||||
@@ -314,7 +314,7 @@ class UtilityTests {
|
|||||||
private fun filterByDifficultyRange(
|
private fun filterByDifficultyRange(
|
||||||
problems: List<ProblemData>,
|
problems: List<ProblemData>,
|
||||||
minGrade: String,
|
minGrade: String,
|
||||||
maxGrade: String
|
maxGrade: String,
|
||||||
): List<ProblemData> {
|
): List<ProblemData> {
|
||||||
return problems.filter { problem ->
|
return problems.filter { problem ->
|
||||||
if (problem.climbType == "BOULDER" && problem.difficulty.startsWith("V")) {
|
if (problem.climbType == "BOULDER" && problem.difficulty.startsWith("V")) {
|
||||||
@@ -330,7 +330,7 @@ class UtilityTests {
|
|||||||
|
|
||||||
private fun mergeData(
|
private fun mergeData(
|
||||||
local: Map<String, String>,
|
local: Map<String, String>,
|
||||||
server: Map<String, String>
|
server: Map<String, String>,
|
||||||
): Map<String, String> {
|
): Map<String, String> {
|
||||||
return (local.keys + server.keys).associateWith { key -> server[key] ?: local[key]!! }
|
return (local.keys + server.keys).associateWith { key -> server[key] ?: local[key]!! }
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ class UtilityTests {
|
|||||||
val totalAttempts: Int,
|
val totalAttempts: Int,
|
||||||
val successfulAttempts: Int,
|
val successfulAttempts: Int,
|
||||||
val successRate: Double,
|
val successRate: Double,
|
||||||
val averageDuration: Double
|
val averageDuration: Double,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ProblemData(
|
data class ProblemData(
|
||||||
@@ -358,13 +358,13 @@ class UtilityTests {
|
|||||||
val name: String,
|
val name: String,
|
||||||
val climbType: String,
|
val climbType: String,
|
||||||
val difficulty: String,
|
val difficulty: String,
|
||||||
val tags: List<String>
|
val tags: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BackupData(
|
data class BackupData(
|
||||||
val version: String,
|
val version: String,
|
||||||
val formatVersion: String,
|
val formatVersion: String,
|
||||||
val exportedAt: 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.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) 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.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. For more details, visit
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
# 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
|
# 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
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.12.3"
|
agp = "8.12.3"
|
||||||
kotlin = "2.2.21"
|
kotlin = "2.3.0"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
@@ -10,8 +10,8 @@ androidxTestExt = "1.3.0"
|
|||||||
androidxTestRunner = "1.7.0"
|
androidxTestRunner = "1.7.0"
|
||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.0"
|
activityCompose = "1.12.2"
|
||||||
composeBom = "2025.11.01"
|
composeBom = "2025.12.01"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
navigation = "2.9.6"
|
navigation = "2.9.6"
|
||||||
viewmodel = "2.10.0"
|
viewmodel = "2.10.0"
|
||||||
@@ -19,7 +19,10 @@ kotlinxSerialization = "1.9.0"
|
|||||||
kotlinxCoroutines = "1.10.2"
|
kotlinxCoroutines = "1.10.2"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
ksp = "2.2.20-2.0.3"
|
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]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
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 |