diff --git a/.gitignore b/.gitignore index eb634aa..202b661 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,17 @@ vendor/ coverage/ .cache/ tmp/ + +# ── Android / Gradle ── +*.iml +.gradle/ +local.properties +.navigation/ +captures/ +*.apk +*.aab +*.ap_ +*.dex +output.json +app/release/ +app/debug/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6228bf4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,107 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.google.services) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "pl.firmatpp.itstransport" + compileSdk = 35 + + defaultConfig { + applicationId = "pl.firmatpp.itstransport" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + isMinifyEnabled = false + buildConfigField( + "String", + "API_BASE_URL", + "\"https://itstransport.moghome53.top/api\"" + ) + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField( + "String", + "API_BASE_URL", + "\"https://api-intranet.firmatpp.pl/api\"" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + // Compose BOM + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + androidTestImplementation(libs.compose.ui.test.junit4) + + // Activity Compose + implementation(libs.activity.compose) + + // Navigation Compose + implementation(libs.navigation.compose) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + + // Retrofit + OkHttp + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + + // Security (EncryptedSharedPreferences) + implementation(libs.security.crypto) + + // Lifecycle + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + + // Core KTX + implementation(libs.core.ktx) + + // Firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) +} diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..d37b7fa --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,30 @@ +{ + "_NOTE": "PLACEHOLDER — Replace with the real google-services.json from Firebase Console (Project Settings > General > Your apps > Download google-services.json).", + "project_info": { + "project_number": "000000000000", + "project_id": "its-transport-placeholder", + "storage_bucket": "its-transport-placeholder.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000", + "android_client_info": { + "package_name": "pl.firmatpp.itstransport" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f3609a4 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,39 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Retrofit +-keepattributes Signature +-keepattributes Exceptions +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# Gson +-keepattributes *Annotation* +-keep class com.google.gson.stream.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** + +# Hilt +-keep class dagger.hilt.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; } + +# Firebase +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eb6696b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/pl/firmatpp/itstransport/ITSTransportApp.kt b/app/src/main/java/pl/firmatpp/itstransport/ITSTransportApp.kt new file mode 100644 index 0000000..c86a90f --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ITSTransportApp.kt @@ -0,0 +1,7 @@ +package pl.firmatpp.itstransport + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ITSTransportApp : Application() diff --git a/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt b/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt new file mode 100644 index 0000000..8938e2d --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt @@ -0,0 +1,28 @@ +package pl.firmatpp.itstransport + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint +import pl.firmatpp.itstransport.data.auth.TokenManager +import pl.firmatpp.itstransport.ui.navigation.AppNavigation +import pl.firmatpp.itstransport.ui.theme.ITSTransportTheme +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var tokenManager: TokenManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ITSTransportTheme { + AppNavigation(tokenManager = tokenManager) + } + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/AuthApi.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/AuthApi.kt new file mode 100644 index 0000000..fc5edb9 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/AuthApi.kt @@ -0,0 +1,23 @@ +package pl.firmatpp.itstransport.data.api + +import pl.firmatpp.itstransport.data.model.LoginRequest +import pl.firmatpp.itstransport.data.model.LoginResponse +import pl.firmatpp.itstransport.data.model.UserResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface AuthApi { + + @POST("auth/login") + suspend fun login(@Body request: LoginRequest): LoginResponse + + @GET("auth/user") + suspend fun getUser(): UserResponse + + @POST("auth/logout") + suspend fun logout() + + @POST("auth/refresh") + suspend fun refresh(): LoginResponse +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/AuthInterceptor.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/AuthInterceptor.kt new file mode 100644 index 0000000..3be7d58 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/AuthInterceptor.kt @@ -0,0 +1,46 @@ +package pl.firmatpp.itstransport.data.api + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Response +import pl.firmatpp.itstransport.data.auth.TokenManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthInterceptor @Inject constructor( + private val tokenManager: TokenManager, +) : Interceptor { + + companion object { + private const val TAG = "AuthInterceptor" + private const val HEADER_AUTHORIZATION = "Authorization" + private const val TOKEN_PREFIX = "Bearer " + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // Attach Bearer token if available + val token = tokenManager.getToken() + val request = if (token != null) { + Log.d(TAG, "Attaching Bearer token to request: ${originalRequest.url}") + originalRequest.newBuilder() + .header(HEADER_AUTHORIZATION, "$TOKEN_PREFIX$token") + .build() + } else { + Log.d(TAG, "No token available, proceeding without auth: ${originalRequest.url}") + originalRequest + } + + val response = chain.proceed(request) + + // Handle 401 — clear token, do NOT retry to avoid infinite loop + if (response.code == 401) { + Log.w(TAG, "Received 401 Unauthenticated for ${request.url} — clearing token") + tokenManager.clearToken() + } + + return response + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/DeviceTokenApi.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/DeviceTokenApi.kt new file mode 100644 index 0000000..f0b3f1e --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/DeviceTokenApi.kt @@ -0,0 +1,14 @@ +package pl.firmatpp.itstransport.data.api + +import retrofit2.http.Body +import retrofit2.http.HTTP +import retrofit2.http.POST + +interface DeviceTokenApi { + + @POST("device-tokens") + suspend fun registerToken(@Body body: Map) + + @HTTP(method = "DELETE", path = "device-tokens", hasBody = true) + suspend fun unregisterToken(@Body body: Map) +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/SchedulerApi.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/SchedulerApi.kt new file mode 100644 index 0000000..beb1e8c --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/SchedulerApi.kt @@ -0,0 +1,18 @@ +package pl.firmatpp.itstransport.data.api + +import pl.firmatpp.itstransport.data.model.SchedulerDriverResponse +import pl.firmatpp.itstransport.data.model.SchedulerEventResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface SchedulerApi { + + @GET("scheduler/events") + suspend fun getEvents( + @Query("start") start: String, + @Query("end") end: String, + ): List + + @GET("scheduler/resources") + suspend fun getResources(): List +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/ServiceApi.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/ServiceApi.kt new file mode 100644 index 0000000..3de37c0 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/ServiceApi.kt @@ -0,0 +1,20 @@ +package pl.firmatpp.itstransport.data.api + +import pl.firmatpp.itstransport.data.model.ServiceEventsResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface ServiceApi { + + @GET("service-events") + suspend fun getEvents( + @Query("page") page: Int, + @Query("per_page") perPage: Int, + @Query("search") search: String? = null, + @Query("source") source: String? = null, + @Query("date_from") dateFrom: String? = null, + @Query("date_to") dateTo: String? = null, + @Query("sort_by") sortBy: String? = null, + @Query("sort_direction") sortDirection: String? = null, + ): ServiceEventsResponse +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/ServiceWizardApi.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/ServiceWizardApi.kt new file mode 100644 index 0000000..6ac58ca --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/ServiceWizardApi.kt @@ -0,0 +1,31 @@ +package pl.firmatpp.itstransport.data.api + +import pl.firmatpp.itstransport.data.model.CreateServiceEventRequest +import pl.firmatpp.itstransport.data.model.CreateServiceEventResponse +import pl.firmatpp.itstransport.data.model.ServiceTypesResponse +import pl.firmatpp.itstransport.data.model.Trailer +import pl.firmatpp.itstransport.data.model.Truck +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface ServiceWizardApi { + + /** Returns a bare JSON array — no wrapper. */ + @GET("trucks/all") + suspend fun getTrucks(): List + + /** Returns a bare JSON array — no wrapper. */ + @GET("trailers/all") + suspend fun getTrailers(): List + + /** Returns wrapped { data: [...] }. */ + @GET("maintenance/service-types") + suspend fun getServiceTypes(): ServiceTypesResponse + + /** Creates a new service event. Returns 201 with { data: }. */ + @POST("service-events") + suspend fun createServiceEvent( + @Body request: CreateServiceEventRequest, + ): CreateServiceEventResponse +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/auth/AuthRepository.kt b/app/src/main/java/pl/firmatpp/itstransport/data/auth/AuthRepository.kt new file mode 100644 index 0000000..dadc774 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/auth/AuthRepository.kt @@ -0,0 +1,140 @@ +package pl.firmatpp.itstransport.data.auth + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessaging +import com.google.gson.Gson +import kotlinx.coroutines.tasks.await +import pl.firmatpp.itstransport.data.api.AuthApi +import pl.firmatpp.itstransport.data.model.ApiError +import pl.firmatpp.itstransport.data.model.LoginRequest +import pl.firmatpp.itstransport.data.model.UserResponse +import pl.firmatpp.itstransport.data.repository.DeviceTokenRepository +import retrofit2.HttpException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepository @Inject constructor( + private val authApi: AuthApi, + private val tokenManager: TokenManager, + private val deviceTokenRepository: DeviceTokenRepository, +) { + companion object { + private const val TAG = "AuthRepository" + } + + suspend fun login(email: String, password: String): Result { + return try { + val loginResponse = authApi.login(LoginRequest(email, password)) + tokenManager.saveToken(loginResponse.accessToken) + Log.d(TAG, "Login successful, token saved") + + // Register FCM token with backend after successful login + registerFcmToken() + + // Fetch user profile after login + val user = authApi.getUser() + Result.success(user) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "Login failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "Login failed with exception: ${e.javaClass.simpleName}") + Result.failure(e) + } + } + + suspend fun logout(): Result { + return try { + // Unregister FCM token before logout + unregisterFcmToken() + + authApi.logout() + tokenManager.clearToken() + Log.d(TAG, "Logout successful") + Result.success(Unit) + } catch (e: Exception) { + // Clear token locally even if remote logout fails + tokenManager.clearToken() + Log.w(TAG, "Logout remote call failed, token cleared locally: ${e.javaClass.simpleName}") + Result.success(Unit) + } + } + + private suspend fun registerFcmToken() { + try { + val fcmToken = FirebaseMessaging.getInstance().token.await() + val result = deviceTokenRepository.registerToken(fcmToken) + if (result.isSuccess) { + Log.d(TAG, "FCM token registered with backend") + } else { + Log.w(TAG, "FCM token registration failed: ${result.exceptionOrNull()?.message}") + } + } catch (e: Exception) { + // Non-fatal: push won't work but login should still succeed + Log.w(TAG, "Failed to get/register FCM token: ${e.javaClass.simpleName}") + } + } + + private suspend fun unregisterFcmToken() { + try { + val fcmToken = FirebaseMessaging.getInstance().token.await() + val result = deviceTokenRepository.unregisterToken(fcmToken) + if (result.isSuccess) { + Log.d(TAG, "FCM token unregistered from backend") + } else { + Log.w(TAG, "FCM token unregistration failed: ${result.exceptionOrNull()?.message}") + } + } catch (e: Exception) { + // Non-fatal: stale token will be cleaned up by backend on next send + Log.w(TAG, "Failed to get/unregister FCM token: ${e.javaClass.simpleName}") + } + } + + suspend fun getUser(): Result { + return try { + val user = authApi.getUser() + Result.success(user) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "Get user failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "Get user failed: ${e.javaClass.simpleName}") + Result.failure(e) + } + } + + fun isLoggedIn(): Boolean { + return tokenManager.hasToken() + } + + fun clearAuth() { + tokenManager.clearToken() + Log.d(TAG, "Auth cleared") + } + + private fun parseApiError(e: HttpException): ApiError? { + return try { + val errorBody = e.response()?.errorBody()?.string() + if (errorBody != null) { + Gson().fromJson(errorBody, ApiError::class.java)?.copy(status = e.code()) + } else { + null + } + } catch (_: Exception) { + null + } + } +} + +/** + * Custom exception wrapping HTTP errors with parsed API error details. + * Preserves the original HttpException as the cause for full stack trace access. + */ +class ApiException( + val code: Int, + val apiError: ApiError?, + cause: Throwable, +) : Exception(apiError?.message ?: "HTTP $code error", cause) diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/auth/TokenManager.kt b/app/src/main/java/pl/firmatpp/itstransport/data/auth/TokenManager.kt new file mode 100644 index 0000000..e8aa0bc --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/auth/TokenManager.kt @@ -0,0 +1,81 @@ +package pl.firmatpp.itstransport.data.auth + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + companion object { + private const val TAG = "TokenManager" + private const val PREFS_NAME = "its_transport_auth" + private const val ENCRYPTED_PREFS_NAME = "its_transport_auth_encrypted" + private const val KEY_ACCESS_TOKEN = "access_token" + } + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val prefs: SharedPreferences by lazy { createPreferences() } + + init { + // Deferred check — triggers lazy prefs init + _isLoggedIn.value = hasToken() + Log.d(TAG, "Initial isLoggedIn: ${_isLoggedIn.value}") + } + + private fun createPreferences(): SharedPreferences { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to create EncryptedSharedPreferences, falling back to regular prefs", e) + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + } + + @Synchronized + fun saveToken(token: String) { + prefs.edit().putString(KEY_ACCESS_TOKEN, token).apply() + _isLoggedIn.value = true + Log.d(TAG, "Token saved (non-null: true)") + } + + @Synchronized + fun getToken(): String? { + val token = prefs.getString(KEY_ACCESS_TOKEN, null) + Log.d(TAG, "Token retrieved (non-null: ${token != null})") + return token + } + + @Synchronized + fun clearToken() { + prefs.edit().remove(KEY_ACCESS_TOKEN).apply() + _isLoggedIn.value = false + Log.d(TAG, "Token cleared — isLoggedIn: false") + } + + @Synchronized + fun hasToken(): Boolean { + return getToken() != null + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/fcm/FCMService.kt b/app/src/main/java/pl/firmatpp/itstransport/data/fcm/FCMService.kt new file mode 100644 index 0000000..af3004b --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/fcm/FCMService.kt @@ -0,0 +1,116 @@ +package pl.firmatpp.itstransport.data.fcm + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import pl.firmatpp.itstransport.data.auth.TokenManager +import pl.firmatpp.itstransport.data.repository.DeviceTokenRepository + +class FCMService : FirebaseMessagingService() { + + companion object { + private const val TAG = "FCMService" + const val CHANNEL_ID = "its_transport_notifications" + private const val CHANNEL_NAME = "Powiadomienia ITS Transport" + private var notificationId = 0 + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface FCMServiceEntryPoint { + fun tokenManager(): TokenManager + fun deviceTokenRepository(): DeviceTokenRepository + } + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun getEntryPoint(): FCMServiceEntryPoint { + return EntryPointAccessors.fromApplication( + applicationContext, + FCMServiceEntryPoint::class.java, + ) + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "onNewToken: ${token.take(10)}…") + + val entryPoint = getEntryPoint() + if (entryPoint.tokenManager().hasToken()) { + serviceScope.launch { + val result = entryPoint.deviceTokenRepository().registerToken(token) + if (result.isSuccess) { + Log.d(TAG, "Re-registered new FCM token with backend") + } else { + Log.w(TAG, "Failed to re-register new FCM token: ${result.exceptionOrNull()?.message}") + } + } + } else { + Log.d(TAG, "User not logged in, skipping token registration") + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + Log.d(TAG, "onMessageReceived from: ${message.from}, data keys: ${message.data.keys}") + + val title = message.notification?.title + ?: message.data["title"] + ?: "ITS Transport" + val body = message.notification?.body + ?: message.data["body"] + ?: return // Nothing to display if no body + + showNotification(title, body) + } + + private fun showNotification(title: String, body: String) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setDefaults(NotificationCompat.DEFAULT_SOUND) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + try { + NotificationManagerCompat.from(this).notify(notificationId++, notification) + } catch (e: SecurityException) { + // POST_NOTIFICATIONS permission not granted — log and skip + Log.w(TAG, "Cannot show notification — POST_NOTIFICATIONS not granted") + } + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Powiadomienia o trasach i serwisach" + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + Log.d(TAG, "NotificationChannel '$CHANNEL_ID' created") + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/model/AuthModels.kt b/app/src/main/java/pl/firmatpp/itstransport/data/model/AuthModels.kt new file mode 100644 index 0000000..6677d06 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/model/AuthModels.kt @@ -0,0 +1,29 @@ +package pl.firmatpp.itstransport.data.model + +import com.google.gson.annotations.SerializedName + +data class LoginRequest( + val email: String, + val password: String, +) + +data class LoginResponse( + @SerializedName("access_token") val accessToken: String, + @SerializedName("token_type") val tokenType: String, +) + +data class UserResponse( + val id: Long, + val name: String, + val email: String, + @SerializedName("created_at") val createdAt: String, + @SerializedName("updated_at") val updatedAt: String, + val roles: List, + val permissions: List, +) + +data class ApiError( + val message: String, + val errors: Map>? = null, + val status: Int? = null, +) diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/model/SchedulerModels.kt b/app/src/main/java/pl/firmatpp/itstransport/data/model/SchedulerModels.kt new file mode 100644 index 0000000..705f510 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/model/SchedulerModels.kt @@ -0,0 +1,57 @@ +package pl.firmatpp.itstransport.data.model + +import com.google.gson.annotations.SerializedName + +/** + * Scheduler event from the backend — represents a single transport route/task. + * Maps to the FullCalendar event shape returned by /scheduler/events. + */ +data class SchedulerEventResponse( + val id: Long, + val title: String, + @SerializedName("resourceId") val resourceId: String, + val start: String, + val end: String, + val color: String? = null, + @SerializedName("extendedProps") val extendedProps: EventExtendedProps, +) + +/** + * Extended properties nested inside each scheduler event. + * Contains the transport-specific fields displayed on route cards. + */ +data class EventExtendedProps( + @SerializedName("transport_code") val transportCode: String, + @SerializedName("contractor_route") val contractorRoute: String, + @SerializedName("truck_plate") val truckPlate: String, + @SerializedName("trailer_plate") val trailerPlate: String? = null, + val weight: Double? = null, + val status: String? = null, + @SerializedName("is_external_rental") val isExternalRental: Boolean = false, + @SerializedName("external_driver_name") val externalDriverName: String? = null, +) + +/** + * Driver resource from the backend — maps resourceId to driver name. + * Returned by /scheduler/resources. + */ +data class SchedulerDriverResponse( + val id: String, + val title: String, +) + +/** + * UI-ready display model for a single route card. + * All fields are pre-resolved (e.g. driverName from resource lookup or external rental). + */ +data class RouteDisplayItem( + val id: Long, + val driverName: String, + val transportCode: String, + val contractorRoute: String, + val truckPlate: String, + val trailerPlate: String? = null, + val weight: Double? = null, + val status: String? = null, + val color: String? = null, +) diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/model/ServiceModels.kt b/app/src/main/java/pl/firmatpp/itstransport/data/model/ServiceModels.kt new file mode 100644 index 0000000..a8a3a41 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/model/ServiceModels.kt @@ -0,0 +1,70 @@ +package pl.firmatpp.itstransport.data.model + +import com.google.gson.annotations.SerializedName + +data class ServiceEventsResponse( + val data: List, + val meta: PaginationMeta, +) + +data class PaginationMeta( + @SerializedName("current_page") val currentPage: Int, + val from: Int?, + @SerializedName("last_page") val lastPage: Int, + @SerializedName("per_page") val perPage: Int, + val to: Int?, + val total: Int, +) + +data class ServiceEvent( + val id: Long, + @SerializedName("costable_type") val costableType: String?, + val costable: VehicleRef?, + @SerializedName("service_provider") val serviceProvider: ProviderRef?, + val user: UserRef?, + @SerializedName("service_date") val serviceDate: String, + val mileage: Int?, + @SerializedName("general_notes") val generalNotes: String?, + @SerializedName("total_amount") val totalAmount: String?, + val currency: String?, + val source: String?, + val items: List, + @SerializedName("items_count") val itemsCount: Int, +) + +data class ServiceItem( + val id: Long, + @SerializedName("cost_type") val costType: CostTypeRef?, + val name: String?, + val notes: String?, + val amount: String?, +) + +data class VehicleRef( + val id: Long, + val name: String?, + @SerializedName("license_plate") val licensePlate: String?, +) + +data class ProviderRef( + val id: Long, + val name: String?, +) + +data class UserRef( + val id: Long, + val name: String?, +) + +data class CostTypeRef( + val id: Long, + val name: String?, + val icon: String?, +) + +data class ServiceEventFilters( + val search: String = "", + val dateFrom: String? = null, + val dateTo: String? = null, + val source: String? = null, +) diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/model/WizardModels.kt b/app/src/main/java/pl/firmatpp/itstransport/data/model/WizardModels.kt new file mode 100644 index 0000000..0d50cf0 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/model/WizardModels.kt @@ -0,0 +1,100 @@ +package pl.firmatpp.itstransport.data.model + +import com.google.gson.annotations.SerializedName + +// ────────────────────────────────────────────── +// Vehicle responses — trucks/all and trailers/all +// Both endpoints return bare JSON arrays [...] +// ────────────────────────────────────────────── + +data class Truck( + val id: Long, + val name: String?, + @SerializedName("license_plate") val licensePlate: String?, + val model: String?, + val brand: String?, + val mileage: Int?, + val status: String?, +) + +data class Trailer( + val id: Long, + val name: String?, + @SerializedName("license_plate") val licensePlate: String?, + val model: String?, + val brand: String?, + val status: String?, +) + +// ────────────────────────────────────────────── +// Service types — maintenance/service-types +// Returns wrapped { data: [...] } +// ────────────────────────────────────────────── + +data class ServiceTypesResponse( + val data: List, +) + +data class ServiceType( + val id: Long, + val name: String?, + @SerializedName("category") val category: ServiceCategory?, + @SerializedName("position_groups") val positionGroups: List?, +) + +data class ServiceCategory( + val id: Long, + val name: String?, + val icon: String?, + @SerializedName("vehicle_type") val vehicleType: String?, // "all", "truck", "truck_trailer" +) + +data class PositionGroup( + val id: Long, + val name: String?, + @SerializedName("vehicle_type") val vehicleType: String?, // "all", "truck", "truck_trailer" + val positions: List?, +) + +data class Position( + val id: Long, + val name: String?, +) + +// ────────────────────────────────────────────── +// Local state for selected services in the wizard +// ────────────────────────────────────────────── + +data class SelectedService( + val serviceType: ServiceType, + val selectedPositions: List = emptyList(), + val notes: String? = null, + val amount: String? = null, +) + +// ────────────────────────────────────────────── +// Submission — POST service-events +// Accepts CreateServiceEventRequest, returns 201 +// with { data: } +// ────────────────────────────────────────────── + +data class CreateServiceEventRequest( + @SerializedName("costable_type") val costableType: String, // "truck" or "truck_trailer" + @SerializedName("costable_id") val costableId: Long, + @SerializedName("service_date") val serviceDate: String, + val mileage: Int? = null, + @SerializedName("general_notes") val generalNotes: String? = null, + val items: List, +) + +data class CreateServiceItemRequest( + @SerializedName("service_type_id") val serviceTypeId: Long, + val name: String?, + val notes: String? = null, + val amount: String? = null, + @SerializedName("position_ids") val positionIds: List? = null, +) + +data class CreateServiceEventResponse( + val data: ServiceEvent, +) diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/repository/DeviceTokenRepository.kt b/app/src/main/java/pl/firmatpp/itstransport/data/repository/DeviceTokenRepository.kt new file mode 100644 index 0000000..6dab455 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/repository/DeviceTokenRepository.kt @@ -0,0 +1,43 @@ +package pl.firmatpp.itstransport.data.repository + +import android.os.Build +import android.util.Log +import pl.firmatpp.itstransport.data.api.DeviceTokenApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceTokenRepository @Inject constructor( + private val deviceTokenApi: DeviceTokenApi, +) { + companion object { + private const val TAG = "DeviceTokenRepository" + } + + suspend fun registerToken(token: String): Result { + return try { + val body = mapOf( + "token" to token, + "device_name" to "${Build.MANUFACTURER} ${Build.MODEL}", + ) + deviceTokenApi.registerToken(body) + Log.d(TAG, "FCM token registered: ${token.take(10)}…") + Result.success(Unit) + } catch (e: Exception) { + Log.w(TAG, "FCM token registration failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + suspend fun unregisterToken(token: String): Result { + return try { + val body = mapOf("token" to token) + deviceTokenApi.unregisterToken(body) + Log.d(TAG, "FCM token unregistered: ${token.take(10)}…") + Result.success(Unit) + } catch (e: Exception) { + Log.w(TAG, "FCM token unregistration failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/repository/SchedulerRepository.kt b/app/src/main/java/pl/firmatpp/itstransport/data/repository/SchedulerRepository.kt new file mode 100644 index 0000000..ef0633d --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/repository/SchedulerRepository.kt @@ -0,0 +1,86 @@ +package pl.firmatpp.itstransport.data.repository + +import android.util.Log +import com.google.gson.Gson +import pl.firmatpp.itstransport.data.api.SchedulerApi +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.model.ApiError +import pl.firmatpp.itstransport.data.model.RouteDisplayItem +import retrofit2.HttpException +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository that fetches today's scheduler events and driver resources, + * joins them by resourceId, and returns a list of display-ready route items. + * + * External rentals (isExternalRental == true) use extendedProps.externalDriverName + * instead of looking up the driver resource. + */ +@Singleton +class SchedulerRepository @Inject constructor( + private val schedulerApi: SchedulerApi, +) { + companion object { + private const val TAG = "SchedulerRepository" + } + + suspend fun getTodayRoutes(): Result> { + val today = LocalDate.now().toString() + Log.d(TAG, "Fetching today's routes for date: $today") + + return try { + val events = schedulerApi.getEvents(start = today, end = today) + Log.d(TAG, "Fetched ${events.size} events") + + val resources = schedulerApi.getResources() + Log.d(TAG, "Fetched ${resources.size} driver resources") + + val driverMap = resources.associateBy { it.id } + + val routes = events.map { event -> + val driverName = if (event.extendedProps.isExternalRental) { + event.extendedProps.externalDriverName ?: "Wynajem zewnętrzny" + } else { + driverMap[event.resourceId]?.title ?: "Nieznany kierowca" + } + + RouteDisplayItem( + id = event.id, + driverName = driverName, + transportCode = event.extendedProps.transportCode, + contractorRoute = event.extendedProps.contractorRoute, + truckPlate = event.extendedProps.truckPlate, + trailerPlate = event.extendedProps.trailerPlate, + weight = event.extendedProps.weight, + status = event.extendedProps.status, + color = event.color, + ) + } + + Log.d(TAG, "Mapped ${routes.size} route display items") + Result.success(routes) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "API call failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "API call failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + private fun parseApiError(e: HttpException): ApiError? { + return try { + val errorBody = e.response()?.errorBody()?.string() + if (errorBody != null) { + Gson().fromJson(errorBody, ApiError::class.java)?.copy(status = e.code()) + } else { + null + } + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/repository/ServiceRepository.kt b/app/src/main/java/pl/firmatpp/itstransport/data/repository/ServiceRepository.kt new file mode 100644 index 0000000..5174477 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/repository/ServiceRepository.kt @@ -0,0 +1,66 @@ +package pl.firmatpp.itstransport.data.repository + +import android.util.Log +import com.google.gson.Gson +import pl.firmatpp.itstransport.data.api.ServiceApi +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.model.ApiError +import pl.firmatpp.itstransport.data.model.ServiceEventFilters +import pl.firmatpp.itstransport.data.model.ServiceEventsResponse +import retrofit2.HttpException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository that fetches paginated service events with optional filters. + * Wraps results in Result and preserves HTTP error details via ApiException. + */ +@Singleton +class ServiceRepository @Inject constructor( + private val serviceApi: ServiceApi, +) { + companion object { + private const val TAG = "ServiceRepository" + } + + suspend fun getEvents( + filters: ServiceEventFilters, + page: Int = 1, + perPage: Int = 20, + ): Result { + Log.d(TAG, "Fetching service events page=$page, perPage=$perPage, search='${filters.search}', source=${filters.source}, dateFrom=${filters.dateFrom}, dateTo=${filters.dateTo}") + + return try { + val response = serviceApi.getEvents( + page = page, + perPage = perPage, + search = filters.search.ifBlank { null }, + source = filters.source, + dateFrom = filters.dateFrom, + dateTo = filters.dateTo, + ) + Log.d(TAG, "Fetched ${response.data.size} service events (page ${response.meta.currentPage}/${response.meta.lastPage}, total ${response.meta.total})") + Result.success(response) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "API call failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "API call failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + private fun parseApiError(e: HttpException): ApiError? { + return try { + val errorBody = e.response()?.errorBody()?.string() + if (errorBody != null) { + Gson().fromJson(errorBody, ApiError::class.java)?.copy(status = e.code()) + } else { + null + } + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/repository/ServiceWizardRepository.kt b/app/src/main/java/pl/firmatpp/itstransport/data/repository/ServiceWizardRepository.kt new file mode 100644 index 0000000..8ddb2fe --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/data/repository/ServiceWizardRepository.kt @@ -0,0 +1,106 @@ +package pl.firmatpp.itstransport.data.repository + +import android.util.Log +import com.google.gson.Gson +import pl.firmatpp.itstransport.data.api.ServiceWizardApi +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.model.ApiError +import pl.firmatpp.itstransport.data.model.CreateServiceEventRequest +import pl.firmatpp.itstransport.data.model.ServiceEvent +import pl.firmatpp.itstransport.data.model.ServiceType +import pl.firmatpp.itstransport.data.model.Trailer +import pl.firmatpp.itstransport.data.model.Truck +import retrofit2.HttpException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository for the service wizard flow. + * Fetches trucks, trailers, service types, and submits new service events. + * All methods return Result with ApiException preserving HTTP code + parsed body. + */ +@Singleton +class ServiceWizardRepository @Inject constructor( + private val serviceWizardApi: ServiceWizardApi, +) { + companion object { + private const val TAG = "ServiceWizardRepo" + } + + suspend fun getTrucks(): Result> { + Log.d(TAG, "Fetching all trucks") + return try { + val trucks = serviceWizardApi.getTrucks() + Log.d(TAG, "Fetched ${trucks.size} trucks") + Result.success(trucks) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "Fetch trucks failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "Fetch trucks failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + suspend fun getTrailers(): Result> { + Log.d(TAG, "Fetching all trailers") + return try { + val trailers = serviceWizardApi.getTrailers() + Log.d(TAG, "Fetched ${trailers.size} trailers") + Result.success(trailers) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "Fetch trailers failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "Fetch trailers failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + suspend fun getServiceTypes(): Result> { + Log.d(TAG, "Fetching service types") + return try { + val response = serviceWizardApi.getServiceTypes() + Log.d(TAG, "Fetched ${response.data.size} service types") + Result.success(response.data) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "Fetch service types failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "Fetch service types failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + suspend fun submitServiceEvent(request: CreateServiceEventRequest): Result { + Log.d(TAG, "Submitting service event: costableType=${request.costableType}, costableId=${request.costableId}, items=${request.items.size}") + return try { + val response = serviceWizardApi.createServiceEvent(request) + Log.d(TAG, "Service event created: id=${response.data.id}") + Result.success(response.data) + } catch (e: HttpException) { + val apiError = parseApiError(e) + Log.w(TAG, "Submit service event failed with HTTP ${e.code()}: ${apiError?.message ?: e.message()}") + Result.failure(ApiException(e.code(), apiError, e)) + } catch (e: Exception) { + Log.w(TAG, "Submit service event failed: ${e.javaClass.simpleName} — ${e.message}") + Result.failure(e) + } + } + + private fun parseApiError(e: HttpException): ApiError? { + return try { + val errorBody = e.response()?.errorBody()?.string() + if (errorBody != null) { + Gson().fromJson(errorBody, ApiError::class.java)?.copy(status = e.code()) + } else { + null + } + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt b/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt new file mode 100644 index 0000000..ba0c2ed --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt @@ -0,0 +1,93 @@ +package pl.firmatpp.itstransport.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import pl.firmatpp.itstransport.BuildConfig +import pl.firmatpp.itstransport.data.api.AuthApi +import pl.firmatpp.itstransport.data.api.AuthInterceptor +import pl.firmatpp.itstransport.data.api.DeviceTokenApi +import pl.firmatpp.itstransport.data.api.SchedulerApi +import pl.firmatpp.itstransport.data.api.ServiceApi +import pl.firmatpp.itstransport.data.api.ServiceWizardApi +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + // Redact Authorization header to prevent token leakage in logs + redactHeader("Authorization") + } + } + + @Provides + @Singleton + fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + loggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL + "/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideAuthApi(retrofit: Retrofit): AuthApi { + return retrofit.create(AuthApi::class.java) + } + + @Provides + @Singleton + fun provideSchedulerApi(retrofit: Retrofit): SchedulerApi { + return retrofit.create(SchedulerApi::class.java) + } + + @Provides + @Singleton + fun provideServiceApi(retrofit: Retrofit): ServiceApi { + return retrofit.create(ServiceApi::class.java) + } + + @Provides + @Singleton + fun provideServiceWizardApi(retrofit: Retrofit): ServiceWizardApi { + return retrofit.create(ServiceWizardApi::class.java) + } + + @Provides + @Singleton + fun provideDeviceTokenApi(retrofit: Retrofit): DeviceTokenApi { + return retrofit.create(DeviceTokenApi::class.java) + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/MainScreen.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/MainScreen.kt new file mode 100644 index 0000000..d6fcda4 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/MainScreen.kt @@ -0,0 +1,128 @@ +package pl.firmatpp.itstransport.ui + +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import pl.firmatpp.itstransport.ui.home.HomeScreen +import pl.firmatpp.itstransport.ui.navigation.BottomTab +import pl.firmatpp.itstransport.ui.service.ServiceHistoryScreen + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + onLogout: () -> Unit, + onNavigateToAddService: () -> Unit, + shouldRefreshService: Boolean = false, + onServiceRefreshed: () -> Unit = {}, +) { + var selectedTab by rememberSaveable { mutableStateOf(BottomTab.Trasy.route) } + + // When a service was just added, switch to Serwis tab automatically + LaunchedEffect(shouldRefreshService) { + if (shouldRefreshService) { + selectedTab = BottomTab.Serwis.route + } + } + + // Request POST_NOTIFICATIONS permission on Android 13+ (API 33) + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { /* Permission granted or denied — FCMService handles missing permission gracefully */ }, + ) + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "ITS Transport", + style = MaterialTheme.typography.titleLarge, + ) + }, + actions = { + IconButton(onClick = onLogout) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = "Wyloguj", + ) + } + }, + ) + }, + floatingActionButton = { + if (selectedTab == BottomTab.Serwis.route) { + FloatingActionButton(onClick = onNavigateToAddService) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Dodaj serwis", + ) + } + } + }, + bottomBar = { + NavigationBar { + BottomTab.entries.forEach { tab -> + NavigationBarItem( + selected = selectedTab == tab.route, + onClick = { selectedTab = tab.route }, + icon = { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + ) + }, + label = { Text(tab.label) }, + ) + } + } + }, + ) { innerPadding -> + when (selectedTab) { + BottomTab.Trasy.route -> { + HomeScreen(modifier = Modifier.padding(innerPadding)) + } + BottomTab.Serwis.route -> { + ServiceHistoryScreen( + modifier = Modifier.padding(innerPadding), + shouldRefresh = shouldRefreshService, + onRefreshConsumed = onServiceRefreshed, + ) + } + else -> { + ServiceHistoryScreen( + modifier = Modifier.padding(innerPadding), + shouldRefresh = shouldRefreshService, + onRefreshConsumed = onServiceRefreshed, + ) + } + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/auth/LoginScreen.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..ee44fde --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/auth/LoginScreen.kt @@ -0,0 +1,216 @@ +package pl.firmatpp.itstransport.ui.auth + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + viewModel: LoginViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var emailError by rememberSaveable { mutableStateOf(null) } + var passwordError by rememberSaveable { mutableStateOf(null) } + + val isLoading = uiState is LoginUiState.Loading + val focusManager = LocalFocusManager.current + + // Navigate on success + LaunchedEffect(uiState) { + if (uiState is LoginUiState.Success) { + onLoginSuccess() + } + } + + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.weight(1f)) + + // App title + Text( + text = "ITS Transport", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Subtitle + Text( + text = "System zarządzania flotą", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // Email field + OutlinedTextField( + value = email, + onValueChange = { + email = it + emailError = null + viewModel.clearError() + }, + label = { Text("Email") }, + singleLine = true, + isError = emailError != null, + supportingText = emailError?.let { error -> { Text(error) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Password field + OutlinedTextField( + value = password, + onValueChange = { + password = it + passwordError = null + viewModel.clearError() + }, + label = { Text("Hasło") }, + singleLine = true, + isError = passwordError != null, + supportingText = passwordError?.let { error -> { Text(error) } }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + submitLogin(email, password, viewModel, { emailError = it }, { passwordError = it }) + }, + ), + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Login button + Button( + onClick = { + focusManager.clearFocus() + submitLogin(email, password, viewModel, { emailError = it }, { passwordError = it }) + }, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text("Zaloguj się") + } + } + + // Error message + AnimatedVisibility( + visible = uiState is LoginUiState.Error, + enter = fadeIn(), + exit = fadeOut(), + ) { + val errorMessage = (uiState as? LoginUiState.Error)?.message ?: "" + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +private fun submitLogin( + email: String, + password: String, + viewModel: LoginViewModel, + setEmailError: (String?) -> Unit, + setPasswordError: (String?) -> Unit, +) { + var hasError = false + + if (email.isBlank()) { + setEmailError("Wprowadź email") + hasError = true + } + if (password.isBlank()) { + setPasswordError("Wprowadź hasło") + hasError = true + } + + if (!hasError) { + viewModel.login(email.trim(), password) + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/auth/LoginViewModel.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/auth/LoginViewModel.kt new file mode 100644 index 0000000..426b66a --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/auth/LoginViewModel.kt @@ -0,0 +1,100 @@ +package pl.firmatpp.itstransport.ui.auth + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.auth.AuthRepository +import pl.firmatpp.itstransport.data.model.UserResponse +import java.io.IOException +import java.net.UnknownHostException +import javax.inject.Inject + +sealed class LoginUiState { + data object Idle : LoginUiState() + data object Loading : LoginUiState() + data class Success(val user: UserResponse) : LoginUiState() + data class Error(val message: String) : LoginUiState() +} + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + companion object { + private const val TAG = "LoginViewModel" + } + + private val _uiState = MutableStateFlow(LoginUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun login(email: String, password: String) { + if (_uiState.value is LoginUiState.Loading) return + + // Field validation + if (email.isBlank() || password.isBlank()) { + Log.d(TAG, "Login attempted with empty fields") + _uiState.value = LoginUiState.Error("Wprowadź email i hasło") + return + } + + _uiState.value = LoginUiState.Loading + Log.d(TAG, "Login attempt for email: ${email.take(3)}***") + + viewModelScope.launch { + val result = authRepository.login(email, password) + result.fold( + onSuccess = { user -> + Log.d(TAG, "Login successful for user: ${user.name}") + _uiState.value = LoginUiState.Success(user) + }, + onFailure = { exception -> + val message = mapErrorToPolishMessage(exception) + Log.w(TAG, "Login failed: ${exception.javaClass.simpleName} → \"$message\"") + _uiState.value = LoginUiState.Error(message) + }, + ) + } + } + + fun clearError() { + if (_uiState.value is LoginUiState.Error) { + _uiState.value = LoginUiState.Idle + } + } + + private fun mapErrorToPolishMessage(exception: Throwable): String { + return when { + // Network connectivity issues + exception is IOException || exception is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + // HTTP errors wrapped by AuthRepository + exception is ApiException -> { + when { + // 422 Validation / 401 Unauthorized — invalid credentials + exception.code == 422 || exception.code == 401 -> + "Nieprawidłowy email lub hasło" + // Server errors + exception.code in 500..599 -> + "Błąd serwera. Spróbuj ponownie za chwilę." + else -> + "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + } + + // Network errors wrapped inside other exception types + exception.cause is IOException || exception.cause is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + // Fallback + else -> "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeScreen.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeScreen.kt new file mode 100644 index 0000000..599ae36 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeScreen.kt @@ -0,0 +1,164 @@ +package pl.firmatpp.itstransport.ui.home + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DirectionsBus +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + viewModel: HomeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = modifier.fillMaxSize(), + ) { + when (val state = uiState) { + is HomeUiState.Idle, + is HomeUiState.Loading -> { + LoadingContent() + } + + is HomeUiState.Success -> { + RouteList(routes = state.routes) + } + + is HomeUiState.Error -> { + ErrorContent( + message = state.message, + onRetry = { viewModel.refresh() }, + ) + } + + is HomeUiState.Empty -> { + EmptyContent() + } + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun RouteList(routes: List) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = routes, + key = { it.id }, + ) { route -> + RouteCard(route = route) + } + } +} + +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Błąd", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = onRetry) { + Text("Spróbuj ponownie") + } + } +} + +@Composable +private fun EmptyContent() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.DirectionsBus, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Brak tras na dziś", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeViewModel.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..9d04ec6 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeViewModel.kt @@ -0,0 +1,107 @@ +package pl.firmatpp.itstransport.ui.home + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.model.RouteDisplayItem +import pl.firmatpp.itstransport.data.repository.SchedulerRepository +import java.io.IOException +import java.net.UnknownHostException +import javax.inject.Inject + +sealed class HomeUiState { + data object Idle : HomeUiState() + data object Loading : HomeUiState() + data class Success(val routes: List) : HomeUiState() + data class Error(val message: String) : HomeUiState() + data object Empty : HomeUiState() +} + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val schedulerRepository: SchedulerRepository, +) : ViewModel() { + + companion object { + private const val TAG = "HomeViewModel" + } + + private val _uiState = MutableStateFlow(HomeUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + init { + loadRoutes() + } + + fun refresh() { + Log.d(TAG, "Pull-to-refresh triggered") + _isRefreshing.value = true + loadRoutes() + } + + private fun loadRoutes() { + if (_uiState.value is HomeUiState.Loading && !_isRefreshing.value) return + + _uiState.value = HomeUiState.Loading + Log.d(TAG, "Loading today's routes") + + viewModelScope.launch { + val result = schedulerRepository.getTodayRoutes() + result.fold( + onSuccess = { routes -> + if (routes.isEmpty()) { + Log.d(TAG, "No routes for today") + _uiState.value = HomeUiState.Empty + } else { + Log.d(TAG, "Loaded ${routes.size} routes") + _uiState.value = HomeUiState.Success(routes) + } + }, + onFailure = { exception -> + val message = mapErrorToPolishMessage(exception) + Log.w(TAG, "Load failed: ${exception.javaClass.simpleName} → \"$message\"") + _uiState.value = HomeUiState.Error(message) + }, + ) + _isRefreshing.value = false + } + } + + private fun mapErrorToPolishMessage(exception: Throwable): String { + return when { + // Network connectivity issues + exception is IOException || exception is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + // HTTP errors wrapped by SchedulerRepository + exception is ApiException -> { + when { + exception.code == 401 -> + "Sesja wygasła. Zaloguj się ponownie." + exception.code == 403 -> + "Brak uprawnień do wyświetlenia tras." + exception.code in 500..599 -> + "Błąd serwera. Spróbuj ponownie za chwilę." + else -> + "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + } + + // Network errors wrapped inside other exception types + exception.cause is IOException || exception.cause is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + // Fallback + else -> "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/home/RouteCard.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/home/RouteCard.kt new file mode 100644 index 0000000..027c195 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/home/RouteCard.kt @@ -0,0 +1,172 @@ +package pl.firmatpp.itstransport.ui.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import pl.firmatpp.itstransport.data.model.RouteDisplayItem + +@Composable +fun RouteCard( + route: RouteDisplayItem, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + // Color indicator strip + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp)) + .background(parseColor(route.color)), + ) + + // Card content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + // Top row: transport code + status badge + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = route.transportCode, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (route.status != null) { + StatusBadge(status = route.status) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Driver name + Text( + text = route.driverName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // Contractor route (description) + Text( + text = route.contractorRoute, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Bottom row: plates and weight + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LabeledValue(label = "Ciągnik", value = route.truckPlate) + + if (route.trailerPlate != null) { + LabeledValue(label = "Naczepa", value = route.trailerPlate) + } + + if (route.weight != null) { + LabeledValue( + label = "Waga", + value = "${formatWeight(route.weight)} t", + ) + } + } + } + } + } +} + +@Composable +private fun StatusBadge(status: String) { + Text( + text = status, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +@Composable +private fun LabeledValue(label: String, value: String) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } +} + +/** + * Formats weight for display, dropping decimal when it's a whole number. + * 24.0 → "24", 24.5 → "24.5" + */ +private fun formatWeight(weight: Double): String { + return if (weight == weight.toLong().toDouble()) { + weight.toLong().toString() + } else { + weight.toString() + } +} + +/** + * Parses a hex color string (e.g. "#3788d8") from the backend into a Compose Color. + * Falls back to primary-like blue on invalid input. + */ +private fun parseColor(hex: String?): Color { + if (hex.isNullOrBlank()) return Color(0xFF3788D8) + return try { + val sanitized = hex.removePrefix("#") + Color(("FF$sanitized").toLong(16)) + } catch (_: Exception) { + Color(0xFF3788D8) + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/navigation/AppNavigation.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..73a732f --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/navigation/AppNavigation.kt @@ -0,0 +1,104 @@ +package pl.firmatpp.itstransport.ui.navigation + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.flow.filter +import pl.firmatpp.itstransport.data.auth.TokenManager +import pl.firmatpp.itstransport.ui.MainScreen +import pl.firmatpp.itstransport.ui.auth.LoginScreen +import pl.firmatpp.itstransport.ui.service.wizard.AddServiceScreen + +private const val TAG = "AppNavigation" +private const val KEY_SERVICE_ADDED = "service_added" + +@Composable +fun AppNavigation(tokenManager: TokenManager) { + val navController = rememberNavController() + val isLoggedIn by tokenManager.isLoggedIn.collectAsStateWithLifecycle() + val startDestination = if (tokenManager.hasToken()) { + Log.d(TAG, "Token present — starting at main") + Screen.Main.route + } else { + Log.d(TAG, "No token — starting at login") + Screen.Login.route + } + + // Reactive 401 redirect: when AuthInterceptor clears the token on a 401, + // isLoggedIn transitions to false — navigate to login, clearing the back stack. + LaunchedEffect(navController) { + snapshotFlow { isLoggedIn } + .filter { !it } + .collect { + val currentRoute = navController.currentDestination?.route + if (currentRoute != Screen.Login.route) { + Log.d(TAG, "401 session expiry — redirecting to login (from $currentRoute)") + navController.navigate(Screen.Login.route) { + popUpTo(0) { inclusive = true } + } + } + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(Screen.Login.route) { + LoginScreen( + onLoginSuccess = { + Log.d(TAG, "Login success — navigating to main") + navController.navigate(Screen.Main.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + ) + } + composable(Screen.Main.route) { mainBackStackEntry -> + // Observe the "service_added" result set by AddServiceScreen via savedStateHandle + val shouldRefresh by mainBackStackEntry.savedStateHandle + .getStateFlow(KEY_SERVICE_ADDED, false) + .collectAsStateWithLifecycle() + + MainScreen( + onLogout = { + Log.d(TAG, "Logout — clearing token and navigating to login") + tokenManager.clearToken() + navController.navigate(Screen.Login.route) { + popUpTo(0) { inclusive = true } + } + }, + onNavigateToAddService = { + Log.d(TAG, "Navigating to AddService wizard") + navController.navigate(Screen.AddService.route) + }, + shouldRefreshService = shouldRefresh, + onServiceRefreshed = { + // Clear the flag so it doesn't re-trigger on recomposition + mainBackStackEntry.savedStateHandle[KEY_SERVICE_ADDED] = false + }, + ) + } + composable(Screen.AddService.route) { + AddServiceScreen( + onNavigateBack = { + Log.d(TAG, "AddService → navigating back") + navController.popBackStack() + }, + onServiceSubmitted = { + Log.d(TAG, "AddService → service submitted, setting refresh flag and popping back") + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(KEY_SERVICE_ADDED, true) + navController.popBackStack() + }, + ) + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/navigation/Screen.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/navigation/Screen.kt new file mode 100644 index 0000000..d02be9d --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/navigation/Screen.kt @@ -0,0 +1,38 @@ +package pl.firmatpp.itstransport.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.DirectionsBus +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Top-level navigation destinations for auth-gated routing. + */ +sealed class Screen(val route: String) { + data object Login : Screen("login") + data object Main : Screen("main") + data object AddService : Screen("add_service") +} + +/** + * Bottom navigation tab definitions for the main scaffold. + */ +enum class BottomTab( + val route: String, + val label: String, + val icon: ImageVector, + val placeholderText: String, +) { + Trasy( + route = "trasy", + label = "Trasy", + icon = Icons.Filled.DirectionsBus, + placeholderText = "Trasy na dziś", + ), + Serwis( + route = "serwis", + label = "Serwis", + icon = Icons.Filled.Build, + placeholderText = "Historia serwisu", + ), +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceEventCard.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceEventCard.kt new file mode 100644 index 0000000..641a668 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceEventCard.kt @@ -0,0 +1,165 @@ +package pl.firmatpp.itstransport.ui.service + +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +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 pl.firmatpp.itstransport.data.model.ServiceEvent +import java.text.NumberFormat +import java.util.Locale + +@Composable +fun ServiceEventCard( + event: ServiceEvent, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + // Top row: service date + source badge + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = event.serviceDate, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (event.source != null) { + SourceBadge(source = event.source) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Vehicle info + val vehicleText = event.costable?.licensePlate + ?: event.costable?.name + ?: "Brak pojazdu" + Text( + text = vehicleText, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // Items summary + val itemsSummary = buildItemsSummary(event) + Text( + text = itemsSummary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Bottom row: mileage + amount + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (event.mileage != null) { + LabeledValue( + label = "Przebieg", + value = "${formatMileage(event.mileage)} km", + ) + } + if (event.totalAmount != null) { + val amountText = if (event.currency != null) { + "${event.totalAmount} ${event.currency}" + } else { + event.totalAmount + } + LabeledValue(label = "Kwota", value = amountText) + } + } + } + } +} + +@Composable +private fun SourceBadge(source: String) { + val label = when (source) { + "internal" -> "Wewnętrzny" + "external" -> "Zewnętrzny" + else -> source + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +@Composable +private fun LabeledValue(label: String, value: String) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } +} + +/** + * Builds a summary string from service items. + * - Non-empty list: join first 3 item names + "..." if more. + * - Empty list with itemsCount > 0: "$itemsCount pozycji" + * - Otherwise: "Brak pozycji" + */ +private fun buildItemsSummary(event: ServiceEvent): String { + if (event.items.isNotEmpty()) { + val names = event.items.take(3).mapNotNull { it.name } + val joined = names.joinToString(", ") + return if (event.items.size > 3) "$joined..." else joined.ifEmpty { "${event.itemsCount} pozycji" } + } + return if (event.itemsCount > 0) { + "${event.itemsCount} pozycji" + } else { + "Brak pozycji" + } +} + +/** + * Formats mileage with space as thousands separator. + * 123456 → "123 456" + */ +private fun formatMileage(mileage: Int): String { + val formatter = NumberFormat.getIntegerInstance(Locale("pl", "PL")) + return formatter.format(mileage).replace('\u00A0', ' ') +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceHistoryScreen.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceHistoryScreen.kt new file mode 100644 index 0000000..e8dbb6d --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceHistoryScreen.kt @@ -0,0 +1,448 @@ +package pl.firmatpp.itstransport.ui.service + +import androidx.compose.animation.AnimatedVisibility +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +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.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.time.Instant +import java.time.ZoneId + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServiceHistoryScreen( + modifier: Modifier = Modifier, + shouldRefresh: Boolean = false, + onRefreshConsumed: () -> Unit = {}, + viewModel: ServiceHistoryViewModel = hiltViewModel(), +) { + // External refresh trigger — e.g. after a service was just added via the wizard + LaunchedEffect(shouldRefresh) { + if (shouldRefresh) { + viewModel.refresh() + onRefreshConsumed() + } + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val dateFrom by viewModel.dateFrom.collectAsStateWithLifecycle() + val dateTo by viewModel.dateTo.collectAsStateWithLifecycle() + val sourceFilter by viewModel.sourceFilter.collectAsStateWithLifecycle() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = modifier.fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Search bar ── + SearchBar( + query = searchQuery, + onQueryChange = viewModel::updateSearch, + ) + + // ── Collapsible filter section ── + FilterSection( + dateFrom = dateFrom, + dateTo = dateTo, + sourceFilter = sourceFilter, + onDateFromChange = viewModel::updateDateFrom, + onDateToChange = viewModel::updateDateTo, + onSourceFilterChange = viewModel::updateSourceFilter, + onClearFilters = viewModel::clearFilters, + ) + + // ── Content area ── + when (val state = uiState) { + is ServiceHistoryViewModel.ServiceHistoryUiState.Idle, + is ServiceHistoryViewModel.ServiceHistoryUiState.Loading -> { + LoadingContent() + } + + is ServiceHistoryViewModel.ServiceHistoryUiState.Success -> { + ServiceEventList( + events = state.events, + hasMore = state.hasMore, + onLoadMore = viewModel::loadMore, + ) + } + + is ServiceHistoryViewModel.ServiceHistoryUiState.Error -> { + ErrorContent( + message = state.message, + onRetry = { viewModel.refresh() }, + ) + } + + is ServiceHistoryViewModel.ServiceHistoryUiState.Empty -> { + EmptyContent() + } + } + } + } +} + +// ── Search bar ── + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Szukaj...") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Szukaj", + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Wyczyść", + ) + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +// ── Filter section ── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilterSection( + dateFrom: String?, + dateTo: String?, + sourceFilter: String?, + onDateFromChange: (String?) -> Unit, + onDateToChange: (String?) -> Unit, + onSourceFilterChange: (String?) -> Unit, + onClearFilters: () -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + var showDateFromPicker by remember { mutableStateOf(false) } + var showDateToPicker by remember { mutableStateOf(false) } + + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + // Toggle row + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { expanded = !expanded }) { + Text("Filtry") + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Zwiń" else "Rozwiń", + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + AnimatedVisibility(visible = expanded) { + Column { + // Date range row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = dateFrom ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Data od") }, + modifier = Modifier + .weight(1f), + enabled = true, + ) + OutlinedTextField( + value = dateTo ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Data do") }, + modifier = Modifier + .weight(1f), + enabled = true, + ) + } + + // Click targets for date pickers (TextButtons below the fields) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { showDateFromPicker = true }, + modifier = Modifier.weight(1f), + ) { + Text(if (dateFrom != null) "Zmień" else "Wybierz") + } + TextButton( + onClick = { showDateToPicker = true }, + modifier = Modifier.weight(1f), + ) { + Text(if (dateTo != null) "Zmień" else "Wybierz") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Source filter chips + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = sourceFilter == null, + onClick = { onSourceFilterChange(null) }, + label = { Text("Wszystkie") }, + ) + FilterChip( + selected = sourceFilter == "internal", + onClick = { onSourceFilterChange("internal") }, + label = { Text("Wewnętrzne") }, + ) + FilterChip( + selected = sourceFilter == "external", + onClick = { onSourceFilterChange("external") }, + label = { Text("Zewnętrzne") }, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Clear filters button + TextButton(onClick = onClearFilters) { + Text("Wyczyść filtry") + } + } + } + } + + // ── Date picker dialogs ── + if (showDateFromPicker) { + DatePickerDialogWrapper( + onDateSelected = { onDateFromChange(it) }, + onDismiss = { showDateFromPicker = false }, + ) + } + if (showDateToPicker) { + DatePickerDialogWrapper( + onDateSelected = { onDateToChange(it) }, + onDismiss = { showDateToPicker = false }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerDialogWrapper( + onDateSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val date = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + onDateSelected(date) + } + onDismiss() + }, + ) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Anuluj") + } + }, + ) { + DatePicker(state = datePickerState) + } +} + +// ── Content sections ── + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ServiceEventList( + events: List, + hasMore: Boolean, + onLoadMore: () -> Unit, +) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = events, + key = { it.id }, + ) { event -> + ServiceEventCard(event = event) + } + + if (hasMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Button(onClick = onLoadMore) { + Text("Załaduj więcej") + } + } + } + } + } +} + +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Błąd", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = onRetry) { + Text("Spróbuj ponownie") + } + } +} + +@Composable +private fun EmptyContent() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.Build, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Brak historii serwisu", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Brak wyników", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceHistoryViewModel.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceHistoryViewModel.kt new file mode 100644 index 0000000..634f1db --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/ServiceHistoryViewModel.kt @@ -0,0 +1,225 @@ +package pl.firmatpp.itstransport.ui.service + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.model.ServiceEvent +import pl.firmatpp.itstransport.data.model.ServiceEventFilters +import pl.firmatpp.itstransport.data.repository.ServiceRepository +import java.io.IOException +import java.net.UnknownHostException +import javax.inject.Inject + +@HiltViewModel +class ServiceHistoryViewModel @Inject constructor( + private val serviceRepository: ServiceRepository, +) : ViewModel() { + + companion object { + private const val TAG = "ServiceHistoryVM" + } + + // ── Sealed UI state (K004 pattern: defined inside ViewModel file) ── + + sealed class ServiceHistoryUiState { + data object Idle : ServiceHistoryUiState() + data object Loading : ServiceHistoryUiState() + data class Success( + val events: List, + val hasMore: Boolean, + ) : ServiceHistoryUiState() + data class Error(val message: String) : ServiceHistoryUiState() + data object Empty : ServiceHistoryUiState() + } + + // ── Observable state ── + + private val _uiState = MutableStateFlow(ServiceHistoryUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Separate refreshing flag — never reuse Loading for pull-to-refresh (K007). */ + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _dateFrom = MutableStateFlow(null) + val dateFrom: StateFlow = _dateFrom.asStateFlow() + + private val _dateTo = MutableStateFlow(null) + val dateTo: StateFlow = _dateTo.asStateFlow() + + private val _sourceFilter = MutableStateFlow(null) + val sourceFilter: StateFlow = _sourceFilter.asStateFlow() + + // ── Pagination tracking ── + + private var currentPage: Int = 1 + private var lastPage: Int = 1 + private val allEvents: MutableList = mutableListOf() + + // ── Lifecycle ── + + init { + loadInitial() + } + + // ── Public API ── + + /** Initial load — shows Loading state. */ + fun loadInitial() { + Log.d(TAG, "loadInitial: starting first page fetch") + _uiState.value = ServiceHistoryUiState.Loading + currentPage = 1 + lastPage = 1 + allEvents.clear() + fetchEvents(page = 1, isLoadMore = false) + } + + /** Append next page to the accumulated list. */ + fun loadMore() { + if (currentPage >= lastPage) { + Log.d(TAG, "loadMore: already on last page ($currentPage/$lastPage), ignoring") + return + } + val nextPage = currentPage + 1 + Log.d(TAG, "loadMore: fetching page $nextPage") + fetchEvents(page = nextPage, isLoadMore = true) + } + + /** Pull-to-refresh — uses separate isRefreshing flag (K007). */ + fun refresh() { + Log.d(TAG, "Pull-to-refresh triggered") + _isRefreshing.value = true + currentPage = 1 + lastPage = 1 + allEvents.clear() + fetchEvents(page = 1, isLoadMore = false) + } + + fun updateSearch(query: String) { + Log.d(TAG, "Filter change: search='$query'") + _searchQuery.value = query + resetAndReload() + } + + fun updateDateFrom(date: String?) { + Log.d(TAG, "Filter change: dateFrom=$date") + _dateFrom.value = date + resetAndReload() + } + + fun updateDateTo(date: String?) { + Log.d(TAG, "Filter change: dateTo=$date") + _dateTo.value = date + resetAndReload() + } + + fun updateSourceFilter(source: String?) { + Log.d(TAG, "Filter change: source=$source") + _sourceFilter.value = source + resetAndReload() + } + + fun clearFilters() { + Log.d(TAG, "Clearing all filters") + _searchQuery.value = "" + _dateFrom.value = null + _dateTo.value = null + _sourceFilter.value = null + resetAndReload() + } + + // ── Private helpers ── + + /** Reset pagination and fetch from page 1 (used by filter changes). */ + private fun resetAndReload() { + _uiState.value = ServiceHistoryUiState.Loading + currentPage = 1 + lastPage = 1 + allEvents.clear() + fetchEvents(page = 1, isLoadMore = false) + } + + /** + * Fetches a page of service events. + * - [isLoadMore] = true → appends to [allEvents]. + * - [isLoadMore] = false → replaces [allEvents]. + */ + private fun fetchEvents(page: Int, isLoadMore: Boolean) { + viewModelScope.launch { + val filters = ServiceEventFilters( + search = _searchQuery.value, + dateFrom = _dateFrom.value, + dateTo = _dateTo.value, + source = _sourceFilter.value, + ) + + val result = serviceRepository.getEvents(filters, page) + + result.fold( + onSuccess = { response -> + currentPage = response.meta.currentPage + lastPage = response.meta.lastPage + + if (isLoadMore) { + allEvents.addAll(response.data) + } else { + allEvents.clear() + allEvents.addAll(response.data) + } + + if (allEvents.isEmpty()) { + Log.d(TAG, "Loaded page $currentPage/$lastPage — result list is empty") + _uiState.value = ServiceHistoryUiState.Empty + } else { + val hasMore = currentPage < lastPage + Log.d(TAG, "Loaded page $currentPage/$lastPage, total accumulated=${allEvents.size}, hasMore=$hasMore") + _uiState.value = ServiceHistoryUiState.Success( + events = allEvents.toList(), + hasMore = hasMore, + ) + } + }, + onFailure = { exception -> + val message = mapErrorToPolishMessage(exception) + Log.w(TAG, "Fetch failed: ${exception.javaClass.simpleName} → \"$message\"") + _uiState.value = ServiceHistoryUiState.Error(message) + }, + ) + + _isRefreshing.value = false + } + } + + /** Maps exceptions to Polish user-facing messages — mirrors HomeViewModel pattern. */ + private fun mapErrorToPolishMessage(exception: Throwable): String { + return when { + exception is IOException || exception is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + exception is ApiException -> when { + exception.code == 401 -> + "Sesja wygasła. Zaloguj się ponownie." + exception.code == 403 -> + "Brak uprawnień do wyświetlenia historii serwisu." + exception.code in 500..599 -> + "Błąd serwera. Spróbuj ponownie za chwilę." + else -> + "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + + exception.cause is IOException || exception.cause is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + else -> "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/AddServiceScreen.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/AddServiceScreen.kt new file mode 100644 index 0000000..9239375 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/AddServiceScreen.kt @@ -0,0 +1,334 @@ +package pl.firmatpp.itstransport.ui.service.wizard + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +// ────────────────────────────────────────────── +// AddServiceScreen — Wizard host composable +// +// Creates the ViewModel, routes to the 3 steps, +// shows step progress, handles back press, and +// manages loading/error/success states. +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddServiceScreen( + onNavigateBack: () -> Unit, + onServiceSubmitted: () -> Unit = {}, + viewModel: AddServiceViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentStep by viewModel.currentStep.collectAsStateWithLifecycle() + val context = LocalContext.current + + // Wire the onServiceAdded callback to show toast + navigate back + LaunchedEffect(Unit) { + viewModel.onServiceAdded = { + // Callback fires from ViewModel after successful submit + } + } + + // Auto-navigate back on submit success — signals refresh via onServiceSubmitted + LaunchedEffect(uiState) { + if (uiState is AddServiceViewModel.WizardUiState.SubmitSuccess) { + Toast.makeText(context, "Serwis zapisany", Toast.LENGTH_SHORT).show() + onServiceSubmitted() + } + } + + // Handle system back press — go to previous step or exit wizard + BackHandler { + when (currentStep) { + AddServiceViewModel.WizardStep.VEHICLE -> onNavigateBack() + else -> viewModel.goToPrevious() + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Dodaj serwis") }, + navigationIcon = { + IconButton(onClick = { + when (currentStep) { + AddServiceViewModel.WizardStep.VEHICLE -> onNavigateBack() + else -> viewModel.goToPrevious() + } + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Wstecz", + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // ── Step progress indicator ── + StepProgressIndicator(currentStep = currentStep) + + // ── Content area ── + when (val state = uiState) { + is AddServiceViewModel.WizardUiState.Loading -> { + LoadingContent() + } + + is AddServiceViewModel.WizardUiState.Error -> { + ErrorContent( + message = state.message, + onRetry = viewModel::retryLoad, + ) + } + + is AddServiceViewModel.WizardUiState.Ready, + is AddServiceViewModel.WizardUiState.Submitting, + is AddServiceViewModel.WizardUiState.SubmitError, + is AddServiceViewModel.WizardUiState.SubmitSuccess -> { + WizardStepContent( + viewModel = viewModel, + uiState = state, + currentStep = currentStep, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +// ────────────────────────────────────────────── +// Step progress indicator +// ────────────────────────────────────────────── + +@Composable +private fun StepProgressIndicator( + currentStep: AddServiceViewModel.WizardStep, +) { + val stepIndex = AddServiceViewModel.WizardStep.entries.indexOf(currentStep) + val totalSteps = AddServiceViewModel.WizardStep.entries.size + val progress = (stepIndex + 1).toFloat() / totalSteps + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + val labels = listOf("Pojazd", "Usługi", "Podsumowanie") + labels.forEachIndexed { index, label -> + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = if (index <= stepIndex) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.weight(1f), + textAlign = when (index) { + 0 -> TextAlign.Start + labels.lastIndex -> TextAlign.End + else -> TextAlign.Center + }, + ) + } + } + } +} + +// ────────────────────────────────────────────── +// Wizard step routing with animated transitions +// ────────────────────────────────────────────── + +@Composable +private fun WizardStepContent( + viewModel: AddServiceViewModel, + uiState: AddServiceViewModel.WizardUiState, + currentStep: AddServiceViewModel.WizardStep, + modifier: Modifier = Modifier, +) { + // Collect all state needed by steps + val vehicleTab by viewModel.vehicleTab.collectAsStateWithLifecycle() + val searchQuery by viewModel.vehicleSearchQuery.collectAsStateWithLifecycle() + val selectedVehicleId by viewModel.selectedVehicleId.collectAsStateWithLifecycle() + val selectedServices by viewModel.selectedServices.collectAsStateWithLifecycle() + val expandedCategoryId by viewModel.expandedCategoryId.collectAsStateWithLifecycle() + val serviceDate by viewModel.serviceDate.collectAsStateWithLifecycle() + val mileage by viewModel.mileage.collectAsStateWithLifecycle() + val generalNotes by viewModel.generalNotes.collectAsStateWithLifecycle() + + // Reference data is cached on the ViewModel — survives Submitting/SubmitError states + val trucks = viewModel.trucks + val trailers = viewModel.trailers + val serviceTypes = viewModel.serviceTypes + + val isSubmitting = uiState is AddServiceViewModel.WizardUiState.Submitting + val submitError = (uiState as? AddServiceViewModel.WizardUiState.SubmitError)?.message + + AnimatedContent( + targetState = currentStep, + modifier = modifier, + transitionSpec = { + val forward = targetState.ordinal > initialState.ordinal + if (forward) { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + } else { + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + } + }, + label = "wizard_step", + ) { step -> + when (step) { + AddServiceViewModel.WizardStep.VEHICLE -> { + VehicleSelectStep( + trucks = trucks, + trailers = trailers, + vehicleTab = vehicleTab, + searchQuery = searchQuery, + selectedVehicleId = selectedVehicleId, + onTabChange = viewModel::switchVehicleTab, + onSearchChange = viewModel::updateVehicleSearch, + onVehicleSelect = viewModel::selectVehicle, + onNext = viewModel::goToNext, + ) + } + + AddServiceViewModel.WizardStep.SERVICES -> { + ServiceSelectStep( + serviceTypes = viewModel.filteredServiceTypes(), + selectedServices = selectedServices, + expandedCategoryId = expandedCategoryId, + vehicleTab = vehicleTab, + onToggleService = viewModel::toggleService, + onToggleCategory = viewModel::toggleCategory, + onUpdatePositions = viewModel::updatePositions, + onUpdateNotes = viewModel::updateServiceNotes, + onUpdateAmount = viewModel::updateServiceAmount, + onBack = viewModel::goToPrevious, + onNext = viewModel::goToNext, + ) + } + + AddServiceViewModel.WizardStep.SUMMARY -> { + SummaryStep( + serviceDate = serviceDate, + mileage = mileage, + generalNotes = generalNotes, + selectedVehicleId = selectedVehicleId, + vehicleTab = vehicleTab, + trucks = trucks, + trailers = trailers, + selectedServices = selectedServices, + isSubmitting = isSubmitting, + submitError = submitError, + onDateChange = viewModel::updateDate, + onMileageChange = viewModel::updateMileage, + onNotesChange = viewModel::updateNotes, + onBack = viewModel::goToPrevious, + onSubmit = viewModel::submitService, + onDismissError = viewModel::dismissSubmitError, + ) + } + } + } +} + +// ────────────────────────────────────────────── +// Loading / Error states +// ────────────────────────────────────────────── + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = onRetry) { + Text("Spróbuj ponownie") + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/AddServiceViewModel.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/AddServiceViewModel.kt new file mode 100644 index 0000000..3e15610 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/AddServiceViewModel.kt @@ -0,0 +1,404 @@ +package pl.firmatpp.itstransport.ui.service.wizard + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pl.firmatpp.itstransport.data.auth.ApiException +import pl.firmatpp.itstransport.data.model.CreateServiceEventRequest +import pl.firmatpp.itstransport.data.model.CreateServiceItemRequest +import pl.firmatpp.itstransport.data.model.Position +import pl.firmatpp.itstransport.data.model.SelectedService +import pl.firmatpp.itstransport.data.model.ServiceType +import pl.firmatpp.itstransport.data.model.Trailer +import pl.firmatpp.itstransport.data.model.Truck +import pl.firmatpp.itstransport.data.repository.ServiceWizardRepository +import java.io.IOException +import java.net.UnknownHostException +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +@HiltViewModel +class AddServiceViewModel @Inject constructor( + private val repository: ServiceWizardRepository, +) : ViewModel() { + + companion object { + private const val TAG = "AddServiceVM" + } + + // ══════════════════════════════════════════════ + // Enums + // ══════════════════════════════════════════════ + + enum class WizardStep { VEHICLE, SERVICES, SUMMARY } + + enum class VehicleTab { TRUCKS, TRAILERS } + + // ══════════════════════════════════════════════ + // Sealed UI state (K004: defined inside ViewModel file) + // ══════════════════════════════════════════════ + + sealed class WizardUiState { + data object Loading : WizardUiState() + + data class Ready( + val trucks: List, + val trailers: List, + val serviceTypes: List, + ) : WizardUiState() + + data object Submitting : WizardUiState() + data object SubmitSuccess : WizardUiState() + data class SubmitError(val message: String) : WizardUiState() + + data class Error(val message: String) : WizardUiState() + } + + // ══════════════════════════════════════════════ + // Observable state + // ══════════════════════════════════════════════ + + private val _uiState = MutableStateFlow(WizardUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + // -- Wizard step navigation -- + private val _currentStep = MutableStateFlow(WizardStep.VEHICLE) + val currentStep: StateFlow = _currentStep.asStateFlow() + + // -- Vehicle selection -- + private val _vehicleTab = MutableStateFlow(VehicleTab.TRUCKS) + val vehicleTab: StateFlow = _vehicleTab.asStateFlow() + + private val _selectedVehicleId = MutableStateFlow(null) + val selectedVehicleId: StateFlow = _selectedVehicleId.asStateFlow() + + private val _vehicleSearchQuery = MutableStateFlow("") + val vehicleSearchQuery: StateFlow = _vehicleSearchQuery.asStateFlow() + + // -- Service selection -- + private val _selectedServices = MutableStateFlow>(emptyList()) + val selectedServices: StateFlow> = _selectedServices.asStateFlow() + + private val _expandedCategoryId = MutableStateFlow(null) + val expandedCategoryId: StateFlow = _expandedCategoryId.asStateFlow() + + // -- Summary fields -- + private val _serviceDate = MutableStateFlow( + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE), + ) + val serviceDate: StateFlow = _serviceDate.asStateFlow() + + private val _mileage = MutableStateFlow(null) + val mileage: StateFlow = _mileage.asStateFlow() + + private val _generalNotes = MutableStateFlow(null) + val generalNotes: StateFlow = _generalNotes.asStateFlow() + + // Callback invoked after successful submission so the caller can trigger a history refresh. + var onServiceAdded: (() -> Unit)? = null + + // Cache reference data so it survives across steps without re-fetching + private var _trucks: List = emptyList() + private var _trailers: List = emptyList() + private var _serviceTypes: List = emptyList() + + // Public accessors for reference data — cached across all UI states + val trucks: List get() = _trucks + val trailers: List get() = _trailers + val serviceTypes: List get() = _serviceTypes + + // ══════════════════════════════════════════════ + // Init — load reference data in parallel + // ══════════════════════════════════════════════ + + init { + loadReferenceData() + } + + fun retryLoad() { + Log.d(TAG, "retryLoad: reloading reference data") + _uiState.value = WizardUiState.Loading + loadReferenceData() + } + + private fun loadReferenceData() { + Log.d(TAG, "Loading reference data (trucks, trailers, service types)") + viewModelScope.launch { + val trucksDeferred = async { repository.getTrucks() } + val trailersDeferred = async { repository.getTrailers() } + val serviceTypesDeferred = async { repository.getServiceTypes() } + + val trucksResult = trucksDeferred.await() + val trailersResult = trailersDeferred.await() + val serviceTypesResult = serviceTypesDeferred.await() + + // If any call failed, surface the first error + val firstFailure = listOf(trucksResult, trailersResult, serviceTypesResult) + .firstOrNull { it.isFailure } + + if (firstFailure != null) { + val error = firstFailure.exceptionOrNull()!! + val message = mapErrorToPolishMessage(error) + Log.w(TAG, "Reference data load failed: ${error.javaClass.simpleName} → \"$message\"") + _uiState.value = WizardUiState.Error(message) + return@launch + } + + _trucks = trucksResult.getOrThrow() + _trailers = trailersResult.getOrThrow() + _serviceTypes = serviceTypesResult.getOrThrow() + + Log.d( + TAG, + "Reference data loaded: trucks=${_trucks.size}, trailers=${_trailers.size}, serviceTypes=${_serviceTypes.size}", + ) + _uiState.value = WizardUiState.Ready( + trucks = _trucks, + trailers = _trailers, + serviceTypes = _serviceTypes, + ) + } + } + + // ══════════════════════════════════════════════ + // Step navigation + // ══════════════════════════════════════════════ + + fun goToNext() { + val next = when (_currentStep.value) { + WizardStep.VEHICLE -> WizardStep.SERVICES + WizardStep.SERVICES -> WizardStep.SUMMARY + WizardStep.SUMMARY -> return // already at last step + } + Log.d(TAG, "Step: ${_currentStep.value} → $next") + _currentStep.value = next + } + + fun goToPrevious() { + val prev = when (_currentStep.value) { + WizardStep.VEHICLE -> return // already at first step + WizardStep.SERVICES -> WizardStep.VEHICLE + WizardStep.SUMMARY -> WizardStep.SERVICES + } + Log.d(TAG, "Step: ${_currentStep.value} → $prev") + _currentStep.value = prev + } + + // ══════════════════════════════════════════════ + // Vehicle selection + // ══════════════════════════════════════════════ + + fun switchVehicleTab(tab: VehicleTab) { + if (_vehicleTab.value == tab) return + Log.d(TAG, "Vehicle tab: ${_vehicleTab.value} → $tab") + _vehicleTab.value = tab + // Clear selection when switching tabs — a truck ID is not valid for trailers and vice versa + _selectedVehicleId.value = null + _vehicleSearchQuery.value = "" + // Clear selected services since they may be filtered by vehicle type + _selectedServices.value = emptyList() + _expandedCategoryId.value = null + } + + fun selectVehicle(vehicleId: Long) { + Log.d(TAG, "Selected vehicle: id=$vehicleId (tab=${_vehicleTab.value})") + _selectedVehicleId.value = vehicleId + } + + fun updateVehicleSearch(query: String) { + _vehicleSearchQuery.value = query + } + + /** + * Returns service types filtered by current vehicle tab. + * A category with vehicleType "all" is always shown. + * "truck" categories show on TRUCKS tab; "truck_trailer" on TRAILERS tab. + */ + fun filteredServiceTypes(): List { + val tabFilter = when (_vehicleTab.value) { + VehicleTab.TRUCKS -> "truck" + VehicleTab.TRAILERS -> "truck_trailer" + } + return _serviceTypes.filter { st -> + val catType = st.category?.vehicleType + catType == null || catType == "all" || catType == tabFilter + } + } + + // ══════════════════════════════════════════════ + // Service selection + // ══════════════════════════════════════════════ + + fun toggleService(serviceType: ServiceType) { + val current = _selectedServices.value.toMutableList() + val existing = current.indexOfFirst { it.serviceType.id == serviceType.id } + if (existing >= 0) { + current.removeAt(existing) + Log.d(TAG, "Removed service: ${serviceType.name} (id=${serviceType.id})") + } else { + current.add(SelectedService(serviceType = serviceType)) + Log.d(TAG, "Added service: ${serviceType.name} (id=${serviceType.id})") + } + _selectedServices.value = current + } + + fun removeService(serviceTypeId: Long) { + val current = _selectedServices.value.toMutableList() + val removed = current.removeAll { it.serviceType.id == serviceTypeId } + if (removed) { + Log.d(TAG, "Removed service id=$serviceTypeId") + _selectedServices.value = current + } + } + + fun updateServiceNotes(serviceTypeId: Long, notes: String?) { + _selectedServices.value = _selectedServices.value.map { s -> + if (s.serviceType.id == serviceTypeId) s.copy(notes = notes) else s + } + } + + fun updateServiceAmount(serviceTypeId: Long, amount: String?) { + _selectedServices.value = _selectedServices.value.map { s -> + if (s.serviceType.id == serviceTypeId) s.copy(amount = amount) else s + } + } + + fun updatePositions(serviceTypeId: Long, positions: List) { + _selectedServices.value = _selectedServices.value.map { s -> + if (s.serviceType.id == serviceTypeId) s.copy(selectedPositions = positions) else s + } + } + + fun toggleCategory(categoryId: Long) { + _expandedCategoryId.value = if (_expandedCategoryId.value == categoryId) null else categoryId + } + + // ══════════════════════════════════════════════ + // Summary fields + // ══════════════════════════════════════════════ + + fun updateDate(date: String) { + _serviceDate.value = date + } + + fun updateMileage(mileage: Int?) { + _mileage.value = mileage + } + + fun updateNotes(notes: String?) { + _generalNotes.value = notes + } + + // ══════════════════════════════════════════════ + // Submission + // ══════════════════════════════════════════════ + + fun submitService() { + val vehicleId = _selectedVehicleId.value + if (vehicleId == null) { + Log.w(TAG, "submitService called without selected vehicle") + _uiState.value = WizardUiState.SubmitError("Wybierz pojazd przed zapisaniem.") + return + } + + val services = _selectedServices.value + if (services.isEmpty()) { + Log.w(TAG, "submitService called with no selected services") + _uiState.value = WizardUiState.SubmitError("Dodaj przynajmniej jedną usługę.") + return + } + + val costableType = when (_vehicleTab.value) { + VehicleTab.TRUCKS -> "truck" + VehicleTab.TRAILERS -> "truck_trailer" + } + + val items = services.map { selected -> + CreateServiceItemRequest( + serviceTypeId = selected.serviceType.id, + name = selected.serviceType.name, + notes = selected.notes, + amount = selected.amount, + positionIds = selected.selectedPositions + .map { it.id } + .ifEmpty { null }, + ) + } + + val request = CreateServiceEventRequest( + costableType = costableType, + costableId = vehicleId, + serviceDate = _serviceDate.value, + mileage = _mileage.value, + generalNotes = _generalNotes.value, + items = items, + ) + + Log.d(TAG, "Submitting: costableType=$costableType, vehicleId=$vehicleId, items=${items.size}") + _uiState.value = WizardUiState.Submitting + + viewModelScope.launch { + val result = repository.submitServiceEvent(request) + result.fold( + onSuccess = { event -> + Log.d(TAG, "Submit success: event id=${event.id}") + _uiState.value = WizardUiState.SubmitSuccess + onServiceAdded?.invoke() + }, + onFailure = { error -> + val message = mapErrorToPolishMessage(error) + Log.w(TAG, "Submit failed: ${error.javaClass.simpleName} → \"$message\"") + _uiState.value = WizardUiState.SubmitError(message) + }, + ) + } + } + + /** + * Restore Ready state after a submit error so the user can correct and retry. + */ + fun dismissSubmitError() { + _uiState.value = WizardUiState.Ready( + trucks = _trucks, + trailers = _trailers, + serviceTypes = _serviceTypes, + ) + } + + // ══════════════════════════════════════════════ + // Error mapping — Polish user-facing messages + // (mirrors ServiceHistoryViewModel pattern) + // ══════════════════════════════════════════════ + + private fun mapErrorToPolishMessage(exception: Throwable): String { + return when { + exception is IOException || exception is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + exception is ApiException -> when { + exception.code == 401 -> + "Sesja wygasła. Zaloguj się ponownie." + exception.code == 403 -> + "Brak uprawnień do dodania serwisu." + exception.code == 422 -> + exception.apiError?.message ?: "Błąd walidacji danych. Sprawdź formularz." + exception.code in 500..599 -> + "Błąd serwera. Spróbuj ponownie za chwilę." + else -> + "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + + exception.cause is IOException || exception.cause is UnknownHostException -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + + else -> "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/ServiceSelectStep.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/ServiceSelectStep.kt new file mode 100644 index 0000000..1cfb476 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/ServiceSelectStep.kt @@ -0,0 +1,482 @@ +package pl.firmatpp.itstransport.ui.service.wizard + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.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.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import pl.firmatpp.itstransport.data.model.Position +import pl.firmatpp.itstransport.data.model.PositionGroup +import pl.firmatpp.itstransport.data.model.SelectedService +import pl.firmatpp.itstransport.data.model.ServiceCategory +import pl.firmatpp.itstransport.data.model.ServiceType + +// ────────────────────────────────────────────── +// ServiceSelectStep — Step 2 of the Add Service wizard +// +// Top-to-bottom: +// 1. Selected count header +// 2. Category cards (expand/collapse) with service type tiles +// 3. Per-service detail (positions, notes, amount) +// 4. Navigation buttons (Wstecz / Dalej) +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ServiceSelectStep( + serviceTypes: List, + selectedServices: List, + expandedCategoryId: Long?, + vehicleTab: AddServiceViewModel.VehicleTab, + onToggleService: (ServiceType) -> Unit, + onToggleCategory: (Long) -> Unit, + onUpdatePositions: (serviceTypeId: Long, positions: List) -> Unit, + onUpdateNotes: (serviceTypeId: Long, notes: String?) -> Unit, + onUpdateAmount: (serviceTypeId: Long, amount: String?) -> Unit, + onBack: () -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier, +) { + // Group service types by category for display + val categorizedTypes = serviceTypes + .groupBy { it.category } + .toSortedMap(compareBy { it?.id ?: Long.MAX_VALUE }) + + val selectedIds = selectedServices.map { it.serviceType.id }.toSet() + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // ── 1. Selected count header ── + Text( + text = "Wybrane usługi: ${selectedServices.size}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // ── 2. Scrollable content (categories + selected service details) ── + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Category cards + items( + items = categorizedTypes.entries.toList(), + key = { it.key?.id ?: -1L }, + ) { (category, types) -> + CategoryCard( + category = category, + serviceTypes = types, + selectedIds = selectedIds, + isExpanded = category?.id == expandedCategoryId, + vehicleTab = vehicleTab, + onToggleExpand = { category?.id?.let(onToggleCategory) }, + onToggleService = onToggleService, + ) + } + + // Per-service detail sections (notes, amount, positions) + if (selectedServices.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Szczegóły wybranych usług", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + items( + items = selectedServices, + key = { it.serviceType.id }, + ) { selected -> + SelectedServiceDetail( + selected = selected, + vehicleTab = vehicleTab, + onUpdatePositions = onUpdatePositions, + onUpdateNotes = onUpdateNotes, + onUpdateAmount = onUpdateAmount, + ) + } + } + } + + // ── 3. Navigation buttons ── + Spacer(modifier = Modifier.height(12.dp)) + NavigationButtons( + canGoNext = selectedServices.isNotEmpty(), + onBack = onBack, + onNext = onNext, + ) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +// ────────────────────────────────────────────── +// CategoryCard — expandable card for a service category +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun CategoryCard( + category: ServiceCategory?, + serviceTypes: List, + selectedIds: Set, + isExpanded: Boolean, + vehicleTab: AddServiceViewModel.VehicleTab, + onToggleExpand: () -> Unit, + onToggleService: (ServiceType) -> Unit, +) { + val categoryName = category?.name ?: "Inne" + val firstLetter = categoryName.first().uppercase() + + ElevatedCard( + onClick = onToggleExpand, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header row: badge + name + chevron + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // First-letter badge + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp), + ) { + Text( + text = firstLetter, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = categoryName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (isExpanded) "Zwiń" else "Rozwiń", + ) + } + + // Expanded content: service type tiles + AnimatedVisibility(visible = isExpanded) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + serviceTypes.forEach { serviceType -> + ServiceTypeTile( + serviceType = serviceType, + isSelected = serviceType.id in selectedIds, + onClick = { onToggleService(serviceType) }, + ) + } + } + } + } + } +} + +// ────────────────────────────────────────────── +// ServiceTypeTile — tappable chip for a single service type +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ServiceTypeTile( + serviceType: ServiceType, + isSelected: Boolean, + onClick: () -> Unit, +) { + val borderStroke = if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + } + + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + + OutlinedCard( + onClick = onClick, + border = borderStroke, + colors = CardDefaults.outlinedCardColors(containerColor = containerColor), + ) { + Text( + text = serviceType.name ?: "—", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } +} + +// ────────────────────────────────────────────── +// SelectedServiceDetail — per-service notes, amount, positions +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun SelectedServiceDetail( + selected: SelectedService, + vehicleTab: AddServiceViewModel.VehicleTab, + onUpdatePositions: (serviceTypeId: Long, positions: List) -> Unit, + onUpdateNotes: (serviceTypeId: Long, notes: String?) -> Unit, + onUpdateAmount: (serviceTypeId: Long, amount: String?) -> Unit, +) { + val serviceTypeId = selected.serviceType.id + val expanded = rememberSaveable { mutableStateOf(true) } + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header row with expand toggle + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = selected.serviceType.name ?: "—", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + androidx.compose.material3.IconButton( + onClick = { expanded.value = !expanded.value }, + ) { + Icon( + imageVector = if (expanded.value) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded.value) "Zwiń" else "Rozwiń", + ) + } + } + + AnimatedVisibility(visible = expanded.value) { + Column { + // Position pickers (if service type has position groups) + val positionGroups = selected.serviceType.positionGroups + if (!positionGroups.isNullOrEmpty()) { + val filteredGroups = filterPositionGroups(positionGroups, vehicleTab) + if (filteredGroups.isNotEmpty()) { + PositionPicker( + positionGroups = filteredGroups, + selectedPositions = selected.selectedPositions, + onPositionsChanged = { positions -> + onUpdatePositions(serviceTypeId, positions) + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // Notes field + OutlinedTextField( + value = selected.notes ?: "", + onValueChange = { value -> + onUpdateNotes(serviceTypeId, value.ifBlank { null }) + }, + placeholder = { Text("Uwagi...") }, + singleLine = false, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Amount field + OutlinedTextField( + value = selected.amount ?: "", + onValueChange = { value -> + onUpdateAmount(serviceTypeId, value.ifBlank { null }) + }, + placeholder = { Text("Kwota") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +// ────────────────────────────────────────────── +// PositionPicker — FilterChips grouped by PositionGroup +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun PositionPicker( + positionGroups: List, + selectedPositions: List, + onPositionsChanged: (List) -> Unit, +) { + val selectedIds = selectedPositions.map { it.id }.toSet() + + Column { + Text( + text = "Pozycje", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + + positionGroups.forEach { group -> + if (group.positions.isNullOrEmpty()) return@forEach + + Text( + text = group.name ?: "—", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + group.positions.forEach { position -> + val isSelected = position.id in selectedIds + FilterChip( + selected = isSelected, + onClick = { + val newPositions = if (isSelected) { + selectedPositions.filter { it.id != position.id } + } else { + selectedPositions + position + } + onPositionsChanged(newPositions) + }, + label = { + Text( + text = position.name ?: "—", + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + } + } + } + } +} + +// ────────────────────────────────────────────── +// NavigationButtons — Wstecz / Dalej +// ────────────────────────────────────────────── + +@Composable +private fun NavigationButtons( + canGoNext: Boolean, + onBack: () -> Unit, + onNext: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onBack, + modifier = Modifier + .weight(1f) + .height(52.dp), + ) { + Text("Wstecz") + } + Button( + onClick = onNext, + enabled = canGoNext, + modifier = Modifier + .weight(1f) + .height(52.dp), + ) { + Text("Dalej") + } + } +} + +// ────────────────────────────────────────────── +// Utility — filter position groups by vehicle type +// ────────────────────────────────────────────── + +private fun filterPositionGroups( + groups: List, + vehicleTab: AddServiceViewModel.VehicleTab, +): List { + val tabType = when (vehicleTab) { + AddServiceViewModel.VehicleTab.TRUCKS -> "truck" + AddServiceViewModel.VehicleTab.TRAILERS -> "truck_trailer" + } + return groups.filter { group -> + val vt = group.vehicleType + vt == null || vt == "all" || vt == tabType + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/SummaryStep.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/SummaryStep.kt new file mode 100644 index 0000000..a0cadad --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/SummaryStep.kt @@ -0,0 +1,403 @@ +package pl.firmatpp.itstransport.ui.service.wizard + +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import pl.firmatpp.itstransport.data.model.SelectedService +import pl.firmatpp.itstransport.data.model.Trailer +import pl.firmatpp.itstransport.data.model.Truck +import java.time.Instant +import java.time.ZoneId + +// ────────────────────────────────────────────── +// SummaryStep — Step 3 of the Add Service wizard +// +// Review screen before submission: +// 1. Date picker (Data serwisu) +// 2. Mileage input (Przebieg km, optional) +// 3. General notes (Uwagi ogólne, optional) +// 4. Selected vehicle summary card +// 5. Selected services list +// 6. Navigation: Wstecz / Zapisz serwis +// 7. Error display with retry +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SummaryStep( + serviceDate: String, + mileage: Int?, + generalNotes: String?, + selectedVehicleId: Long?, + vehicleTab: AddServiceViewModel.VehicleTab, + trucks: List, + trailers: List, + selectedServices: List, + isSubmitting: Boolean, + submitError: String?, + onDateChange: (String) -> Unit, + onMileageChange: (Int?) -> Unit, + onNotesChange: (String?) -> Unit, + onBack: () -> Unit, + onSubmit: () -> Unit, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDatePicker by remember { mutableStateOf(false) } + + // Resolve selected vehicle for display + val vehiclePlate: String + val vehicleModel: String + when (vehicleTab) { + AddServiceViewModel.VehicleTab.TRUCKS -> { + val truck = trucks.find { it.id == selectedVehicleId } + vehiclePlate = truck?.licensePlate ?: "—" + vehicleModel = listOfNotNull(truck?.brand, truck?.model) + .joinToString(" ").ifBlank { truck?.name ?: "—" } + } + AddServiceViewModel.VehicleTab.TRAILERS -> { + val trailer = trailers.find { it.id == selectedVehicleId } + vehiclePlate = trailer?.licensePlate ?: "—" + vehicleModel = listOfNotNull(trailer?.brand, trailer?.model) + .joinToString(" ").ifBlank { trailer?.name ?: "—" } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Podsumowanie", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + // ── 1. Date picker ── + item { + OutlinedTextField( + value = serviceDate, + onValueChange = {}, + readOnly = true, + label = { Text("Data serwisu") }, + trailingIcon = { + TextButton(onClick = { showDatePicker = true }) { + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = "Wybierz datę", + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + // ── 2. Mileage ── + item { + OutlinedTextField( + value = mileage?.toString() ?: "", + onValueChange = { value -> + onMileageChange(value.filter { it.isDigit() }.toIntOrNull()) + }, + label = { Text("Przebieg (km)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + + // ── 3. General notes ── + item { + OutlinedTextField( + value = generalNotes ?: "", + onValueChange = { value -> + onNotesChange(value.ifBlank { null }) + }, + label = { Text("Uwagi ogólne") }, + singleLine = false, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + } + + // ── 4. Selected vehicle card ── + item { + Text( + text = "Pojazd", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = vehiclePlate, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = vehicleModel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + + // ── 5. Selected services ── + item { + Text( + text = "Usługi (${selectedServices.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp), + ) + } + + items( + items = selectedServices, + key = { it.serviceType.id }, + ) { selected -> + ServiceSummaryCard(selected = selected) + } + } + + // ── Error display ── + AnimatedVisibility(visible = submitError != null) { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Text( + text = submitError ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismissError) { + Text("Ponów") + } + } + } + } + + // ── 6. Navigation buttons ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onBack, + enabled = !isSubmitting, + modifier = Modifier + .weight(1f) + .height(52.dp), + ) { + Text("Wstecz") + } + Button( + onClick = onSubmit, + enabled = !isSubmitting, + modifier = Modifier + .weight(1f) + .height(52.dp), + ) { + if (isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Zapisz serwis") + } + } + } + } + + // ── Date picker dialog (K009 pattern) ── + if (showDatePicker) { + DatePickerDialogWrapper( + onDateSelected = { onDateChange(it) }, + onDismiss = { showDatePicker = false }, + ) + } +} + +// ────────────────────────────────────────────── +// DatePickerDialogWrapper — reuses K009 pattern +// from ServiceHistoryScreen +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerDialogWrapper( + onDateSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val date = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + onDateSelected(date) + } + onDismiss() + }, + ) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Anuluj") + } + }, + ) { + DatePicker(state = datePickerState) + } +} + +// ────────────────────────────────────────────── +// ServiceSummaryCard — single service in review +// ────────────────────────────────────────────── + +@Composable +private fun ServiceSummaryCard(selected: SelectedService) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Service name + category + Text( + text = selected.serviceType.name ?: "—", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + selected.serviceType.category?.name?.let { catName -> + Text( + text = catName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Amount + if (!selected.amount.isNullOrBlank()) { + Text( + text = "Kwota: ${selected.amount}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + } + + // Notes + if (!selected.notes.isNullOrBlank()) { + Text( + text = "Uwagi: ${selected.notes}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Positions + if (selected.selectedPositions.isNotEmpty()) { + val positionNames = selected.selectedPositions + .mapNotNull { it.name } + .joinToString(", ") + Text( + text = "Pozycje: $positionNames", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/VehicleSelectStep.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/VehicleSelectStep.kt new file mode 100644 index 0000000..8e7e62c --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/service/wizard/VehicleSelectStep.kt @@ -0,0 +1,286 @@ +package pl.firmatpp.itstransport.ui.service.wizard + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +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.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import pl.firmatpp.itstransport.data.model.Trailer +import pl.firmatpp.itstransport.data.model.Truck + +// ────────────────────────────────────────────── +// Vehicle representation used by the grid — unifies +// Truck and Trailer into a single renderable item. +// ────────────────────────────────────────────── + +private data class VehicleItem( + val id: Long, + val licensePlate: String, + val modelName: String, + val mileageLabel: String?, +) + +private fun Truck.toItem() = VehicleItem( + id = id, + licensePlate = licensePlate ?: "—", + modelName = listOfNotNull(brand, model).joinToString(" ").ifBlank { name ?: "—" }, + mileageLabel = mileage?.let { "Przebieg: $it km" }, +) + +private fun Trailer.toItem() = VehicleItem( + id = id, + licensePlate = licensePlate ?: "—", + modelName = listOfNotNull(brand, model).joinToString(" ").ifBlank { name ?: "—" }, + mileageLabel = null, // trailers don't have mileage +) + +// ────────────────────────────────────────────── +// VehicleSelectStep — Step 1 of the Add Service wizard +// ────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VehicleSelectStep( + trucks: List, + trailers: List, + vehicleTab: AddServiceViewModel.VehicleTab, + searchQuery: String, + selectedVehicleId: Long?, + onTabChange: (AddServiceViewModel.VehicleTab) -> Unit, + onSearchChange: (String) -> Unit, + onVehicleSelect: (Long) -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier, +) { + // Build the filtered item list based on the active tab and search query + val items: List = when (vehicleTab) { + AddServiceViewModel.VehicleTab.TRUCKS -> trucks.map { it.toItem() } + AddServiceViewModel.VehicleTab.TRAILERS -> trailers.map { it.toItem() } + }.let { list -> + if (searchQuery.isBlank()) list + else { + val q = searchQuery.trim().lowercase() + list.filter { item -> + item.licensePlate.lowercase().contains(q) || + item.modelName.lowercase().contains(q) + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // ── 1. Tab pills row ── + TabPillsRow( + vehicleTab = vehicleTab, + onTabChange = onTabChange, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // ── 2. Search bar ── + VehicleSearchBar( + query = searchQuery, + onQueryChange = onSearchChange, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // ── 3. Vehicle grid ── + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(1f), + ) { + items( + items = items, + key = { it.id }, + ) { vehicle -> + VehicleTile( + vehicle = vehicle, + isSelected = vehicle.id == selectedVehicleId, + onClick = { onVehicleSelect(vehicle.id) }, + ) + } + } + + // ── 4. "Dalej" button ── + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onNext, + enabled = selectedVehicleId != null, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), // large touch target (R009) + ) { + Text("Dalej") + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +// ── Tab pills ── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TabPillsRow( + vehicleTab: AddServiceViewModel.VehicleTab, + onTabChange: (AddServiceViewModel.VehicleTab) -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = vehicleTab == AddServiceViewModel.VehicleTab.TRUCKS, + onClick = { onTabChange(AddServiceViewModel.VehicleTab.TRUCKS) }, + label = { Text("Ciągniki") }, + ) + FilterChip( + selected = vehicleTab == AddServiceViewModel.VehicleTab.TRAILERS, + onClick = { onTabChange(AddServiceViewModel.VehicleTab.TRAILERS) }, + label = { Text("Naczepy") }, + ) + } +} + +// ── Search bar ── + +@Composable +private fun VehicleSearchBar( + query: String, + onQueryChange: (String) -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Szukaj pojazd...") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Szukaj", + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Wyczyść", + ) + } + } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) +} + +// ── Vehicle tile ── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VehicleTile( + vehicle: VehicleItem, + isSelected: Boolean, + onClick: () -> Unit, +) { + val borderStroke = if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + } + + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + + ElevatedCard( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(110.dp), // large touch target for workshop use (R009) + colors = CardDefaults.elevatedCardColors( + containerColor = containerColor, + ), + border = borderStroke, + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // License plate — large, bold + Text( + text = vehicle.licensePlate, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + ) + + // Model name — smaller + Text( + text = vehicle.modelName, + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + ) + + // Mileage — trucks only + if (vehicle.mileageLabel != null) { + Text( + text = vehicle.mileageLabel, + style = MaterialTheme.typography.labelSmall, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + ) + } + } + } +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Color.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Color.kt new file mode 100644 index 0000000..e49aa3b --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Color.kt @@ -0,0 +1,53 @@ +package pl.firmatpp.itstransport.ui.theme + +import androidx.compose.ui.graphics.Color + +// ITS Transport brand — teal primary matching web frontend teal-600 +val Teal600 = Color(0xFF0D9488) +val Teal700 = Color(0xFF0F766E) +val Teal800 = Color(0xFF115E59) +val Teal50 = Color(0xFFF0FDFA) +val Teal100 = Color(0xFFCCFBF1) + +// Secondary — slate tones for professional look +val Slate600 = Color(0xFF475569) +val Slate700 = Color(0xFF334155) +val Slate50 = Color(0xFFF8FAFC) +val Slate100 = Color(0xFFF1F5F9) + +// Tertiary — amber accent +val Amber600 = Color(0xFFD97706) +val Amber100 = Color(0xFFFEF3C7) + +// Error +val Red600 = Color(0xFFDC2626) +val Red50 = Color(0xFFFEF2F2) + +// Light scheme colors +val LightPrimary = Teal600 +val LightOnPrimary = Color.White +val LightPrimaryContainer = Teal100 +val LightOnPrimaryContainer = Teal800 + +val LightSecondary = Slate600 +val LightOnSecondary = Color.White +val LightSecondaryContainer = Slate100 +val LightOnSecondaryContainer = Slate700 + +val LightTertiary = Amber600 +val LightOnTertiary = Color.White +val LightTertiaryContainer = Amber100 +val LightOnTertiaryContainer = Color(0xFF78350F) + +val LightError = Red600 +val LightOnError = Color.White +val LightErrorContainer = Red50 +val LightOnErrorContainer = Color(0xFF991B1B) + +val LightBackground = Color(0xFFFAFAFA) +val LightOnBackground = Color(0xFF1C1B1F) +val LightSurface = Color.White +val LightOnSurface = Color(0xFF1C1B1F) +val LightSurfaceVariant = Slate50 +val LightOnSurfaceVariant = Slate600 +val LightOutline = Color(0xFFCBD5E1) diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Theme.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Theme.kt new file mode 100644 index 0000000..322d756 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Theme.kt @@ -0,0 +1,54 @@ +package pl.firmatpp.itstransport.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + secondary = LightSecondary, + onSecondary = LightOnSecondary, + secondaryContainer = LightSecondaryContainer, + onSecondaryContainer = LightOnSecondaryContainer, + tertiary = LightTertiary, + onTertiary = LightOnTertiary, + tertiaryContainer = LightTertiaryContainer, + onTertiaryContainer = LightOnTertiaryContainer, + error = LightError, + onError = LightOnError, + errorContainer = LightErrorContainer, + onErrorContainer = LightOnErrorContainer, + background = LightBackground, + onBackground = LightOnBackground, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + outline = LightOutline, +) + +@Composable +fun ITSTransportTheme( + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + dynamicLightColorScheme(LocalContext.current) + } + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Type.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Type.kt new file mode 100644 index 0000000..2dffece --- /dev/null +++ b/app/src/main/java/pl/firmatpp/itstransport/ui/theme/Type.kt @@ -0,0 +1,73 @@ +package pl.firmatpp.itstransport.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b7fe192 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + ITS Transport + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e1f8e2b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.google.services) apply false +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..eba452b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,71 @@ +[versions] +agp = "8.7.3" +kotlin = "2.1.10" +ksp = "2.1.10-1.0.29" +composeBom = "2026.03.00" +material3 = "1.3.1" +activityCompose = "1.9.3" +navigationCompose = "2.8.5" +hilt = "2.56.2" +hiltNavigationCompose = "1.2.0" +retrofit = "2.11.0" +okhttp = "4.12.0" +securityCrypto = "1.1.0-alpha06" +lifecycleViewmodelCompose = "2.8.7" +coreKtx = "1.15.0" +firebaseBom = "33.7.0" +googleServices = "4.4.2" + +[libraries] +# Compose BOM +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } + +# Compose UI +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } + +# Material3 +compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } + +# Activity Compose +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +# Navigation Compose +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +# Retrofit + OkHttp +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# Security (EncryptedSharedPreferences) +security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } + +# Lifecycle +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" } + +# Core KTX +core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } + +# Firebase +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..dc6cbc4 --- /dev/null +++ b/gradlew @@ -0,0 +1,143 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced functionality shells and target +# temporary micro-containers:// +# +# Implementations may assume +# /usr/bin/env +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is:// + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is:// + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks:// +# temporary micro containers use:/ +# them properly following the shell quoting and substitution rules. +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a26d8ff --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %OS%==Windows_NT endlocal + +:omega + +@exit /b %ERRORLEVEL% + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..31fc4e3 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ITSTransport" +include(":app")