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"
|
||||
OLLAMA_URL="http://localhost:11434"
|
||||
OLLAMA_MODEL="llama3.1:8b"
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/loam
|
||||
|
||||
# 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