Add Android BikeSetup client

This commit is contained in:
2026-05-19 16:37:14 +02:00
parent 97f6e253f7
commit 4e18d97b85
51 changed files with 3197 additions and 3 deletions
+9 -3
View File
@@ -1,3 +1,9 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/bikeapp?schema=public" # Database
OLLAMA_URL="http://localhost:11434" DATABASE_URL=postgresql://user:password@localhost:5432/loam
OLLAMA_MODEL="llama3.1:8b"
# Auth (required for user accounts)
JWT_SECRET=change-this-to-a-long-random-string-in-production
# AI recommendations (optional, defaults shown)
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
+19
View File
@@ -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
+88
View File
@@ -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 110 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.
+105
View File
@@ -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")
}
+8
View File
@@ -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.** { *; }
+26
View File
@@ -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>
+6
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+7
View File
@@ -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
Vendored Executable
+85
View File
@@ -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" "$@"
+89
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.PREFER_PROJECT
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BikeSetup"
include(":app")