Add Android BikeSetup client
This commit is contained in:
+9
-3
@@ -1,3 +1,9 @@
|
|||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/bikeapp?schema=public"
|
# Database
|
||||||
OLLAMA_URL="http://localhost:11434"
|
DATABASE_URL=postgresql://user:password@localhost:5432/loam
|
||||||
OLLAMA_MODEL="llama3.1:8b"
|
|
||||||
|
# Auth (required for user accounts)
|
||||||
|
JWT_SECRET=change-this-to-a-long-random-string-in-production
|
||||||
|
|
||||||
|
# AI recommendations (optional, defaults shown)
|
||||||
|
OLLAMA_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=llama3.1:8b
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build/
|
||||||
|
/captures/
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
local.properties
|
||||||
|
*.apk
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# BikeSetup Android App
|
||||||
|
|
||||||
|
Android client for the BikeSetup backend. Built with Kotlin, Jetpack Compose, and Ktor.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **UI**: Jetpack Compose + Material 3 (dark theme default)
|
||||||
|
- **Architecture**: MVVM with ViewModels, single-activity
|
||||||
|
- **Navigation**: Jetpack Navigation Compose
|
||||||
|
- **DI**: Koin
|
||||||
|
- **Networking**: Ktor client (OkHttp engine) with automatic cookie jar for session auth
|
||||||
|
- **Serialization**: Kotlinx Serialization
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
com.bikeloam.app/
|
||||||
|
├── data/
|
||||||
|
│ ├── model/ # Auth, Catalog, Build, Review DTOs
|
||||||
|
│ ├── network/ # Ktor HttpClient + BikeApiClient
|
||||||
|
│ └── repository/ # BikeRepository (Result-wrapped API calls)
|
||||||
|
├── di/
|
||||||
|
│ └── AppModule.kt # Koin module wiring
|
||||||
|
├── navigation/
|
||||||
|
│ └── BikeAppNavGraph.kt
|
||||||
|
└── ui/
|
||||||
|
├── screen/ # 10 Composable screens
|
||||||
|
├── theme/ # Material3 theme + colors
|
||||||
|
└── viewmodel/ # 9 ViewModels (MVVM)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install Android SDK and set the path in `local.properties`:
|
||||||
|
```properties
|
||||||
|
sdk.dir=/path/to/your/Android/Sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Generate the Gradle wrapper jar (if missing):
|
||||||
|
```bash
|
||||||
|
gradle wrapper
|
||||||
|
```
|
||||||
|
Or open the project in Android Studio.
|
||||||
|
|
||||||
|
3. Start the backend server:
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build the app:
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Install on an emulator or device:
|
||||||
|
```bash
|
||||||
|
./gradlew installDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Configuration
|
||||||
|
|
||||||
|
The debug build points to `http://10.0.2.2:3000` (emulator localhost). For a physical device, update `BASE_URL` in `app/build.gradle.kts` to your machine's LAN IP (e.g., `http://192.168.1.x:3000`).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
buildConfigField("String", "BASE_URL", "\"http://YOUR_IP:3000\"")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Splash + Auth Gate**: On cold start, checks `/api/auth/me`. Authenticated users skip Login.
|
||||||
|
- **Authentication**: Register / Login / Logout via cookie-based JWT sessions.
|
||||||
|
- **Catalog Browsing**: Fetches frames, forks, shocks, and tires from `/api/catalog`.
|
||||||
|
- **Build Management**: Create builds with component selection dropdowns. View build details with resolved component names.
|
||||||
|
- **Ride Reviews**: Submit post-ride reviews with 1–10 sliders and issue checkboxes.
|
||||||
|
- **AI Recommendations**: Fetches setup recommendations from `/api/ai/recommend`.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- **Session cookies** are handled automatically by Ktor's `HttpCookies` plugin.
|
||||||
|
- **No `local.properties`** should be committed (it's `.gitignore`d).
|
||||||
|
- **Network security config** allows cleartext HTTP for development (`10.0.2.2`).
|
||||||
|
|
||||||
|
## Next Steps / Known Limitations
|
||||||
|
|
||||||
|
- No individual `GET /api/builds/:id` endpoint exists on the backend; the detail screen fetches the full list and filters by ID.
|
||||||
|
- Component images are not yet loaded (Coil is included but image URLs aren't resolved from the backend yet).
|
||||||
|
- Add offline caching (Room) for catalog and builds if desired.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
kotlin("kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.bikeloam.app"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.bikeloam.app"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
buildConfigField("String", "BASE_URL", "\"http://10.0.2.2:3000\"")
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
buildConfigField("String", "BASE_URL", "\"https://YOUR_DOMAIN\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{LICENSE,NOTICE,NOTICE.txt,LICENSE.txt}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core Android
|
||||||
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
|
|
||||||
|
// Compose BOM
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||||
|
|
||||||
|
// ViewModel + SavedStateHandle
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
|
||||||
|
|
||||||
|
// Networking - Ktor with OkHttp engine (handles cookies natively)
|
||||||
|
implementation("io.ktor:ktor-client-okhttp:3.0.3")
|
||||||
|
implementation("io.ktor:ktor-client-content-negotiation:3.0.3")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
||||||
|
// DataStore for preferences
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
|
||||||
|
// Coil for image loading
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
// Koin DI
|
||||||
|
implementation("io.insert-koin:koin-android:3.5.6")
|
||||||
|
implementation("io.insert-koin:koin-androidx-compose:3.5.6")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
|
androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# Keep ProGuard rules for OkHttp, Ktor, and KotlinX Serialization
|
||||||
|
-keep class okhttp3.** { *; }
|
||||||
|
-keep class io.ktor.** { *; }
|
||||||
|
-keep class kotlinx.serialization.** { *; }
|
||||||
|
-keep class com.bikeloam.app.data.model.** { *; }
|
||||||
|
|
||||||
|
# Compose
|
||||||
|
-keep class androidx.compose.** { *; }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".BikeAppApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:theme="@style/Theme.BikeSetup"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.BikeSetup">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.bikeloam.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bikeloam.app.di.appModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class BikeAppApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startKoin {
|
||||||
|
androidLogger()
|
||||||
|
androidContext(this@BikeAppApplication)
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.bikeloam.app
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bikeloam.app.navigation.BikeAppNavGraph
|
||||||
|
import com.bikeloam.app.ui.theme.BikeSetupTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
BikeSetupTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
BikeAppNavGraph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.bikeloam.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val email: String,
|
||||||
|
val name: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthResponse(
|
||||||
|
val user: User? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val name: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorResponse(
|
||||||
|
val error: String = "Unknown error"
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.bikeloam.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RiderProfile(
|
||||||
|
val weight: Int,
|
||||||
|
val discipline: String,
|
||||||
|
val level: String,
|
||||||
|
val style: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BuildSelection(
|
||||||
|
val name: String,
|
||||||
|
val frameId: String? = null,
|
||||||
|
val forkId: String? = null,
|
||||||
|
val shockId: String? = null,
|
||||||
|
val frontTireId: String? = null,
|
||||||
|
val rearTireId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BikeBuild(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val riderWeightKg: Int,
|
||||||
|
val discipline: String,
|
||||||
|
val riderLevel: String,
|
||||||
|
val rideStyle: String,
|
||||||
|
val frameId: String? = null,
|
||||||
|
val forkId: String? = null,
|
||||||
|
val shockId: String? = null,
|
||||||
|
val frontTireId: String? = null,
|
||||||
|
val rearTireId: String? = null,
|
||||||
|
val setup: String? = null,
|
||||||
|
val imagePath: String? = null,
|
||||||
|
val userId: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateBuildRequest(
|
||||||
|
val name: String,
|
||||||
|
val rider: RiderProfile,
|
||||||
|
val frameId: String? = null,
|
||||||
|
val forkId: String? = null,
|
||||||
|
val shockId: String? = null,
|
||||||
|
val frontTireId: String? = null,
|
||||||
|
val rearTireId: String? = null,
|
||||||
|
val setup: Map<String, Any>? = null,
|
||||||
|
val imagePath: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.bikeloam.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Product(
|
||||||
|
val id: String,
|
||||||
|
val category: String,
|
||||||
|
val brand: String,
|
||||||
|
val model: String,
|
||||||
|
val imagePath: String? = null,
|
||||||
|
val specs: String? = null,
|
||||||
|
val setup: String? = null,
|
||||||
|
val position: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatalogResponse(
|
||||||
|
val frames: List<Product> = emptyList(),
|
||||||
|
val forks: List<Product> = emptyList(),
|
||||||
|
val shocks: List<Product> = emptyList(),
|
||||||
|
val tires: List<Product> = emptyList()
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.bikeloam.app.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RideReview(
|
||||||
|
val id: String,
|
||||||
|
val buildId: String,
|
||||||
|
val suspFeel: Int,
|
||||||
|
val frontGrip: Int,
|
||||||
|
val rearGrip: Int,
|
||||||
|
val rollSpeed: Int,
|
||||||
|
val confidence: Int,
|
||||||
|
val bottomOut: Boolean = false,
|
||||||
|
val harshHit: Boolean = false,
|
||||||
|
val wallow: Boolean = false,
|
||||||
|
val smallBump: Boolean = false,
|
||||||
|
val notes: String? = null,
|
||||||
|
val advice: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateReviewRequest(
|
||||||
|
val buildId: String,
|
||||||
|
val suspFeel: Int,
|
||||||
|
val frontGrip: Int,
|
||||||
|
val rearGrip: Int,
|
||||||
|
val rollSpeed: Int,
|
||||||
|
val confidence: Int,
|
||||||
|
val bottomOut: Boolean = false,
|
||||||
|
val harshHit: Boolean = false,
|
||||||
|
val wallow: Boolean = false,
|
||||||
|
val smallBump: Boolean = false,
|
||||||
|
val notes: String? = null,
|
||||||
|
val advice: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RecommendRequest(
|
||||||
|
val rider: RiderProfile,
|
||||||
|
val selection: BuildSelection,
|
||||||
|
val setup: Map<String, Any>? = null,
|
||||||
|
val review: Map<String, Any>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RecommendResponse(
|
||||||
|
val model: String,
|
||||||
|
val recommendation: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.bikeloam.app.data.network
|
||||||
|
|
||||||
|
import com.bikeloam.app.data.model.*
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
|
||||||
|
class BikeApiClient(private val client: HttpClient) {
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
suspend fun login(email: String, password: String): AuthResponse {
|
||||||
|
return client.post("/api/auth/login") {
|
||||||
|
setBody(LoginRequest(email, password))
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(email: String, password: String, name: String?): AuthResponse {
|
||||||
|
return client.post("/api/auth/register") {
|
||||||
|
setBody(RegisterRequest(email, password, name))
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCurrentUser(): AuthResponse? {
|
||||||
|
return try {
|
||||||
|
client.get("/api/auth/me").body()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout() {
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog
|
||||||
|
suspend fun getCatalog(): CatalogResponse {
|
||||||
|
return client.get("/api/catalog").body()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds
|
||||||
|
suspend fun getBuilds(): List<BikeBuild> {
|
||||||
|
return client.get("/api/builds").body()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createBuild(request: CreateBuildRequest): BikeBuild {
|
||||||
|
return client.post("/api/builds") {
|
||||||
|
setBody(request)
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
suspend fun createReview(request: CreateReviewRequest): RideReview {
|
||||||
|
return client.post("/api/reviews") {
|
||||||
|
setBody(request)
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Recommendation
|
||||||
|
suspend fun getRecommendation(request: RecommendRequest): RecommendResponse {
|
||||||
|
return client.post("/api/ai/recommend") {
|
||||||
|
setBody(request)
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.bikeloam.app.data.network
|
||||||
|
|
||||||
|
import com.bikeloam.app.data.model.ErrorResponse
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.okhttp.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.plugins.cookies.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class ApiException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
object NetworkClient {
|
||||||
|
|
||||||
|
fun create(baseUrl: String): HttpClient = HttpClient(OkHttp) {
|
||||||
|
engine {
|
||||||
|
config {
|
||||||
|
// Allow cleartext HTTP for emulator (10.0.2.2)
|
||||||
|
hostnameVerifier { _, _ -> true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 60_000
|
||||||
|
connectTimeoutMillis = 15_000
|
||||||
|
socketTimeoutMillis = 15_000
|
||||||
|
}
|
||||||
|
|
||||||
|
install(HttpCookies)
|
||||||
|
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
explicitNulls = false
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponseValidator {
|
||||||
|
validateResponse { response ->
|
||||||
|
if (!response.status.isSuccess()) {
|
||||||
|
val errorBody = try {
|
||||||
|
response.body<ErrorResponse>()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
throw ApiException(
|
||||||
|
errorBody?.error ?: "HTTP ${response.status.value}: ${response.status.description}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRequest {
|
||||||
|
url(baseUrl)
|
||||||
|
headers {
|
||||||
|
append(HttpHeaders.ContentType, ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.bikeloam.app.data.repository
|
||||||
|
|
||||||
|
import com.bikeloam.app.data.model.*
|
||||||
|
import com.bikeloam.app.data.network.BikeApiClient
|
||||||
|
|
||||||
|
class BikeRepository(private val apiClient: BikeApiClient) {
|
||||||
|
|
||||||
|
suspend fun login(email: String, password: String): Result<User> {
|
||||||
|
return try {
|
||||||
|
val response = apiClient.login(email, password)
|
||||||
|
Result.success(response.user)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(email: String, password: String, name: String?): Result<User> {
|
||||||
|
return try {
|
||||||
|
val response = apiClient.register(email, password, name)
|
||||||
|
Result.success(response.user)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCurrentUser(): Result<User?> {
|
||||||
|
return try {
|
||||||
|
val response = apiClient.getCurrentUser()
|
||||||
|
Result.success(response?.user)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout(): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
apiClient.logout()
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog
|
||||||
|
suspend fun fetchCatalog(): Result<CatalogResponse> {
|
||||||
|
return try {
|
||||||
|
val catalog = apiClient.getCatalog()
|
||||||
|
Result.success(catalog)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds
|
||||||
|
suspend fun fetchBuilds(): Result<List<BikeBuild>> {
|
||||||
|
return try {
|
||||||
|
val builds = apiClient.getBuilds()
|
||||||
|
Result.success(builds)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createBuild(request: CreateBuildRequest): Result<BikeBuild> {
|
||||||
|
return try {
|
||||||
|
val build = apiClient.createBuild(request)
|
||||||
|
Result.success(build)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
suspend fun createReview(request: CreateReviewRequest): Result<RideReview> {
|
||||||
|
return try {
|
||||||
|
val review = apiClient.createReview(request)
|
||||||
|
Result.success(review)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Recommendation
|
||||||
|
suspend fun getRecommendation(request: RecommendRequest): Result<RecommendResponse> {
|
||||||
|
return try {
|
||||||
|
val response = apiClient.getRecommendation(request)
|
||||||
|
Result.success(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.bikeloam.app.di
|
||||||
|
|
||||||
|
import com.bikeloam.app.BuildConfig
|
||||||
|
import com.bikeloam.app.data.network.BikeApiClient
|
||||||
|
import com.bikeloam.app.data.network.NetworkClient
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import com.bikeloam.app.ui.viewmodel.*
|
||||||
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
single { NetworkClient.create(BuildConfig.BASE_URL) }
|
||||||
|
single { BikeApiClient(get()) }
|
||||||
|
single { BikeRepository(get()) }
|
||||||
|
|
||||||
|
viewModel { SplashViewModel(get()) }
|
||||||
|
viewModel { LoginViewModel(get()) }
|
||||||
|
viewModel { RegisterViewModel(get()) }
|
||||||
|
viewModel { HomeViewModel(get()) }
|
||||||
|
viewModel { BuildListViewModel(get()) }
|
||||||
|
viewModel { BuildDetailViewModel(get()) }
|
||||||
|
viewModel { CreateBuildViewModel(get()) }
|
||||||
|
viewModel { ReviewViewModel(get()) }
|
||||||
|
viewModel { RecommendationViewModel(get()) }
|
||||||
|
viewModel { SettingsViewModel(get()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package com.bikeloam.app.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.bikeloam.app.ui.screen.*
|
||||||
|
|
||||||
|
sealed class Screen(val route: String) {
|
||||||
|
object Splash : Screen("splash")
|
||||||
|
object Login : Screen("login")
|
||||||
|
object Register : Screen("register")
|
||||||
|
object Home : Screen("home")
|
||||||
|
object BuildList : Screen("build_list")
|
||||||
|
object CreateBuild : Screen("create_build")
|
||||||
|
object BuildDetail : Screen("build_detail/{buildId}") {
|
||||||
|
fun createRoute(buildId: String) = "build_detail/$buildId"
|
||||||
|
}
|
||||||
|
object Review : Screen("review/{buildId}") {
|
||||||
|
fun createRoute(buildId: String) = "review/$buildId"
|
||||||
|
}
|
||||||
|
object Recommendation : Screen("recommendation/{buildId}") {
|
||||||
|
fun createRoute(buildId: String) = "recommendation/$buildId"
|
||||||
|
}
|
||||||
|
object Settings : Screen("settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BikeAppNavGraph(modifier: Modifier = Modifier) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Screen.Splash.route,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
composable(Screen.Splash.route) {
|
||||||
|
SplashScreen(
|
||||||
|
onAuthenticated = {
|
||||||
|
navController.navigate(Screen.Home.route) {
|
||||||
|
popUpTo(Screen.Splash.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUnauthenticated = {
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(Screen.Splash.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Login.route) {
|
||||||
|
LoginScreen(
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(Screen.Home.route) {
|
||||||
|
popUpTo(Screen.Login.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToRegister = {
|
||||||
|
navController.navigate(Screen.Register.route)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Register.route) {
|
||||||
|
RegisterScreen(
|
||||||
|
onRegisterSuccess = {
|
||||||
|
navController.navigate(Screen.Home.route) {
|
||||||
|
popUpTo(Screen.Login.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToLogin = {
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(Screen.Register.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Home.route) {
|
||||||
|
HomeScreen(
|
||||||
|
onNavigateToBuildList = {
|
||||||
|
navController.navigate(Screen.BuildList.route)
|
||||||
|
},
|
||||||
|
onNavigateToCreateBuild = {
|
||||||
|
navController.navigate(Screen.CreateBuild.route)
|
||||||
|
},
|
||||||
|
onNavigateToSettings = {
|
||||||
|
navController.navigate(Screen.Settings.route)
|
||||||
|
},
|
||||||
|
onLogout = {
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.BuildList.route) {
|
||||||
|
BuildListScreen(
|
||||||
|
onBuildSelected = { buildId ->
|
||||||
|
navController.navigate(Screen.BuildDetail.createRoute(buildId))
|
||||||
|
},
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.CreateBuild.route) {
|
||||||
|
CreateBuildScreen(
|
||||||
|
onBuildCreated = {
|
||||||
|
navController.popBackStack(Screen.Home.route, inclusive = false)
|
||||||
|
},
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.BuildDetail.route,
|
||||||
|
arguments = listOf(navArgument("buildId") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val buildId = backStackEntry.arguments?.getString("buildId") ?: return@composable
|
||||||
|
BuildDetailScreen(
|
||||||
|
buildId = buildId,
|
||||||
|
onNavigateToReview = {
|
||||||
|
navController.navigate(Screen.Review.createRoute(buildId))
|
||||||
|
},
|
||||||
|
onNavigateToRecommendation = {
|
||||||
|
navController.navigate(Screen.Recommendation.createRoute(buildId))
|
||||||
|
},
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.Review.route,
|
||||||
|
arguments = listOf(navArgument("buildId") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val buildId = backStackEntry.arguments?.getString("buildId") ?: return@composable
|
||||||
|
ReviewScreen(
|
||||||
|
buildId = buildId,
|
||||||
|
onReviewSubmitted = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.Recommendation.route,
|
||||||
|
arguments = listOf(navArgument("buildId") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val buildId = backStackEntry.arguments?.getString("buildId") ?: return@composable
|
||||||
|
RecommendationScreen(
|
||||||
|
buildId = buildId,
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Screen.Settings.route) {
|
||||||
|
SettingsScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.data.model.BikeBuild
|
||||||
|
import com.bikeloam.app.ui.viewmodel.BuildDetailViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BuildDetailScreen(
|
||||||
|
buildId: String,
|
||||||
|
onNavigateToReview: () -> Unit,
|
||||||
|
onNavigateToRecommendation: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: BuildDetailViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
LaunchedEffect(buildId) {
|
||||||
|
viewModel.loadBuild(buildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Build Details") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = androidx.compose.ui.Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (uiState.error != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val build = uiState.build
|
||||||
|
if (build != null) {
|
||||||
|
BuildInfoCard(build, viewModel)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onNavigateToReview,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Write Review")
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onNavigateToRecommendation,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Get AI Recommendation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BuildInfoCard(build: BikeBuild, viewModel: BuildDetailViewModel) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
Text(
|
||||||
|
text = build.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
DetailRow("Rider Weight", "${build.riderWeightKg} kg")
|
||||||
|
DetailRow("Discipline", build.discipline)
|
||||||
|
DetailRow("Level", build.riderLevel)
|
||||||
|
DetailRow("Style", build.rideStyle)
|
||||||
|
|
||||||
|
if (build.frameId != null || build.forkId != null || build.shockId != null ||
|
||||||
|
build.frontTireId != null || build.rearTireId != null
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Components",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
build.frameId?.let {
|
||||||
|
val name = viewModel.resolveProductName(it) ?: it
|
||||||
|
DetailRow("Frame", name)
|
||||||
|
}
|
||||||
|
build.forkId?.let {
|
||||||
|
val name = viewModel.resolveProductName(it) ?: it
|
||||||
|
DetailRow("Fork", name)
|
||||||
|
}
|
||||||
|
build.shockId?.let {
|
||||||
|
val name = viewModel.resolveProductName(it) ?: it
|
||||||
|
DetailRow("Shock", name)
|
||||||
|
}
|
||||||
|
build.frontTireId?.let {
|
||||||
|
val name = viewModel.resolveProductName(it) ?: it
|
||||||
|
DetailRow("Front Tire", name)
|
||||||
|
}
|
||||||
|
build.rearTireId?.let {
|
||||||
|
val name = viewModel.resolveProductName(it) ?: it
|
||||||
|
DetailRow("Rear Tire", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!build.setup.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Setup",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Text(build.setup, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailRow(label: String, value: String) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "$label: ",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.data.model.BikeBuild
|
||||||
|
import com.bikeloam.app.ui.viewmodel.BuildListViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BuildListScreen(
|
||||||
|
onBuildSelected: (String) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: BuildListViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("My Builds") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.loadBuilds() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (uiState.error != null) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(uiState.error, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
} else if (uiState.builds.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("No builds yet. Create your first setup!")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.builds) { build ->
|
||||||
|
BuildCard(
|
||||||
|
build = build,
|
||||||
|
onClick = { onBuildSelected(build.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BuildCard(build: BikeBuild, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = build.name,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${build.riderWeightKg}kg · ${build.discipline} · ${build.riderLevel} · ${build.rideStyle}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row {
|
||||||
|
build.frameId?.let {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { },
|
||||||
|
label = { Text("Frame") },
|
||||||
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
build.forkId?.let {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { },
|
||||||
|
label = { Text("Fork") },
|
||||||
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
build.shockId?.let {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { },
|
||||||
|
label = { Text("Shock") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.data.model.Product
|
||||||
|
import com.bikeloam.app.ui.viewmodel.CreateBuildViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CreateBuildScreen(
|
||||||
|
onBuildCreated: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: CreateBuildViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("New Build") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Build Details",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.buildName,
|
||||||
|
onValueChange = viewModel::updateBuildName,
|
||||||
|
label = { Text("Build Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.riderWeight,
|
||||||
|
onValueChange = viewModel::updateRiderWeight,
|
||||||
|
label = { Text("Rider Weight (kg)") },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
StringDropdown(
|
||||||
|
label = "Discipline",
|
||||||
|
options = viewModel.disciplines,
|
||||||
|
selected = uiState.discipline,
|
||||||
|
onSelect = viewModel::updateDiscipline
|
||||||
|
)
|
||||||
|
|
||||||
|
StringDropdown(
|
||||||
|
label = "Rider Level",
|
||||||
|
options = viewModel.levels,
|
||||||
|
selected = uiState.level,
|
||||||
|
onSelect = viewModel::updateLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
StringDropdown(
|
||||||
|
label = "Ride Style",
|
||||||
|
options = viewModel.styles,
|
||||||
|
selected = uiState.style,
|
||||||
|
onSelect = viewModel::updateStyle
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Components",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
ProductDropdown(
|
||||||
|
label = "Frame",
|
||||||
|
products = uiState.frames,
|
||||||
|
selectedId = uiState.selectedFrameId,
|
||||||
|
onSelect = viewModel::selectFrame
|
||||||
|
)
|
||||||
|
|
||||||
|
ProductDropdown(
|
||||||
|
label = "Fork",
|
||||||
|
products = uiState.forks,
|
||||||
|
selectedId = uiState.selectedForkId,
|
||||||
|
onSelect = viewModel::selectFork
|
||||||
|
)
|
||||||
|
|
||||||
|
ProductDropdown(
|
||||||
|
label = "Shock",
|
||||||
|
products = uiState.shocks,
|
||||||
|
selectedId = uiState.selectedShockId,
|
||||||
|
onSelect = viewModel::selectShock
|
||||||
|
)
|
||||||
|
|
||||||
|
ProductDropdown(
|
||||||
|
label = "Front Tire",
|
||||||
|
products = uiState.tires.filter { it.position == "front" || it.position == null },
|
||||||
|
selectedId = uiState.selectedFrontTireId,
|
||||||
|
onSelect = viewModel::selectFrontTire
|
||||||
|
)
|
||||||
|
|
||||||
|
ProductDropdown(
|
||||||
|
label = "Rear Tire",
|
||||||
|
products = uiState.tires.filter { it.position == "rear" || it.position == null },
|
||||||
|
selectedId = uiState.selectedRearTireId,
|
||||||
|
onSelect = viewModel::selectRearTire
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.createBuild(onBuildCreated) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Create Build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ProductDropdown(
|
||||||
|
label: String,
|
||||||
|
products: List<Product>,
|
||||||
|
selectedId: String?,
|
||||||
|
onSelect: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedProduct = products.find { it.id == selectedId }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedProduct?.let { "${it.brand} ${it.model}" } ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(label) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("None") },
|
||||||
|
onClick = {
|
||||||
|
onSelect(null)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
products.forEach { product ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("${product.brand} ${product.model}") },
|
||||||
|
onClick = {
|
||||||
|
onSelect(product.id)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun StringDropdown(
|
||||||
|
label: String,
|
||||||
|
options: List<String>,
|
||||||
|
selected: String,
|
||||||
|
onSelect: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selected.replaceFirstChar { it.uppercase() },
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(label) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option.replaceFirstChar { it.uppercase() }) },
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.HomeViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
onNavigateToBuildList: () -> Unit,
|
||||||
|
onNavigateToCreateBuild: () -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
viewModel: HomeViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("BikeSetup") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.logout(onLogout) }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = onNavigateToCreateBuild) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "New Build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
val user = uiState.user
|
||||||
|
Text(
|
||||||
|
text = "Welcome${user?.name?.let { ", $it" } ?: ""}",
|
||||||
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
if (user != null) {
|
||||||
|
Text(
|
||||||
|
text = user.email,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onNavigateToBuildList,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.List,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"My Builds",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"View and manage your bike setups",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onNavigateToCreateBuild,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"New Build",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Create a new bike setup",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.LoginViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
onNavigateToRegister: () -> Unit,
|
||||||
|
viewModel: LoginViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "BikeSetup",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Sign in to your account",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 32.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.email,
|
||||||
|
onValueChange = viewModel::updateEmail,
|
||||||
|
label = { Text("Email") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
label = { Text("Password") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = uiState.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login(onLoginSuccess) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Sign In")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onNavigateToRegister) {
|
||||||
|
Text("Don't have an account? Register")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.RecommendationViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RecommendationScreen(
|
||||||
|
buildId: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: RecommendationViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
LaunchedEffect(buildId) {
|
||||||
|
viewModel.loadBuildAndRecommend(buildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("AI Recommendation") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
uiState.build?.let { build ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = build.name,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${build.riderWeightKg}kg · ${build.discipline} · ${build.riderLevel}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.recommendation?.let { response ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Recommendation",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = response.recommendation,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Model: ${response.model}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.RegisterViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
onRegisterSuccess: () -> Unit,
|
||||||
|
onNavigateToLogin: () -> Unit,
|
||||||
|
viewModel: RegisterViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Create Account",
|
||||||
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Join BikeSetup",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 32.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.name,
|
||||||
|
onValueChange = viewModel::updateName,
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.email,
|
||||||
|
onValueChange = viewModel::updateEmail,
|
||||||
|
label = { Text("Email") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
label = { Text("Password (min 6 chars)") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = uiState.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.register(onRegisterSuccess) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Create Account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onNavigateToLogin) {
|
||||||
|
Text("Already have an account? Sign In")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.ReviewViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ReviewScreen(
|
||||||
|
buildId: String,
|
||||||
|
onReviewSubmitted: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ReviewViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
LaunchedEffect(buildId) {
|
||||||
|
viewModel.loadBuild(buildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Ride Review") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
uiState.build?.let { build ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = build.name,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${build.riderWeightKg}kg · ${build.discipline} · ${build.riderLevel}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Rate your ride (1-10)",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
RatingSlider("Suspension Feel", uiState.suspFeel, viewModel::updateSuspFeel)
|
||||||
|
RatingSlider("Front Grip", uiState.frontGrip, viewModel::updateFrontGrip)
|
||||||
|
RatingSlider("Rear Grip", uiState.rearGrip, viewModel::updateRearGrip)
|
||||||
|
RatingSlider("Roll Speed", uiState.rollSpeed, viewModel::updateRollSpeed)
|
||||||
|
RatingSlider("Confidence", uiState.confidence, viewModel::updateConfidence)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Issues",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
IssueCheckbox("Bottom Out", uiState.bottomOut, viewModel::toggleBottomOut)
|
||||||
|
IssueCheckbox("Harsh Hit", uiState.harshHit, viewModel::toggleHarshHit)
|
||||||
|
IssueCheckbox("Wallow", uiState.wallow, viewModel::toggleWallow)
|
||||||
|
IssueCheckbox("Small Bump", uiState.smallBump, viewModel::toggleSmallBump)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.notes,
|
||||||
|
onValueChange = viewModel::updateNotes,
|
||||||
|
label = { Text("Notes (optional)") },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.advice,
|
||||||
|
onValueChange = viewModel::updateAdvice,
|
||||||
|
label = { Text("Advice (optional)") },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.submitReview(buildId, onReviewSubmitted) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Submit Review")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RatingSlider(label: String, value: Int, onValueChange: (Int) -> Unit) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(value.toString(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = value.toFloat(),
|
||||||
|
onValueChange = { onValueChange(it.toInt()) },
|
||||||
|
valueRange = 1f..10f,
|
||||||
|
steps = 8,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IssueCheckbox(label: String, checked: Boolean, onToggle: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { onToggle() }
|
||||||
|
)
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.SettingsViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Settings") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Settings",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "API Endpoint",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Configured in BuildConfig. Update app/build.gradle.kts for production.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "App Info",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Version 1.0.0",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.bikeloam.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bikeloam.app.ui.viewmodel.SplashViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen(
|
||||||
|
onAuthenticated: () -> Unit,
|
||||||
|
onUnauthenticated: () -> Unit,
|
||||||
|
viewModel: SplashViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.isReady, uiState.isAuthenticated) {
|
||||||
|
if (uiState.isReady) {
|
||||||
|
if (uiState.isAuthenticated) {
|
||||||
|
onAuthenticated()
|
||||||
|
} else {
|
||||||
|
onUnauthenticated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "BikeSetup",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.bikeloam.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val PrimaryGreen = Color(0xFF2E7D32)
|
||||||
|
val PrimaryGreenLight = Color(0xFF4CAF50)
|
||||||
|
val PrimaryGreenDark = Color(0xFF1B5E20)
|
||||||
|
val AccentOrange = Color(0xFFEF6C00)
|
||||||
|
val AccentOrangeLight = Color(0xFFFF9800)
|
||||||
|
|
||||||
|
val SurfaceDark = Color(0xFF121212)
|
||||||
|
val SurfaceDarkVariant = Color(0xFF1E1E1E)
|
||||||
|
val OnSurfaceDark = Color(0xFFE0E0E0)
|
||||||
|
val OnSurfaceDarkVariant = Color(0xFFB0B0B0)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.bikeloam.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = PrimaryGreenLight,
|
||||||
|
onPrimary = Color.Black,
|
||||||
|
primaryContainer = PrimaryGreenDark,
|
||||||
|
secondary = AccentOrange,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
tertiary = PrimaryGreen,
|
||||||
|
background = SurfaceDark,
|
||||||
|
surface = SurfaceDarkVariant,
|
||||||
|
onSurface = OnSurfaceDark,
|
||||||
|
onSurfaceVariant = OnSurfaceDarkVariant,
|
||||||
|
error = Color(0xFFCF6679),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = PrimaryGreen,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = PrimaryGreenLight,
|
||||||
|
secondary = AccentOrange,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
tertiary = PrimaryGreenDark,
|
||||||
|
background = Color(0xFFF5F5F5),
|
||||||
|
surface = Color.White,
|
||||||
|
onSurface = Color(0xFF1C1B1F),
|
||||||
|
error = Color(0xFFB3261E),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val Typography = Typography(
|
||||||
|
displayLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
),
|
||||||
|
displayMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BikeSetupTheme(
|
||||||
|
darkTheme: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.BikeBuild
|
||||||
|
import com.bikeloam.app.data.model.CatalogResponse
|
||||||
|
import com.bikeloam.app.data.model.Product
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class BuildDetailViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(BuildDetailUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun loadBuild(buildId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
|
// Fetch both builds and catalog in parallel
|
||||||
|
val buildsResult = repository.fetchBuilds()
|
||||||
|
val catalogResult = repository.fetchCatalog()
|
||||||
|
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
|
||||||
|
val catalog = catalogResult.getOrNull()
|
||||||
|
val allProducts = catalog?.let {
|
||||||
|
it.frames + it.forks + it.shocks + it.tires
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
buildsResult
|
||||||
|
.onSuccess { builds ->
|
||||||
|
val build = builds.find { it.id == buildId }
|
||||||
|
if (build != null) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
build = build,
|
||||||
|
products = allProducts.associateBy { it.id }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(error = "Build not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
uiState = uiState.copy(error = it.message ?: "Failed to load build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveProductName(productId: String?): String? {
|
||||||
|
return productId?.let { uiState.products[it]?.let { p -> "${p.brand} ${p.model}" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BuildDetailUiState(
|
||||||
|
val build: BikeBuild? = null,
|
||||||
|
val products: Map<String, Product> = emptyMap(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.BikeBuild
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class BuildListViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(BuildListUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadBuilds()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBuilds() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
val result = repository.fetchBuilds()
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
result
|
||||||
|
.onSuccess { builds ->
|
||||||
|
uiState = uiState.copy(builds = builds)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
uiState = uiState.copy(error = it.message ?: "Failed to load builds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BuildListUiState(
|
||||||
|
val builds: List<BikeBuild> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.*
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CreateBuildViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
val disciplines = listOf("xc", "trail", "enduro")
|
||||||
|
val levels = listOf("beginner", "intermediate", "advanced", "expert")
|
||||||
|
val styles = listOf("smooth", "neutral", "aggressive")
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(CreateBuildUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCatalog() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.fetchCatalog()
|
||||||
|
result
|
||||||
|
.onSuccess { catalog ->
|
||||||
|
uiState = uiState.copy(
|
||||||
|
frames = catalog.frames,
|
||||||
|
forks = catalog.forks,
|
||||||
|
shocks = catalog.shocks,
|
||||||
|
tires = catalog.tires
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
uiState = uiState.copy(error = it.message ?: "Failed to load catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBuildName(name: String) {
|
||||||
|
uiState = uiState.copy(buildName = name, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRiderWeight(weight: String) {
|
||||||
|
uiState = uiState.copy(riderWeight = weight.filter { it.isDigit() }, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDiscipline(discipline: String) {
|
||||||
|
uiState = uiState.copy(discipline = discipline, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLevel(level: String) {
|
||||||
|
uiState = uiState.copy(level = level, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStyle(style: String) {
|
||||||
|
uiState = uiState.copy(style = style, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectFrame(frameId: String?) {
|
||||||
|
uiState = uiState.copy(selectedFrameId = frameId, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectFork(forkId: String?) {
|
||||||
|
uiState = uiState.copy(selectedForkId = forkId, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectShock(shockId: String?) {
|
||||||
|
uiState = uiState.copy(selectedShockId = shockId, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectFrontTire(tireId: String?) {
|
||||||
|
uiState = uiState.copy(selectedFrontTireId = tireId, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectRearTire(tireId: String?) {
|
||||||
|
uiState = uiState.copy(selectedRearTireId = tireId, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBuild(onSuccess: () -> Unit) {
|
||||||
|
val name = uiState.buildName.trim()
|
||||||
|
val weight = uiState.riderWeight.toIntOrNull()
|
||||||
|
|
||||||
|
if (name.isBlank()) {
|
||||||
|
uiState = uiState.copy(error = "Build name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (weight == null || weight < 35 || weight > 160) {
|
||||||
|
uiState = uiState.copy(error = "Valid rider weight (35-160 kg) is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (uiState.discipline.isBlank() || uiState.level.isBlank() || uiState.style.isBlank()) {
|
||||||
|
uiState = uiState.copy(error = "Discipline, level, and style are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val rider = RiderProfile(weight, uiState.discipline, uiState.level, uiState.style)
|
||||||
|
val request = CreateBuildRequest(
|
||||||
|
name = name,
|
||||||
|
rider = rider,
|
||||||
|
frameId = uiState.selectedFrameId,
|
||||||
|
forkId = uiState.selectedForkId,
|
||||||
|
shockId = uiState.selectedShockId,
|
||||||
|
frontTireId = uiState.selectedFrontTireId,
|
||||||
|
rearTireId = uiState.selectedRearTireId
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
val result = repository.createBuild(request)
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
result
|
||||||
|
.onSuccess { onSuccess() }
|
||||||
|
.onFailure { uiState = uiState.copy(error = it.message ?: "Failed to create build") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreateBuildUiState(
|
||||||
|
val buildName: String = "",
|
||||||
|
val riderWeight: String = "",
|
||||||
|
val discipline: String = "",
|
||||||
|
val level: String = "",
|
||||||
|
val style: String = "",
|
||||||
|
val selectedFrameId: String? = null,
|
||||||
|
val selectedForkId: String? = null,
|
||||||
|
val selectedShockId: String? = null,
|
||||||
|
val selectedFrontTireId: String? = null,
|
||||||
|
val selectedRearTireId: String? = null,
|
||||||
|
val frames: List<Product> = emptyList(),
|
||||||
|
val forks: List<Product> = emptyList(),
|
||||||
|
val shocks: List<Product> = emptyList(),
|
||||||
|
val tires: List<Product> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.User
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class HomeViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(HomeUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadUser() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.getCurrentUser()
|
||||||
|
result
|
||||||
|
.onSuccess { user ->
|
||||||
|
uiState = uiState.copy(user = user, isLoading = false)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
uiState = uiState.copy(user = null, isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onLoggedOut: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.logout()
|
||||||
|
onLoggedOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HomeUiState(
|
||||||
|
val user: User? = null,
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class LoginViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(LoginUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun updateEmail(email: String) {
|
||||||
|
uiState = uiState.copy(email = email, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePassword(password: String) {
|
||||||
|
uiState = uiState.copy(password = password, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(onSuccess: () -> Unit) {
|
||||||
|
val email = uiState.email.trim()
|
||||||
|
val password = uiState.password
|
||||||
|
|
||||||
|
if (email.isBlank() || password.isBlank()) {
|
||||||
|
uiState = uiState.copy(error = "Email and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
val result = repository.login(email, password)
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
result
|
||||||
|
.onSuccess { onSuccess() }
|
||||||
|
.onFailure { uiState = uiState.copy(error = it.message ?: "Login failed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoginUiState(
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.BikeBuild
|
||||||
|
import com.bikeloam.app.data.model.RecommendRequest
|
||||||
|
import com.bikeloam.app.data.model.RecommendResponse
|
||||||
|
import com.bikeloam.app.data.model.RiderProfile
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RecommendationViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(RecommendationUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun loadBuildAndRecommend(buildId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null, recommendation = null)
|
||||||
|
|
||||||
|
// Fetch builds and find the one we need
|
||||||
|
val buildsResult = repository.fetchBuilds()
|
||||||
|
val build = buildsResult.getOrNull()?.find { it.id == buildId }
|
||||||
|
|
||||||
|
if (build == null) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Build not found. Could not fetch recommendation."
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState = uiState.copy(build = build)
|
||||||
|
|
||||||
|
val rider = RiderProfile(
|
||||||
|
weight = build.riderWeightKg,
|
||||||
|
discipline = build.discipline,
|
||||||
|
level = build.riderLevel,
|
||||||
|
style = build.rideStyle
|
||||||
|
)
|
||||||
|
val selection = com.bikeloam.app.data.model.BuildSelection(
|
||||||
|
name = build.name,
|
||||||
|
frameId = build.frameId,
|
||||||
|
forkId = build.forkId,
|
||||||
|
shockId = build.shockId,
|
||||||
|
frontTireId = build.frontTireId,
|
||||||
|
rearTireId = build.rearTireId
|
||||||
|
)
|
||||||
|
val request = RecommendRequest(
|
||||||
|
rider = rider,
|
||||||
|
selection = selection,
|
||||||
|
setup = build.setup?.let { mapOf("setup" to it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.getRecommendation(request)
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
result
|
||||||
|
.onSuccess { response ->
|
||||||
|
uiState = uiState.copy(recommendation = response)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
uiState = uiState.copy(error = it.message ?: "Failed to get recommendation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RecommendationUiState(
|
||||||
|
val build: BikeBuild? = null,
|
||||||
|
val recommendation: RecommendResponse? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.User
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RegisterViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(RegisterUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun updateEmail(email: String) {
|
||||||
|
uiState = uiState.copy(email = email, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePassword(password: String) {
|
||||||
|
uiState = uiState.copy(password = password, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateName(name: String) {
|
||||||
|
uiState = uiState.copy(name = name, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(onSuccess: () -> Unit) {
|
||||||
|
val email = uiState.email.trim()
|
||||||
|
val password = uiState.password
|
||||||
|
|
||||||
|
if (email.isBlank() || password.isBlank()) {
|
||||||
|
uiState = uiState.copy(error = "Email and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
uiState = uiState.copy(error = "Password must be at least 6 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
val result = repository.register(email, password, uiState.name.takeIf { it.isNotBlank() })
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
result
|
||||||
|
.onSuccess { onSuccess() }
|
||||||
|
.onFailure { uiState = uiState.copy(error = it.message ?: "Registration failed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RegisterUiState(
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.model.BikeBuild
|
||||||
|
import com.bikeloam.app.data.model.CreateReviewRequest
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ReviewViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(ReviewUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun loadBuild(buildId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.fetchBuilds()
|
||||||
|
result
|
||||||
|
.onSuccess { builds ->
|
||||||
|
val build = builds.find { it.id == buildId }
|
||||||
|
if (build != null) {
|
||||||
|
uiState = uiState.copy(build = build)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSuspFeel(value: Int) {
|
||||||
|
uiState = uiState.copy(suspFeel = value.coerceIn(1, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFrontGrip(value: Int) {
|
||||||
|
uiState = uiState.copy(frontGrip = value.coerceIn(1, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRearGrip(value: Int) {
|
||||||
|
uiState = uiState.copy(rearGrip = value.coerceIn(1, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRollSpeed(value: Int) {
|
||||||
|
uiState = uiState.copy(rollSpeed = value.coerceIn(1, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfidence(value: Int) {
|
||||||
|
uiState = uiState.copy(confidence = value.coerceIn(1, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleBottomOut() {
|
||||||
|
uiState = uiState.copy(bottomOut = !uiState.bottomOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleHarshHit() {
|
||||||
|
uiState = uiState.copy(harshHit = !uiState.harshHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleWallow() {
|
||||||
|
uiState = uiState.copy(wallow = !uiState.wallow)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSmallBump() {
|
||||||
|
uiState = uiState.copy(smallBump = !uiState.smallBump)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotes(notes: String) {
|
||||||
|
uiState = uiState.copy(notes = notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAdvice(advice: String) {
|
||||||
|
uiState = uiState.copy(advice = advice)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitReview(buildId: String, onSuccess: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
val request = CreateReviewRequest(
|
||||||
|
buildId = buildId,
|
||||||
|
suspFeel = uiState.suspFeel,
|
||||||
|
frontGrip = uiState.frontGrip,
|
||||||
|
rearGrip = uiState.rearGrip,
|
||||||
|
rollSpeed = uiState.rollSpeed,
|
||||||
|
confidence = uiState.confidence,
|
||||||
|
bottomOut = uiState.bottomOut,
|
||||||
|
harshHit = uiState.harshHit,
|
||||||
|
wallow = uiState.wallow,
|
||||||
|
smallBump = uiState.smallBump,
|
||||||
|
notes = uiState.notes.takeIf { it.isNotBlank() },
|
||||||
|
advice = uiState.advice.takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
val result = repository.createReview(request)
|
||||||
|
uiState = uiState.copy(isLoading = false)
|
||||||
|
result
|
||||||
|
.onSuccess { onSuccess() }
|
||||||
|
.onFailure { uiState = uiState.copy(error = it.message ?: "Failed to submit review") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ReviewUiState(
|
||||||
|
val build: BikeBuild? = null,
|
||||||
|
val suspFeel: Int = 5,
|
||||||
|
val frontGrip: Int = 5,
|
||||||
|
val rearGrip: Int = 5,
|
||||||
|
val rollSpeed: Int = 5,
|
||||||
|
val confidence: Int = 5,
|
||||||
|
val bottomOut: Boolean = false,
|
||||||
|
val harshHit: Boolean = false,
|
||||||
|
val wallow: Boolean = false,
|
||||||
|
val smallBump: Boolean = false,
|
||||||
|
val notes: String = "",
|
||||||
|
val advice: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
|
||||||
|
class SettingsViewModel(private val repository: BikeRepository) : ViewModel()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.bikeloam.app.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bikeloam.app.data.repository.BikeRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SplashViewModel(private val repository: BikeRepository) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(SplashUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
checkAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkAuth() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.getCurrentUser()
|
||||||
|
result
|
||||||
|
.onSuccess { user ->
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isReady = true,
|
||||||
|
isAuthenticated = user != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isReady = true,
|
||||||
|
isAuthenticated = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SplashUiState(
|
||||||
|
val isReady: Boolean = false,
|
||||||
|
val isAuthenticated: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1B5E20"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="2.5"
|
||||||
|
android:scaleY="2.5"
|
||||||
|
android:translateX="27"
|
||||||
|
android:translateY="27">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM6.5,17.5l1.41,-1.41L8.17,16.17l-1.41,1.41zM17.5,6.5l-1.41,1.41L15.83,7.83l1.41,-1.41zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6z" />
|
||||||
|
</group>
|
||||||
|
</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="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">BikeSetup</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.BikeSetup" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"}
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in
|
||||||
|
/*) app_path=$link ;;
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./android}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in
|
||||||
|
CYGWIN* ) cygwin=true ;;
|
||||||
|
Darwin* ) darwin=true ;;
|
||||||
|
MSYS* | MINGW* ) msys=true ;;
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command.
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[%-+.-]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+89
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode = RepositoriesMode.PREFER_PROJECT
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "BikeSetup"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user