From 7345bd41a60ebae86f6a21956b6881e8b73b5147 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 05:41:56 +0000 Subject: [PATCH 1/5] feat: add native Android app with Jetpack Compose + Material 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Capacitor WebView wrapper with a fully native Android app: - Kotlin + Jetpack Compose + Material 3 (Material You dynamic colors) - Bottom navigation: Véhicules list + Radar map - WebView login flow preserving the cookie trick (shared CookieManager between WebView and OkHttp for seamless session auth) - Vehicle list with search, sort (distance/number/battery/brand), fuel filter chips, pull-to-refresh, and detail bottom sheet - Radar screen with Google Maps, radius slider, vehicle markers, auto-refresh countdown, and auto-book feature - Retrofit + OkHttp networking with WebViewCookieJar bridge - MVVM architecture with ViewModels and Kotlin Flows - DataStore for auth persistence - Accompanist for runtime permissions (location) https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp --- android-native/.gitignore | 14 + android-native/app/build.gradle.kts | 85 ++++ android-native/app/proguard-rules.pro | 14 + .../app/src/main/AndroidManifest.xml | 31 ++ .../com/communauto/tools/CommunautoApp.kt | 25 ++ .../java/com/communauto/tools/MainActivity.kt | 23 + .../tools/data/api/CommunautoApi.kt | 34 ++ .../tools/data/api/NetworkModule.kt | 67 +++ .../communauto/tools/data/auth/AuthManager.kt | 95 +++++ .../communauto/tools/data/model/Vehicle.kt | 79 ++++ .../data/repository/VehicleRepository.kt | 43 ++ .../tools/ui/component/VehicleCard.kt | 142 +++++++ .../tools/ui/component/VehicleDetailSheet.kt | 188 ++++++++ .../tools/ui/navigation/AppNavigation.kt | 159 +++++++ .../communauto/tools/ui/screen/LoginScreen.kt | 243 +++++++++++ .../communauto/tools/ui/screen/RadarScreen.kt | 401 ++++++++++++++++++ .../tools/ui/screen/VehicleListScreen.kt | 220 ++++++++++ .../com/communauto/tools/ui/theme/Color.kt | 15 + .../com/communauto/tools/ui/theme/Theme.kt | 49 +++ .../com/communauto/tools/ui/theme/Type.kt | 47 ++ .../communauto/tools/util/LocationUtils.kt | 24 ++ .../tools/viewmodel/AuthViewModel.kt | 66 +++ .../tools/viewmodel/RadarViewModel.kt | 188 ++++++++ .../tools/viewmodel/VehicleViewModel.kt | 107 +++++ .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 8 + android-native/build.gradle.kts | 5 + android-native/gradle.properties | 7 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android-native/settings.gradle.kts | 18 + 30 files changed, 2406 insertions(+) create mode 100644 android-native/.gitignore create mode 100644 android-native/app/build.gradle.kts create mode 100644 android-native/app/proguard-rules.pro create mode 100644 android-native/app/src/main/AndroidManifest.xml create mode 100644 android-native/app/src/main/java/com/communauto/tools/CommunautoApp.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/MainActivity.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/data/api/CommunautoApi.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/data/api/NetworkModule.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/data/auth/AuthManager.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/data/model/Vehicle.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/data/repository/VehicleRepository.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleCard.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleDetailSheet.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/navigation/AppNavigation.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/screen/LoginScreen.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/screen/RadarScreen.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/screen/VehicleListScreen.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/theme/Color.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/theme/Theme.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/ui/theme/Type.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/viewmodel/AuthViewModel.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/viewmodel/RadarViewModel.kt create mode 100644 android-native/app/src/main/java/com/communauto/tools/viewmodel/VehicleViewModel.kt create mode 100644 android-native/app/src/main/res/values/strings.xml create mode 100644 android-native/app/src/main/res/values/themes.xml create mode 100644 android-native/build.gradle.kts create mode 100644 android-native/gradle.properties create mode 100644 android-native/gradle/wrapper/gradle-wrapper.properties create mode 100644 android-native/settings.gradle.kts diff --git a/android-native/.gitignore b/android-native/.gitignore new file mode 100644 index 0000000..14c2233 --- /dev/null +++ b/android-native/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build +/app/release +*.apk +*.aab diff --git a/android-native/app/build.gradle.kts b/android-native/app/build.gradle.kts new file mode 100644 index 0000000..1c461aa --- /dev/null +++ b/android-native/app/build.gradle.kts @@ -0,0 +1,85 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.communauto.tools" + compileSdk = 35 + + defaultConfig { + applicationId = "com.communauto.tools" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + // Google Maps API key — set in gradle.properties or local.properties + manifestPlaceholders["MAPS_API_KEY"] = + project.findProperty("MAPS_API_KEY") as? String ?: "" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + + // Compose + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.navigation:navigation-compose:2.8.5") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + + // Networking + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // Google Maps Compose + implementation("com.google.maps.android:maps-compose:6.2.1") + implementation("com.google.android.gms:play-services-maps:19.0.0") + implementation("com.google.android.gms:play-services-location:21.3.0") + + // WebView + implementation("androidx.webkit:webkit:1.12.1") + + // Accompanist (permissions) + implementation("com.google.accompanist:accompanist-permissions:0.36.0") + + // DataStore for preferences + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Pull-to-refresh + implementation("androidx.compose.material3:material3-android") + + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/android-native/app/proguard-rules.pro b/android-native/app/proguard-rules.pro new file mode 100644 index 0000000..ab368e8 --- /dev/null +++ b/android-native/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.communauto.tools.data.model.** { *; } + +# Gson +-keep class com.google.gson.** { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/android-native/app/src/main/AndroidManifest.xml b/android-native/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3cbe6ab --- /dev/null +++ b/android-native/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android-native/app/src/main/java/com/communauto/tools/CommunautoApp.kt b/android-native/app/src/main/java/com/communauto/tools/CommunautoApp.kt new file mode 100644 index 0000000..30ba191 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/CommunautoApp.kt @@ -0,0 +1,25 @@ +package com.communauto.tools + +import android.app.Application +import android.webkit.CookieManager +import com.communauto.tools.data.auth.AuthManager +import com.communauto.tools.data.repository.VehicleRepository + +class CommunautoApp : Application() { + + lateinit var authManager: AuthManager + private set + + lateinit var vehicleRepository: VehicleRepository + private set + + override fun onCreate() { + super.onCreate() + + // Enable cookies for WebView (shared with OkHttp via WebViewCookieJar) + CookieManager.getInstance().setAcceptCookie(true) + + authManager = AuthManager(this) + vehicleRepository = VehicleRepository() + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/MainActivity.kt b/android-native/app/src/main/java/com/communauto/tools/MainActivity.kt new file mode 100644 index 0000000..8797e1b --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/MainActivity.kt @@ -0,0 +1,23 @@ +package com.communauto.tools + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.viewmodel.compose.viewModel +import com.communauto.tools.ui.navigation.AppNavigation +import com.communauto.tools.ui.theme.CommunautoTheme +import com.communauto.tools.viewmodel.AuthViewModel + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + CommunautoTheme { + val authViewModel: AuthViewModel = viewModel() + AppNavigation(authViewModel = authViewModel) + } + } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/data/api/CommunautoApi.kt b/android-native/app/src/main/java/com/communauto/tools/data/api/CommunautoApi.kt new file mode 100644 index 0000000..491db38 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/data/api/CommunautoApi.kt @@ -0,0 +1,34 @@ +package com.communauto.tools.data.api + +import com.communauto.tools.data.model.BookingResponse +import com.communauto.tools.data.model.VehiclesResponse +import com.communauto.tools.data.model.WcfResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface CommunautoApi { + + @GET("WCF/LSI/LSIBookingServiceV3.svc/GetAvailableVehicles") + suspend fun getAvailableVehicles( + @Query("BranchID") branchId: Int = 1, + @Query("LanguageID") languageId: Int = 2, + @Query("CityID") cityId: Int? = null, + ): WcfResponse + + @GET("WCF/LSI/LSIBookingServiceV3.svc/CreateBooking") + suspend fun createBooking( + @Query("CustomerID") customerId: Int, + @Query("CarID") carId: Int, + ): WcfResponse + + @GET("WCF/LSI/LSIBookingServiceV3.svc/CancelBooking") + suspend fun cancelBooking( + @Query("CustomerID") customerId: Int, + @Query("BranchID") branchId: Int = 1, + ): WcfResponse + + @GET("WCF/LSI/LSIBookingServiceV3.svc/GetCurrentBooking") + suspend fun getCurrentBooking( + @Query("CustomerID") customerId: Int, + ): WcfResponse +} diff --git a/android-native/app/src/main/java/com/communauto/tools/data/api/NetworkModule.kt b/android-native/app/src/main/java/com/communauto/tools/data/api/NetworkModule.kt new file mode 100644 index 0000000..c9f950e --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/data/api/NetworkModule.kt @@ -0,0 +1,67 @@ +package com.communauto.tools.data.api + +import android.webkit.CookieManager +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +/** + * CookieJar that delegates to Android's [CookieManager]. + * + * This is the key to the "cookie trick": the WebView login flow sets cookies + * in CookieManager. Because OkHttp shares the same CookieManager, all API + * calls automatically include the session cookies — no manual extraction needed. + */ +class WebViewCookieJar : CookieJar { + + private val cookieManager: CookieManager = CookieManager.getInstance() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val urlString = url.toString() + for (cookie in cookies) { + cookieManager.setCookie(urlString, cookie.toString()) + } + } + + override fun loadForRequest(url: HttpUrl): List { + val urlString = url.toString() + val cookieString = cookieManager.getCookie(urlString) ?: return emptyList() + return cookieString.split(";").mapNotNull { raw -> + Cookie.parse(url, raw.trim()) + } + } +} + +object NetworkModule { + + private const val BASE_URL = "https://www.reservauto.net/" + + private val cookieJar = WebViewCookieJar() + + private val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .cookieJar(cookieJar) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + ) + .build() + } + + val api: CommunautoApi by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(CommunautoApi::class.java) + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/data/auth/AuthManager.kt b/android-native/app/src/main/java/com/communauto/tools/data/auth/AuthManager.kt new file mode 100644 index 0000000..3531c8f --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/data/auth/AuthManager.kt @@ -0,0 +1,95 @@ +package com.communauto.tools.data.auth + +import android.content.Context +import android.webkit.CookieManager +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore("auth") +private val KEY_AUTHENTICATED = booleanPreferencesKey("authenticated") + +/** + * Manages authentication state. + * + * Authentication works via the WebView cookie trick: + * 1. User logs in via WebView → cookies stored in Android CookieManager + * 2. OkHttp uses a CookieJar backed by the same CookieManager + * 3. All API calls automatically carry the session cookies + * + * We only persist a boolean "has logged in before" flag — the actual + * cookies live in CookieManager. + */ +class AuthManager(private val context: Context) { + + private val _isAuthenticated = MutableStateFlow(false) + val isAuthenticated: Flow = _isAuthenticated.asStateFlow() + + /** Restore auth state from DataStore + check CookieManager. */ + suspend fun init() { + val savedAuth = context.dataStore.data.first()[KEY_AUTHENTICATED] ?: false + _isAuthenticated.value = savedAuth && hasCookies() + } + + /** Mark as authenticated after successful WebView login. */ + suspend fun onLoginSuccess() { + _isAuthenticated.value = true + context.dataStore.edit { it[KEY_AUTHENTICATED] = true } + } + + /** Log out: clear cookies and persisted flag. */ + suspend fun logout() { + _isAuthenticated.value = false + context.dataStore.edit { it[KEY_AUTHENTICATED] = false } + CookieManager.getInstance().removeAllCookies(null) + } + + /** Extract `uid` from the reservauto cookies (needed for booking calls). */ + fun getUid(): Int? { + val cookies = CookieManager.getInstance() + .getCookie("https://www.reservauto.net") ?: return null + val match = Regex("uid=(\\d+)").find(cookies) ?: return null + return match.groupValues[1].toIntOrNull() + } + + /** Check if we have the required session cookies. */ + fun hasCookies(): Boolean { + val cookies = CookieManager.getInstance() + .getCookie("https://www.reservauto.net") ?: return false + return cookies.contains("uid=") && cookies.contains("mySession=") + } + + /** Get raw cookie string for display. */ + fun getCookieString(): String? = + CookieManager.getInstance().getCookie("https://www.reservauto.net") + + /** Get status of required cookies. */ + fun getCookieStatus(): List { + val raw = getCookieString() ?: "" + val parsed = raw.split(";").associate { pair -> + val parts = pair.trim().split("=", limit = 2) + parts[0].trim() to (parts.getOrNull(1)?.trim() ?: "") + } + return listOf("uid", "bid", "mySession").map { name -> + val value = parsed[name] + CookieEntry( + name = name, + present = !value.isNullOrEmpty(), + preview = value?.take(20), + ) + } + } +} + +data class CookieEntry( + val name: String, + val present: Boolean, + val preview: String?, +) diff --git a/android-native/app/src/main/java/com/communauto/tools/data/model/Vehicle.kt b/android-native/app/src/main/java/com/communauto/tools/data/model/Vehicle.kt new file mode 100644 index 0000000..304b239 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/data/model/Vehicle.kt @@ -0,0 +1,79 @@ +package com.communauto.tools.data.model + +import com.google.gson.annotations.SerializedName + +/** Wrapper for all WCF responses (everything is nested under `.d`). */ +data class WcfResponse(val d: T) + +data class VehiclesResponse( + @SerializedName("Success") val success: Boolean, + @SerializedName("Vehicles") val vehicles: List, + @SerializedName("ErrorMessage") val errorMessage: String?, +) + +data class BookingResponse( + @SerializedName("Success") val success: Boolean, + @SerializedName("ErrorMessage") val errorMessage: String?, + @SerializedName("ErrorType") val errorType: Int?, +) + +data class Vehicle( + @SerializedName("CarId") val carId: Int, + @SerializedName("CarVin") val carVin: String, + @SerializedName("CarPlate") val carPlate: String, + @SerializedName("CarModel") val carModel: String, + @SerializedName("CarNo") val carNo: Int, + @SerializedName("CarBrand") val carBrand: String, + @SerializedName("CarColor") val carColor: String, + @SerializedName("CarSeatNb") val carSeatNb: Int, + @SerializedName("CarAccessories") val carAccessories: List, + @SerializedName("Latitude") val latitude: Double, + @SerializedName("Longitude") val longitude: Double, + @SerializedName("EnergyLevel") val energyLevel: Int?, + @SerializedName("IsElectric") val isElectric: Boolean, + @SerializedName("LastUseDate") val lastUseDate: String?, + @SerializedName("LastUse") val lastUse: Int?, + @SerializedName("isPromo") val isPromo: Boolean, + @SerializedName("BookingStatus") val bookingStatus: Int, + @SerializedName("BoardComputerType") val boardComputerType: Int, + @SerializedName("CityID") val cityId: Int, + @SerializedName("IsVehicleReturnFlex") val isVehicleReturnFlex: Boolean, + @SerializedName("CarStationId") val carStationId: Int?, + @SerializedName("VehiclePromotions") val vehiclePromotions: List?, +) + +data class VehiclePromotion( + @SerializedName("VehiculePromotionType") val promotionType: Int, + @SerializedName("EndDate") val endDate: String?, + @SerializedName("PriorityOrder") val priorityOrder: Int, +) + +/** Vehicle enriched with distance from user. */ +data class VehicleWithDistance( + val vehicle: Vehicle, + val distanceMeters: Double, +) + +/** Human-readable accessory names based on accessory codes. */ +object Accessories { + private val map = mapOf( + 4 to "Bluetooth", + 8 to "Siège chauffant", + 16 to "Toit ouvrant", + 32 to "Régulateur", + 64 to "Caméra de recul", + 128 to "Apple CarPlay", + 256 to "Android Auto", + ) + + fun names(codes: List): List = + codes.mapNotNull { map[it] } +} + +/** Booking status labels. */ +fun Vehicle.statusLabel(): String = when (bookingStatus) { + 0 -> "Disponible" + 1 -> "Réservé" + 2 -> "En cours" + else -> "Inconnu" +} diff --git a/android-native/app/src/main/java/com/communauto/tools/data/repository/VehicleRepository.kt b/android-native/app/src/main/java/com/communauto/tools/data/repository/VehicleRepository.kt new file mode 100644 index 0000000..0a2f8a8 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/data/repository/VehicleRepository.kt @@ -0,0 +1,43 @@ +package com.communauto.tools.data.repository + +import com.communauto.tools.data.api.NetworkModule +import com.communauto.tools.data.model.BookingResponse +import com.communauto.tools.data.model.Vehicle +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.util.haversineDistance + +class VehicleRepository { + + private val api = NetworkModule.api + + suspend fun getAvailableVehicles(): List { + val response = api.getAvailableVehicles() + if (!response.d.success) { + throw Exception(response.d.errorMessage ?: "Erreur inconnue") + } + return response.d.vehicles + } + + suspend fun getVehiclesWithDistance( + userLat: Double, + userLng: Double, + ): List { + return getAvailableVehicles().map { vehicle -> + VehicleWithDistance( + vehicle = vehicle, + distanceMeters = haversineDistance( + userLat, userLng, + vehicle.latitude, vehicle.longitude, + ), + ) + } + } + + suspend fun createBooking(customerId: Int, carId: Int): BookingResponse { + return api.createBooking(customerId, carId).d + } + + suspend fun cancelBooking(customerId: Int): BookingResponse { + return api.cancelBooking(customerId).d + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleCard.kt b/android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleCard.kt new file mode 100644 index 0000000..585ff64 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleCard.kt @@ -0,0 +1,142 @@ +package com.communauto.tools.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.ElectricCar +import androidx.compose.material.icons.filled.EventSeat +import androidx.compose.material.icons.filled.LocalGasStation +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.ui.theme.ElectricBlue +import com.communauto.tools.ui.theme.GasGreen + +@Composable +fun VehicleCard( + vehicle: VehicleWithDistance, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val v = vehicle.vehicle + + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 4.dp else 1.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Fuel type icon + Icon( + if (v.isElectric) Icons.Default.ElectricCar else Icons.Default.LocalGasStation, + contentDescription = if (v.isElectric) "Électrique" else "Essence", + tint = if (v.isElectric) ElectricBlue else GasGreen, + modifier = Modifier.size(32.dp), + ) + Spacer(Modifier.width(12.dp)) + + // Vehicle info + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "#${v.carNo}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(8.dp)) + Text( + "${v.carBrand} ${v.carModel}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + v.carColor, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.EventSeat, + contentDescription = "Places", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + " ${v.carSeatNb}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (v.energyLevel != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.BatteryChargingFull, + contentDescription = "Batterie", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + " ${v.energyLevel}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Distance chip + SuggestionChip( + onClick = onClick, + label = { + Text( + if (vehicle.distanceMeters < 1000) + "${vehicle.distanceMeters.toInt()} m" + else + String.format("%.1f km", vehicle.distanceMeters / 1000), + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) + } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleDetailSheet.kt b/android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleDetailSheet.kt new file mode 100644 index 0000000..46366f2 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleDetailSheet.kt @@ -0,0 +1,188 @@ +package com.communauto.tools.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.ElectricCar +import androidx.compose.material.icons.filled.EventSeat +import androidx.compose.material.icons.filled.LocalGasStation +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.communauto.tools.data.model.Accessories +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.data.model.statusLabel +import com.communauto.tools.ui.theme.ElectricBlue +import com.communauto.tools.ui.theme.GasGreen + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun VehicleDetailSheet( + vehicle: VehicleWithDistance, + isAuthenticated: Boolean, + onDismiss: () -> Unit, + onBook: () -> Unit, +) { + val v = vehicle.vehicle + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(bottom = 32.dp), + ) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (v.isElectric) Icons.Default.ElectricCar else Icons.Default.LocalGasStation, + contentDescription = null, + tint = if (v.isElectric) ElectricBlue else GasGreen, + modifier = Modifier.size(40.dp), + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + "#${v.carNo}", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + "${v.carBrand} ${v.carModel}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Details grid + DetailRow(Icons.Default.Palette, "Couleur", v.carColor) + DetailRow(Icons.Default.EventSeat, "Places", "${v.carSeatNb}") + DetailRow( + if (v.isElectric) Icons.Default.ElectricCar else Icons.Default.LocalGasStation, + "Type", + if (v.isElectric) "Électrique" else "Essence", + ) + if (v.energyLevel != null) { + DetailRow(Icons.Default.BatteryChargingFull, "Batterie", "${v.energyLevel}%") + } + DetailRow( + Icons.Default.LocationOn, + "Distance", + if (vehicle.distanceMeters < 1000) + "${vehicle.distanceMeters.toInt()} m" + else + String.format("%.1f km", vehicle.distanceMeters / 1000), + ) + DetailRow(Icons.Default.Bookmark, "Statut", v.statusLabel()) + + if (v.isVehicleReturnFlex) { + Spacer(Modifier.height(8.dp)) + AssistChip( + onClick = {}, + label = { Text("Retour Flex") }, + ) + } + + // Accessories + val accessories = Accessories.names(v.carAccessories) + if (accessories.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text( + "Accessoires", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + accessories.forEach { name -> + AssistChip( + onClick = {}, + label = { Text(name, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + } + + // Book button + if (isAuthenticated && v.bookingStatus == 0) { + Spacer(Modifier.height(20.dp)) + Button( + onClick = onBook, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("Réserver ce véhicule") + } + } + } + } +} + +@Composable +private fun DetailRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(80.dp), + ) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/navigation/AppNavigation.kt b/android-native/app/src/main/java/com/communauto/tools/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..04d0ed4 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/navigation/AppNavigation.kt @@ -0,0 +1,159 @@ +package com.communauto.tools.ui.navigation + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Radar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.communauto.tools.ui.screen.LoginScreen +import com.communauto.tools.ui.screen.RadarScreen +import com.communauto.tools.ui.screen.VehicleListScreen +import com.communauto.tools.viewmodel.AuthViewModel +import com.communauto.tools.viewmodel.RadarViewModel +import com.communauto.tools.viewmodel.VehicleViewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority + +enum class Screen(val route: String, val label: String, val icon: ImageVector) { + Vehicles("vehicles", "Véhicules", Icons.Default.DirectionsCar), + Radar("radar", "Radar", Icons.Default.Radar), +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun AppNavigation(authViewModel: AuthViewModel) { + val isAuthenticated by authViewModel.isAuthenticated.collectAsState() + + if (!isAuthenticated) { + LoginScreen( + authViewModel = authViewModel, + onLoginSuccess = { /* state-driven: isAuthenticated flips → shows main */ }, + ) + return + } + + val navController = rememberNavController() + val vehicleViewModel: VehicleViewModel = viewModel() + val radarViewModel: RadarViewModel = viewModel() + + val context = LocalContext.current + + // Location permissions + val locationPermissions = rememberMultiplePermissionsState( + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + ) + + LaunchedEffect(Unit) { + if (!locationPermissions.allPermissionsGranted) { + locationPermissions.launchMultiplePermissionRequest() + } + } + + // Get user location + LaunchedEffect(locationPermissions.allPermissionsGranted) { + if (locationPermissions.allPermissionsGranted) { + requestLocation(context) { lat, lng -> + vehicleViewModel.updateLocation(lat, lng) + radarViewModel.updateLocation(lat, lng) + } + } + vehicleViewModel.fetchVehicles() + radarViewModel.fetchVehicles() + } + + Scaffold( + bottomBar = { + NavigationBar { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + Screen.entries.forEach { screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = screen.label) }, + label = { Text(screen.label) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + ) + } + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Vehicles.route, + modifier = Modifier.padding(innerPadding), + ) { + composable(Screen.Vehicles.route) { + VehicleListScreen( + viewModel = vehicleViewModel, + isAuthenticated = isAuthenticated, + onBookVehicle = { carId -> + // Booking handled via ViewModel + }, + ) + } + composable(Screen.Radar.route) { + RadarScreen( + viewModel = radarViewModel, + isAuthenticated = isAuthenticated, + ) + } + } + } +} + +@SuppressLint("MissingPermission") +private fun requestLocation( + context: Context, + onLocation: (Double, Double) -> Unit, +) { + val fusedClient = LocationServices.getFusedLocationProviderClient(context) + fusedClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { location -> + if (location != null) { + onLocation(location.latitude, location.longitude) + } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/screen/LoginScreen.kt b/android-native/app/src/main/java/com/communauto/tools/ui/screen/LoginScreen.kt new file mode 100644 index 0000000..0944aac --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/screen/LoginScreen.kt @@ -0,0 +1,243 @@ +package com.communauto.tools.ui.screen + +import android.graphics.Bitmap +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Login +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.communauto.tools.data.auth.CookieEntry +import com.communauto.tools.viewmodel.AuthViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + authViewModel: AuthViewModel, + onLoginSuccess: () -> Unit, +) { + val showWebView by authViewModel.showWebView.collectAsState() + val isAuthenticated by authViewModel.isAuthenticated.collectAsState() + val cookieStatus by authViewModel.cookieStatus.collectAsState() + + if (isAuthenticated) { + onLoginSuccess() + return + } + + if (showWebView) { + LoginWebView( + onPageFinished = { url -> authViewModel.onWebViewPageFinished(url) }, + onDismiss = { authViewModel.dismissWebView() }, + ) + return + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Communauto") }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.DirectionsCar, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Text( + "Communauto", + style = MaterialTheme.typography.headlineLarge, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Connectez-vous pour accéder aux véhicules", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Vous serez redirigé vers le site de Communauto.\n" + + "Après la connexion, les cookies de session seront\n" + + "automatiquement capturés.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(32.dp)) + Button( + onClick = { authViewModel.startLogin() }, + modifier = Modifier.fillMaxWidth(0.7f), + ) { + Icon(Icons.Default.Login, contentDescription = null) + Spacer(Modifier.size(8.dp)) + Text("Se connecter") + } + + AnimatedVisibility(visible = cookieStatus.isNotEmpty()) { + CookieStatusCard(cookieStatus) + } + } + } +} + +@Composable +private fun CookieStatusCard(entries: List) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(Modifier.padding(16.dp)) { + Text( + "Cookies requis", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + entries.forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 2.dp), + ) { + Icon( + if (entry.present) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (entry.present) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + entry.name, + style = MaterialTheme.typography.bodyMedium, + ) + if (entry.present && entry.preview != null) { + Spacer(Modifier.size(8.dp)) + Text( + "= ${entry.preview}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoginWebView( + onPageFinished: (String) -> Unit, + onDismiss: () -> Unit, +) { + var loading by remember { mutableStateOf(true) } + var webView by remember { mutableStateOf(null) } + + BackHandler { + val wv = webView + if (wv != null && wv.canGoBack()) { + wv.goBack() + } else { + onDismiss() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Connexion Communauto") }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Fermer") + } + }, + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + if (loading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + loading = true + } + + override fun onPageFinished(view: WebView?, url: String?) { + loading = false + url?.let { onPageFinished(it) } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean = false + } + + loadUrl("https://quebec.client.reservauto.net/bookCar") + webView = this + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/screen/RadarScreen.kt b/android-native/app/src/main/java/com/communauto/tools/ui/screen/RadarScreen.kt new file mode 100644 index 0000000..24394d4 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/screen/RadarScreen.kt @@ -0,0 +1,401 @@ +package com.communauto.tools.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ElectricCar +import androidx.compose.material.icons.filled.LocalGasStation +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.ui.theme.ElectricBlue +import com.communauto.tools.ui.theme.GasGreen +import com.communauto.tools.ui.theme.VehicleGray +import com.communauto.tools.viewmodel.FuelFilter +import com.communauto.tools.viewmodel.RadarViewModel +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.Circle +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.rememberCameraPositionState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RadarScreen( + viewModel: RadarViewModel, + isAuthenticated: Boolean, +) { + val userLat by viewModel.userLat.collectAsState() + val userLng by viewModel.userLng.collectAsState() + val radius by viewModel.radius.collectAsState() + val fuelFilter by viewModel.fuelFilter.collectAsState() + val vehiclesInRadius by viewModel.vehiclesInRadius.collectAsState() + val allVehicles by viewModel.allFilteredVehicles.collectAsState() + val loading by viewModel.loading.collectAsState() + val refreshIntervalSec by viewModel.refreshIntervalSec.collectAsState() + val countdown by viewModel.countdown.collectAsState() + val autoBookActive by viewModel.autoBookActive.collectAsState() + val autoBookMessage by viewModel.autoBookMessage.collectAsState() + val autoBookSuccess by viewModel.autoBookSuccess.collectAsState() + + val userPosition = LatLng(userLat, userLng) + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(userPosition, 15f) + } + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + ), + ) + + BottomSheetScaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = { Text("Radar") }, + actions = { + if (loading) { + Text( + "...", + modifier = Modifier.padding(end = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { viewModel.fetchVehicles() }) { + Icon(Icons.Default.Refresh, "Rafraîchir") + } + }, + ) + }, + sheetPeekHeight = 200.dp, + sheetContent = { + RadarControls( + radius = radius, + onRadiusChange = { viewModel.setRadius(it) }, + fuelFilter = fuelFilter, + onFuelFilterChange = { viewModel.setFuelFilter(it) }, + vehiclesInRadius = vehiclesInRadius, + refreshIntervalSec = refreshIntervalSec, + countdown = countdown, + onRefreshIntervalChange = { viewModel.setRefreshInterval(it) }, + isAuthenticated = isAuthenticated, + autoBookActive = autoBookActive, + autoBookMessage = autoBookMessage, + autoBookSuccess = autoBookSuccess, + onStartAutoBook = { viewModel.startAutoBook() }, + onStopAutoBook = { viewModel.stopAutoBook() }, + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = MapProperties(isMyLocationEnabled = false), + uiSettings = MapUiSettings(zoomControlsEnabled = false), + ) { + // User position circle + Circle( + center = userPosition, + radius = 15.0, + fillColor = androidx.compose.ui.graphics.Color(0xFF4285F4), + strokeColor = androidx.compose.ui.graphics.Color.White, + strokeWidth = 4f, + ) + + // Radius circle + Circle( + center = userPosition, + radius = radius.toDouble(), + fillColor = androidx.compose.ui.graphics.Color(0x144285F4), + strokeColor = androidx.compose.ui.graphics.Color(0xFF4285F4), + strokeWidth = 2f, + ) + + // Vehicle markers + allVehicles.forEach { vwd -> + val v = vwd.vehicle + val inRadius = vwd.distanceMeters <= radius + val hue = when { + !inRadius -> BitmapDescriptorFactory.HUE_ORANGE + v.isElectric -> BitmapDescriptorFactory.HUE_AZURE + else -> BitmapDescriptorFactory.HUE_GREEN + } + + Marker( + state = MarkerState(position = LatLng(v.latitude, v.longitude)), + title = "#${v.carNo} ${v.carBrand} ${v.carModel}", + snippet = buildString { + append("${v.carColor} · ${v.carSeatNb} places") + if (v.isElectric) append(" · Électrique") + if (v.energyLevel != null) append(" · ${v.energyLevel}%") + append(" · ${vwd.distanceMeters.toInt()} m") + }, + icon = BitmapDescriptorFactory.defaultMarker(hue), + alpha = if (inRadius) 1f else 0.4f, + ) + } + } + + // FAB: center on user + FloatingActionButton( + onClick = { + cameraPositionState.move( + CameraUpdateFactory.newLatLngZoom(userPosition, 15f) + ) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 210.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) { + Icon(Icons.Default.MyLocation, "Ma position") + } + } + } +} + +@Composable +private fun RadarControls( + radius: Float, + onRadiusChange: (Float) -> Unit, + fuelFilter: FuelFilter, + onFuelFilterChange: (FuelFilter) -> Unit, + vehiclesInRadius: List, + refreshIntervalSec: Int, + countdown: Int, + onRefreshIntervalChange: (Int) -> Unit, + isAuthenticated: Boolean, + autoBookActive: Boolean, + autoBookMessage: String?, + autoBookSuccess: Boolean, + onStartAutoBook: () -> Unit, + onStopAutoBook: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + // Radius slider + Text( + "Rayon : ${radius.toInt()} m", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Slider( + value = radius, + onValueChange = onRadiusChange, + valueRange = 100f..5000f, + steps = 49, + ) + + // Fuel filter + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = fuelFilter == FuelFilter.ALL, + onClick = { onFuelFilterChange(FuelFilter.ALL) }, + label = { Text("Tous") }, + ) + FilterChip( + selected = fuelFilter == FuelFilter.ELECTRIC, + onClick = { onFuelFilterChange(FuelFilter.ELECTRIC) }, + label = { Text("Électrique") }, + leadingIcon = if (fuelFilter == FuelFilter.ELECTRIC) { + { Icon(Icons.Default.ElectricCar, null, Modifier.size(16.dp)) } + } else null, + ) + FilterChip( + selected = fuelFilter == FuelFilter.GAS, + onClick = { onFuelFilterChange(FuelFilter.GAS) }, + label = { Text("Essence") }, + leadingIcon = if (fuelFilter == FuelFilter.GAS) { + { Icon(Icons.Default.LocalGasStation, null, Modifier.size(16.dp)) } + } else null, + ) + } + + Spacer(Modifier.height(4.dp)) + Text( + "${vehiclesInRadius.size} véhicule${if (vehiclesInRadius.size != 1) "s" else ""} dans le rayon", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Auto-refresh + Spacer(Modifier.height(8.dp)) + Text( + "Rafraîchissement", + style = MaterialTheme.typography.labelLarge, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf(15, 30, 60, 90).forEach { sec -> + FilterChip( + selected = refreshIntervalSec == sec, + onClick = { onRefreshIntervalChange(sec) }, + label = { Text("${sec}s") }, + ) + } + FilterChip( + selected = refreshIntervalSec == 0, + onClick = { onRefreshIntervalChange(0) }, + label = { Text("Off") }, + ) + } + if (refreshIntervalSec > 0) { + Text( + "Prochain refresh dans ${countdown}s", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Auto-book + if (isAuthenticated) { + Spacer(Modifier.height(12.dp)) + if (!autoBookActive) { + Button( + onClick = onStartAutoBook, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + ), + ) { + Text("Auto-réserver (${radius.toInt()} m)") + } + } else { + Button( + onClick = onStopAutoBook, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Arrêter auto-réservation") + } + Text( + "En attente d'un véhicule dans ${radius.toInt()} m...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + autoBookMessage?.let { msg -> + Spacer(Modifier.height(4.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = if (autoBookSuccess) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.errorContainer, + ), + ) { + Text( + msg, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + // Vehicle list in radius + Spacer(Modifier.height(12.dp)) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + items(vehiclesInRadius, key = { it.vehicle.carId }) { vwd -> + val v = vwd.vehicle + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (v.isElectric) Icons.Default.ElectricCar else Icons.Default.LocalGasStation, + contentDescription = null, + tint = if (v.isElectric) ElectricBlue else GasGreen, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "#${v.carNo}", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(6.dp)) + Text( + "${v.carBrand} ${v.carModel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + "${vwd.distanceMeters.toInt()} m", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/screen/VehicleListScreen.kt b/android-native/app/src/main/java/com/communauto/tools/ui/screen/VehicleListScreen.kt new file mode 100644 index 0000000..3006c20 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/screen/VehicleListScreen.kt @@ -0,0 +1,220 @@ +package com.communauto.tools.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.ui.component.VehicleCard +import com.communauto.tools.ui.component.VehicleDetailSheet +import com.communauto.tools.viewmodel.FuelFilter +import com.communauto.tools.viewmodel.SortMode +import com.communauto.tools.viewmodel.VehicleViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VehicleListScreen( + viewModel: VehicleViewModel, + isAuthenticated: Boolean, + onBookVehicle: (Int) -> Unit, +) { + val vehicles by viewModel.vehicles.collectAsState() + val loading by viewModel.loading.collectAsState() + val error by viewModel.error.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val sortMode by viewModel.sortMode.collectAsState() + val fuelFilter by viewModel.fuelFilter.collectAsState() + val selectedVehicle by viewModel.selectedVehicle.collectAsState() + + var searchActive by remember { mutableStateOf(false) } + var sortMenuExpanded by remember { mutableStateOf(false) } + + // Detail bottom sheet + selectedVehicle?.let { vehicle -> + VehicleDetailSheet( + vehicle = vehicle, + isAuthenticated = isAuthenticated, + onDismiss = { viewModel.selectVehicle(null) }, + onBook = { + onBookVehicle(vehicle.vehicle.carId) + viewModel.selectVehicle(null) + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Véhicules") }, + actions = { + // Sort menu + Box { + IconButton(onClick = { sortMenuExpanded = true }) { + Icon(Icons.Default.FilterList, "Trier") + } + SortDropdown( + expanded = sortMenuExpanded, + currentSort = sortMode, + onSelect = { viewModel.setSortMode(it); sortMenuExpanded = false }, + onDismiss = { sortMenuExpanded = false }, + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + // Search bar + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = searchQuery, + onQueryChange = { viewModel.setSearchQuery(it) }, + onSearch = { searchActive = false }, + expanded = false, + onExpandedChange = {}, + placeholder = { Text("Rechercher...") }, + leadingIcon = { Icon(Icons.Default.Search, "Rechercher") }, + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) {} + + // Filter chips + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = fuelFilter == FuelFilter.ALL, + onClick = { viewModel.setFuelFilter(FuelFilter.ALL) }, + label = { Text("Tous") }, + ) + FilterChip( + selected = fuelFilter == FuelFilter.ELECTRIC, + onClick = { viewModel.setFuelFilter(FuelFilter.ELECTRIC) }, + label = { Text("Électrique") }, + ) + FilterChip( + selected = fuelFilter == FuelFilter.GAS, + onClick = { viewModel.setFuelFilter(FuelFilter.GAS) }, + label = { Text("Essence") }, + ) + } + + // Count + Text( + "${vehicles.size} véhicule${if (vehicles.size > 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + + // Vehicle list + PullToRefreshBox( + isRefreshing = loading, + onRefresh = { viewModel.fetchVehicles() }, + modifier = Modifier.fillMaxSize(), + ) { + if (error != null && vehicles.isEmpty()) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + error ?: "", + color = MaterialTheme.colorScheme.error, + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(vehicles, key = { it.vehicle.carId }) { vehicle -> + VehicleCard( + vehicle = vehicle, + isSelected = selectedVehicle?.vehicle?.carId == vehicle.vehicle.carId, + onClick = { viewModel.selectVehicle(vehicle) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun SortDropdown( + expanded: Boolean, + currentSort: SortMode, + onSelect: (SortMode) -> Unit, + onDismiss: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + SortMode.entries.forEach { mode -> + DropdownMenuItem( + text = { + Text( + when (mode) { + SortMode.DISTANCE -> "Distance" + SortMode.NUMBER -> "Numéro" + SortMode.BATTERY -> "Batterie" + SortMode.BRAND -> "Marque" + }, + ) + }, + onClick = { onSelect(mode) }, + leadingIcon = if (currentSort == mode) { + { Text("✓") } + } else null, + ) + } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/theme/Color.kt b/android-native/app/src/main/java/com/communauto/tools/ui/theme/Color.kt new file mode 100644 index 0000000..5ab0633 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/theme/Color.kt @@ -0,0 +1,15 @@ +package com.communauto.tools.ui.theme + +import androidx.compose.ui.graphics.Color + +// Communauto brand-inspired palette +val CommunautoPrimary = Color(0xFF1B6B3A) // Green +val CommunautoSecondary = Color(0xFF4A90D9) // Blue +val CommunautoTertiary = Color(0xFFF5A623) // Amber/warning + +val ElectricBlue = Color(0xFF0D6EFD) +val GasGreen = Color(0xFF28A745) +val VehicleGray = Color(0xFFAAAAAA) + +val SurfaceLight = Color(0xFFFBFDF8) +val SurfaceDark = Color(0xFF1A1C19) diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/theme/Theme.kt b/android-native/app/src/main/java/com/communauto/tools/ui/theme/Theme.kt new file mode 100644 index 0000000..b8ce762 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/theme/Theme.kt @@ -0,0 +1,49 @@ +package com.communauto.tools.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = CommunautoPrimary, + secondary = CommunautoSecondary, + tertiary = CommunautoTertiary, + surface = SurfaceLight, +) + +private val DarkColorScheme = darkColorScheme( + primary = CommunautoPrimary, + secondary = CommunautoSecondary, + tertiary = CommunautoTertiary, + surface = SurfaceDark, +) + +@Composable +fun CommunautoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + // Material You dynamic colors (Android 12+) + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/android-native/app/src/main/java/com/communauto/tools/ui/theme/Type.kt b/android-native/app/src/main/java/com/communauto/tools/ui/theme/Type.kt new file mode 100644 index 0000000..bc2f15c --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/ui/theme/Type.kt @@ -0,0 +1,47 @@ +package com.communauto.tools.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 22.sp, + ), + bodyLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + ), +) diff --git a/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt b/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt new file mode 100644 index 0000000..d59a4ce --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt @@ -0,0 +1,24 @@ +package com.communauto.tools.util + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** Montreal downtown — default fallback location. */ +const val DEFAULT_LAT = 45.5017 +const val DEFAULT_LNG = -73.5673 + +/** Haversine distance in meters between two lat/lng points. */ +fun haversineDistance( + lat1: Double, lng1: Double, + lat2: Double, lng2: Double, +): Double { + val r = 6_371_000.0 // Earth radius in meters + val dLat = Math.toRadians(lat2 - lat1) + val dLng = Math.toRadians(lng2 - lng1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLng / 2) * sin(dLng / 2) + return r * 2 * atan2(sqrt(a), sqrt(1 - a)) +} diff --git a/android-native/app/src/main/java/com/communauto/tools/viewmodel/AuthViewModel.kt b/android-native/app/src/main/java/com/communauto/tools/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..b880885 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/viewmodel/AuthViewModel.kt @@ -0,0 +1,66 @@ +package com.communauto.tools.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.communauto.tools.CommunautoApp +import com.communauto.tools.data.auth.AuthManager +import com.communauto.tools.data.auth.CookieEntry +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class AuthViewModel(application: Application) : AndroidViewModel(application) { + + private val authManager: AuthManager = + (application as CommunautoApp).authManager + + val isAuthenticated = authManager.isAuthenticated + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + private val _cookieStatus = MutableStateFlow>(emptyList()) + val cookieStatus = _cookieStatus.asStateFlow() + + private val _showWebView = MutableStateFlow(false) + val showWebView = _showWebView.asStateFlow() + + init { + viewModelScope.launch { authManager.init() } + } + + fun startLogin() { + _showWebView.value = true + } + + fun onWebViewPageFinished(url: String) { + // After the user navigates through login, check for cookies + if (authManager.hasCookies()) { + _cookieStatus.value = authManager.getCookieStatus() + viewModelScope.launch { + authManager.onLoginSuccess() + } + _showWebView.value = false + } + } + + fun dismissWebView() { + _showWebView.value = false + // Check if cookies were obtained while browsing + if (authManager.hasCookies()) { + _cookieStatus.value = authManager.getCookieStatus() + viewModelScope.launch { authManager.onLoginSuccess() } + } + } + + fun refreshCookieStatus() { + _cookieStatus.value = authManager.getCookieStatus() + } + + fun getUid(): Int? = authManager.getUid() + + fun logout() { + viewModelScope.launch { authManager.logout() } + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/viewmodel/RadarViewModel.kt b/android-native/app/src/main/java/com/communauto/tools/viewmodel/RadarViewModel.kt new file mode 100644 index 0000000..34891a6 --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/viewmodel/RadarViewModel.kt @@ -0,0 +1,188 @@ +package com.communauto.tools.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.communauto.tools.CommunautoApp +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.data.repository.VehicleRepository +import com.communauto.tools.util.DEFAULT_LAT +import com.communauto.tools.util.DEFAULT_LNG +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class RadarViewModel(application: Application) : AndroidViewModel(application) { + + private val repository: VehicleRepository = + (application as CommunautoApp).vehicleRepository + + private val authManager = (application as CommunautoApp).authManager + + private val _allVehicles = MutableStateFlow>(emptyList()) + + private val _radius = MutableStateFlow(1000f) + val radius = _radius.asStateFlow() + + private val _fuelFilter = MutableStateFlow(FuelFilter.ALL) + val fuelFilter = _fuelFilter.asStateFlow() + + private val _loading = MutableStateFlow(false) + val loading = _loading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + private val _userLat = MutableStateFlow(DEFAULT_LAT) + val userLat = _userLat.asStateFlow() + private val _userLng = MutableStateFlow(DEFAULT_LNG) + val userLng = _userLng.asStateFlow() + + // Auto-refresh + private val _refreshIntervalSec = MutableStateFlow(0) + val refreshIntervalSec = _refreshIntervalSec.asStateFlow() + private val _countdown = MutableStateFlow(0) + val countdown = _countdown.asStateFlow() + private var refreshJob: Job? = null + + // Auto-book + private val _autoBookActive = MutableStateFlow(false) + val autoBookActive = _autoBookActive.asStateFlow() + private val _autoBookMessage = MutableStateFlow(null) + val autoBookMessage = _autoBookMessage.asStateFlow() + private val _autoBookSuccess = MutableStateFlow(false) + val autoBookSuccess = _autoBookSuccess.asStateFlow() + + val vehiclesInRadius = combine( + _allVehicles, _radius, _fuelFilter + ) { vehicles, r, fuel -> + vehicles + .filter { it.distanceMeters <= r } + .filter { v -> + when (fuel) { + FuelFilter.ALL -> true + FuelFilter.ELECTRIC -> v.vehicle.isElectric + FuelFilter.GAS -> !v.vehicle.isElectric + } + } + .sortedBy { it.distanceMeters } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val allFilteredVehicles = combine( + _allVehicles, _fuelFilter + ) { vehicles, fuel -> + vehicles.filter { v -> + when (fuel) { + FuelFilter.ALL -> true + FuelFilter.ELECTRIC -> v.vehicle.isElectric + FuelFilter.GAS -> !v.vehicle.isElectric + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun setRadius(r: Float) { _radius.value = r } + fun setFuelFilter(f: FuelFilter) { _fuelFilter.value = f } + + fun updateLocation(lat: Double, lng: Double) { + _userLat.value = lat + _userLng.value = lng + } + + fun fetchVehicles() { + viewModelScope.launch { + _loading.value = true + _error.value = null + try { + _allVehicles.value = repository.getVehiclesWithDistance( + _userLat.value, _userLng.value + ) + } catch (e: Exception) { + _error.value = "Erreur lors du chargement" + } finally { + _loading.value = false + } + } + } + + fun setRefreshInterval(seconds: Int) { + _refreshIntervalSec.value = seconds + refreshJob?.cancel() + if (seconds > 0) { + _countdown.value = seconds + refreshJob = viewModelScope.launch { + while (true) { + delay(1000) + _countdown.value-- + if (_countdown.value <= 0) { + _countdown.value = seconds + fetchVehicles() + // Try auto-book after refresh + if (_autoBookActive.value) tryAutoBook() + } + } + } + } + } + + fun startAutoBook() { + _autoBookActive.value = true + _autoBookMessage.value = null + _autoBookSuccess.value = false + if (_refreshIntervalSec.value == 0) setRefreshInterval(15) + viewModelScope.launch { + fetchVehicles() + tryAutoBook() + } + } + + fun stopAutoBook() { + _autoBookActive.value = false + } + + private suspend fun tryAutoBook() { + if (!_autoBookActive.value) return + + val uid = authManager.getUid() + if (uid == null) { + _autoBookMessage.value = "uid introuvable dans les cookies" + _autoBookActive.value = false + return + } + + val cars = vehiclesInRadius.value + if (cars.isEmpty()) return + + for (car in cars) { + try { + val result = repository.createBooking(uid, car.vehicle.carId) + if (result.success) { + _autoBookMessage.value = + "Réservé: #${car.vehicle.carNo} — ${car.vehicle.carBrand} ${car.vehicle.carModel} (${car.distanceMeters.toInt()} m)" + _autoBookSuccess.value = true + _autoBookActive.value = false + return + } + if (result.errorType == 3) { + _autoBookMessage.value = "#${car.vehicle.carNo} limite atteinte, essai suivant..." + continue + } + _autoBookMessage.value = result.errorMessage ?: "Réservation échouée" + return + } catch (e: Exception) { + _autoBookMessage.value = e.message ?: "Erreur réseau" + return + } + } + _autoBookMessage.value = "Limite atteinte sur tous les véhicules dans le rayon" + } + + override fun onCleared() { + super.onCleared() + refreshJob?.cancel() + } +} diff --git a/android-native/app/src/main/java/com/communauto/tools/viewmodel/VehicleViewModel.kt b/android-native/app/src/main/java/com/communauto/tools/viewmodel/VehicleViewModel.kt new file mode 100644 index 0000000..cf7657c --- /dev/null +++ b/android-native/app/src/main/java/com/communauto/tools/viewmodel/VehicleViewModel.kt @@ -0,0 +1,107 @@ +package com.communauto.tools.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.communauto.tools.CommunautoApp +import com.communauto.tools.data.model.VehicleWithDistance +import com.communauto.tools.data.repository.VehicleRepository +import com.communauto.tools.util.DEFAULT_LAT +import com.communauto.tools.util.DEFAULT_LNG +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.launch + +enum class SortMode { DISTANCE, NUMBER, BATTERY, BRAND } +enum class FuelFilter { ALL, ELECTRIC, GAS } + +class VehicleViewModel(application: Application) : AndroidViewModel(application) { + + private val repository: VehicleRepository = + (application as CommunautoApp).vehicleRepository + + private val _vehicles = MutableStateFlow>(emptyList()) + + private val _loading = MutableStateFlow(false) + val loading = _loading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() + + private val _sortMode = MutableStateFlow(SortMode.DISTANCE) + val sortMode = _sortMode.asStateFlow() + + private val _fuelFilter = MutableStateFlow(FuelFilter.ALL) + val fuelFilter = _fuelFilter.asStateFlow() + + private val _userLat = MutableStateFlow(DEFAULT_LAT) + private val _userLng = MutableStateFlow(DEFAULT_LNG) + + private val _selectedVehicle = MutableStateFlow(null) + val selectedVehicle = _selectedVehicle.asStateFlow() + + val vehicles = combine( + _vehicles, _searchQuery, _sortMode, _fuelFilter + ) { vehicles, query, sort, fuel -> + vehicles + .filter { v -> + when (fuel) { + FuelFilter.ALL -> true + FuelFilter.ELECTRIC -> v.vehicle.isElectric + FuelFilter.GAS -> !v.vehicle.isElectric + } + } + .filter { v -> + if (query.isBlank()) true + else { + val q = query.lowercase() + v.vehicle.carNo.toString().contains(q) || + v.vehicle.carBrand.lowercase().contains(q) || + v.vehicle.carModel.lowercase().contains(q) || + v.vehicle.carColor.lowercase().contains(q) + } + } + .sortedWith( + when (sort) { + SortMode.DISTANCE -> compareBy { it.distanceMeters } + SortMode.NUMBER -> compareBy { it.vehicle.carNo } + SortMode.BATTERY -> compareByDescending { it.vehicle.energyLevel ?: -1 } + SortMode.BRAND -> compareBy { it.vehicle.carBrand } + } + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun updateLocation(lat: Double, lng: Double) { + _userLat.value = lat + _userLng.value = lng + // Re-fetch with new location to recalculate distances + fetchVehicles() + } + + fun setSearchQuery(query: String) { _searchQuery.value = query } + fun setSortMode(mode: SortMode) { _sortMode.value = mode } + fun setFuelFilter(filter: FuelFilter) { _fuelFilter.value = filter } + fun selectVehicle(vehicle: VehicleWithDistance?) { _selectedVehicle.value = vehicle } + + fun fetchVehicles() { + viewModelScope.launch { + _loading.value = true + _error.value = null + try { + _vehicles.value = repository.getVehiclesWithDistance( + _userLat.value, _userLng.value + ) + } catch (e: Exception) { + _error.value = "Erreur lors du chargement des véhicules" + } finally { + _loading.value = false + } + } + } +} diff --git a/android-native/app/src/main/res/values/strings.xml b/android-native/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..371949c --- /dev/null +++ b/android-native/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Communauto + diff --git a/android-native/app/src/main/res/values/themes.xml b/android-native/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..0b7a844 --- /dev/null +++ b/android-native/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android-native/build.gradle.kts b/android-native/build.gradle.kts new file mode 100644 index 0000000..cfd711d --- /dev/null +++ b/android-native/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} diff --git a/android-native/gradle.properties b/android-native/gradle.properties new file mode 100644 index 0000000..f101030 --- /dev/null +++ b/android-native/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true + +# Replace with your Google Maps API key +MAPS_API_KEY=YOUR_GOOGLE_MAPS_API_KEY_HERE diff --git a/android-native/gradle/wrapper/gradle-wrapper.properties b/android-native/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..81aa1c0 --- /dev/null +++ b/android-native/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-native/settings.gradle.kts b/android-native/settings.gradle.kts new file mode 100644 index 0000000..a08ad11 --- /dev/null +++ b/android-native/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "CommunautoNative" +include(":app") From afde6291c216473e7749cc802d3000d5e8d63907 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 05:45:49 +0000 Subject: [PATCH 2/5] ci: add GitHub Actions workflow for native Android APK build - Triggers on push to native-app and native-android branches, and PRs - Uses setup-java (17), setup-android, and gradle/actions/setup-gradle - Generates wrapper on-the-fly (no wrapper jar committed) - Uploads debug APK as artifact - Supports MAPS_API_KEY from repo secret or env var https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp --- .github/workflows/build-native-apk.yml | 59 ++++++++++++++++++++++++++ android-native/app/build.gradle.kts | 6 ++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/build-native-apk.yml diff --git a/.github/workflows/build-native-apk.yml b/.github/workflows/build-native-apk.yml new file mode 100644 index 0000000..81fcd41 --- /dev/null +++ b/.github/workflows/build-native-apk.yml @@ -0,0 +1,59 @@ +name: Build Native Android APK + +on: + push: + branches: + - 'claude/native-android-*' + - 'native-app' + paths: + - 'android-native/**' + - '.github/workflows/build-native-apk.yml' + pull_request: + branches: [master] + paths: + - 'android-native/**' + - '.github/workflows/build-native-apk.yml' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + defaults: + run: + working-directory: android-native + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: 8.11.1 + + - name: Generate Gradle wrapper + run: gradle wrapper --gradle-version 8.11.1 + + - name: Build debug APK + run: ./gradlew assembleDebug + env: + # Maps API key (optional — set as repo secret for production) + MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: communauto-native-debug + path: android-native/app/build/outputs/apk/debug/app-debug.apk + retention-days: 30 diff --git a/android-native/app/build.gradle.kts b/android-native/app/build.gradle.kts index 1c461aa..5df3d7f 100644 --- a/android-native/app/build.gradle.kts +++ b/android-native/app/build.gradle.kts @@ -15,9 +15,11 @@ android { versionCode = 1 versionName = "1.0" - // Google Maps API key — set in gradle.properties or local.properties + // Google Maps API key — gradle.properties, local.properties, or MAPS_API_KEY env var manifestPlaceholders["MAPS_API_KEY"] = - project.findProperty("MAPS_API_KEY") as? String ?: "" + project.findProperty("MAPS_API_KEY") as? String + ?: System.getenv("MAPS_API_KEY") + ?: "" } buildTypes { From 70a93b6b75094176af05ccb31a7bd2b53adc7b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 05:54:29 +0000 Subject: [PATCH 3/5] fix: use android platform theme instead of Material3 XML reference The Material3 XML theme parent (Theme.Material3.DayNight.NoActionBar) requires the MDC-Android library in XML resources. Since all Material 3 theming is handled by Jetpack Compose at runtime, use the platform android:Theme.Material.Light.NoActionBar instead. https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp --- android-native/app/src/main/res/values/themes.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android-native/app/src/main/res/values/themes.xml b/android-native/app/src/main/res/values/themes.xml index 0b7a844..94d2d1c 100644 --- a/android-native/app/src/main/res/values/themes.xml +++ b/android-native/app/src/main/res/values/themes.xml @@ -1,7 +1,7 @@ - - From 9b2f864f87ec8f8b3404bbb4995c77ebbb25fa7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 06:02:28 +0000 Subject: [PATCH 4/5] fix: add launcher icon resources for native Android app Adds adaptive icon (car silhouette on Communauto green) and required mipmap/color resources to fix AAPT resource linking error. https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp --- .../main/res/drawable/ic_launcher_foreground.xml | 15 +++++++++++++++ .../main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 +++++ .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 +++++ android-native/app/src/main/res/values/colors.xml | 4 ++++ 4 files changed, 29 insertions(+) create mode 100644 android-native/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android-native/app/src/main/res/values/colors.xml diff --git a/android-native/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-native/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ed6e551 --- /dev/null +++ b/android-native/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android-native/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-native/app/src/main/res/values/colors.xml b/android-native/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..6dede91 --- /dev/null +++ b/android-native/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1B6B3A + From 200a60f2fb3be1ae270636512ba2ccf375524fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 06:05:09 +0000 Subject: [PATCH 5/5] fix: use idiomatic Kotlin math and add Gradle caching to CI - Replace java.lang.Math.toRadians() with pure Kotlin helper - Add Gradle cache to CI workflow for faster rebuilds - Add separate compileDebugKotlin step for fast failure feedback https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp --- .github/workflows/build-native-apk.yml | 7 ++++++- .../main/java/com/communauto/tools/util/LocationUtils.kt | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-native-apk.yml b/.github/workflows/build-native-apk.yml index 81fcd41..beb1806 100644 --- a/.github/workflows/build-native-apk.yml +++ b/.github/workflows/build-native-apk.yml @@ -41,14 +41,19 @@ jobs: uses: gradle/actions/setup-gradle@v4 with: gradle-version: 8.11.1 + cache-read-only: ${{ github.ref != 'refs/heads/native-app' }} - name: Generate Gradle wrapper run: gradle wrapper --gradle-version 8.11.1 + - name: Compile Kotlin (fast feedback) + run: ./gradlew compileDebugKotlin + env: + MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} + - name: Build debug APK run: ./gradlew assembleDebug env: - # Maps API key (optional — set as repo secret for production) MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} - name: Upload APK artifact diff --git a/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt b/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt index d59a4ce..65c5786 100644 --- a/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt +++ b/android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt @@ -5,6 +5,8 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +private fun toRadians(deg: Double): Double = deg * Math.PI / 180.0 + /** Montreal downtown — default fallback location. */ const val DEFAULT_LAT = 45.5017 const val DEFAULT_LNG = -73.5673 @@ -15,10 +17,10 @@ fun haversineDistance( lat2: Double, lng2: Double, ): Double { val r = 6_371_000.0 // Earth radius in meters - val dLat = Math.toRadians(lat2 - lat1) - val dLng = Math.toRadians(lng2 - lng1) + val dLat = toRadians(lat2 - lat1) + val dLng = toRadians(lng2 - lng1) val a = sin(dLat / 2) * sin(dLat / 2) + - cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + cos(toRadians(lat1)) * cos(toRadians(lat2)) * sin(dLng / 2) * sin(dLng / 2) return r * 2 * atan2(sqrt(a), sqrt(1 - a)) }