feat(M001): M001: ITS Transport Android App - Context
Completed slices: - S01: Project scaffold + Auth - S02: Today's routes tab - S03: Service history tab - S04: Add new service wizard - S05: FCM push notifications - S06: Polish & integration verification Branch: milestone/M001
This commit is contained in:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -26,3 +26,17 @@ vendor/
|
|||||||
coverage/
|
coverage/
|
||||||
.cache/
|
.cache/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# ── Android / Gradle ──
|
||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
.navigation/
|
||||||
|
captures/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
output.json
|
||||||
|
app/release/
|
||||||
|
app/debug/
|
||||||
|
|||||||
107
app/build.gradle.kts
Normal file
107
app/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
30
app/google-services.json
Normal file
30
app/google-services.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
39
app/proguard-rules.pro
vendored
Normal file
39
app/proguard-rules.pro
vendored
Normal file
@@ -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.* <methods>;
|
||||||
|
}
|
||||||
|
-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.**
|
||||||
33
app/src/main/AndroidManifest.xml
Normal file
33
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ITSTransportApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.fcm.FCMService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package pl.firmatpp.itstransport
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class ITSTransportApp : Application()
|
||||||
28
app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt
Normal file
28
app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>)
|
||||||
|
|
||||||
|
@HTTP(method = "DELETE", path = "device-tokens", hasBody = true)
|
||||||
|
suspend fun unregisterToken(@Body body: Map<String, String>)
|
||||||
|
}
|
||||||
@@ -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<SchedulerEventResponse>
|
||||||
|
|
||||||
|
@GET("scheduler/resources")
|
||||||
|
suspend fun getResources(): List<SchedulerDriverResponse>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Truck>
|
||||||
|
|
||||||
|
/** Returns a bare JSON array — no wrapper. */
|
||||||
|
@GET("trailers/all")
|
||||||
|
suspend fun getTrailers(): List<Trailer>
|
||||||
|
|
||||||
|
/** Returns wrapped { data: [...] }. */
|
||||||
|
@GET("maintenance/service-types")
|
||||||
|
suspend fun getServiceTypes(): ServiceTypesResponse
|
||||||
|
|
||||||
|
/** Creates a new service event. Returns 201 with { data: <event> }. */
|
||||||
|
@POST("service-events")
|
||||||
|
suspend fun createServiceEvent(
|
||||||
|
@Body request: CreateServiceEventRequest,
|
||||||
|
): CreateServiceEventResponse
|
||||||
|
}
|
||||||
@@ -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<UserResponse> {
|
||||||
|
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<Unit> {
|
||||||
|
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<UserResponse> {
|
||||||
|
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)
|
||||||
@@ -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<Boolean> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
val permissions: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiError(
|
||||||
|
val message: String,
|
||||||
|
val errors: Map<String, List<String>>? = null,
|
||||||
|
val status: Int? = null,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package pl.firmatpp.itstransport.data.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class ServiceEventsResponse(
|
||||||
|
val data: List<ServiceEvent>,
|
||||||
|
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<ServiceItem>,
|
||||||
|
@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,
|
||||||
|
)
|
||||||
@@ -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<ServiceType>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ServiceType(
|
||||||
|
val id: Long,
|
||||||
|
val name: String?,
|
||||||
|
@SerializedName("category") val category: ServiceCategory?,
|
||||||
|
@SerializedName("position_groups") val positionGroups: List<PositionGroup>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Position>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Position> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
val amount: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Submission — POST service-events
|
||||||
|
// Accepts CreateServiceEventRequest, returns 201
|
||||||
|
// with { data: <event> }
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
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<CreateServiceItemRequest>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Long>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateServiceEventResponse(
|
||||||
|
val data: ServiceEvent,
|
||||||
|
)
|
||||||
@@ -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<Unit> {
|
||||||
|
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<Unit> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<RouteDisplayItem>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> 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<ServiceEventsResponse> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> 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<List<Truck>> {
|
||||||
|
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<List<Trailer>> {
|
||||||
|
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<List<ServiceType>> {
|
||||||
|
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<ServiceEvent> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/src/main/java/pl/firmatpp/itstransport/ui/MainScreen.kt
Normal file
128
app/src/main/java/pl/firmatpp/itstransport/ui/MainScreen.kt
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String?>(null) }
|
||||||
|
var passwordError by rememberSaveable { mutableStateOf<String?>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(LoginUiState.Idle)
|
||||||
|
val uiState: StateFlow<LoginUiState> = _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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeScreen.kt
Normal file
164
app/src/main/java/pl/firmatpp/itstransport/ui/home/HomeScreen.kt
Normal file
@@ -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<pl.firmatpp.itstransport.data.model.RouteDisplayItem>) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RouteDisplayItem>) : 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>(HomeUiState.Idle)
|
||||||
|
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/src/main/java/pl/firmatpp/itstransport/ui/home/RouteCard.kt
Normal file
172
app/src/main/java/pl/firmatpp/itstransport/ui/home/RouteCard.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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', ' ')
|
||||||
|
}
|
||||||
@@ -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<pl.firmatpp.itstransport.data.model.ServiceEvent>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ServiceEvent>,
|
||||||
|
val hasMore: Boolean,
|
||||||
|
) : ServiceHistoryUiState()
|
||||||
|
data class Error(val message: String) : ServiceHistoryUiState()
|
||||||
|
data object Empty : ServiceHistoryUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Observable state ──
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<ServiceHistoryUiState>(ServiceHistoryUiState.Idle)
|
||||||
|
val uiState: StateFlow<ServiceHistoryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
/** Separate refreshing flag — never reuse Loading for pull-to-refresh (K007). */
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
|
private val _dateFrom = MutableStateFlow<String?>(null)
|
||||||
|
val dateFrom: StateFlow<String?> = _dateFrom.asStateFlow()
|
||||||
|
|
||||||
|
private val _dateTo = MutableStateFlow<String?>(null)
|
||||||
|
val dateTo: StateFlow<String?> = _dateTo.asStateFlow()
|
||||||
|
|
||||||
|
private val _sourceFilter = MutableStateFlow<String?>(null)
|
||||||
|
val sourceFilter: StateFlow<String?> = _sourceFilter.asStateFlow()
|
||||||
|
|
||||||
|
// ── Pagination tracking ──
|
||||||
|
|
||||||
|
private var currentPage: Int = 1
|
||||||
|
private var lastPage: Int = 1
|
||||||
|
private val allEvents: MutableList<ServiceEvent> = 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Truck>,
|
||||||
|
val trailers: List<Trailer>,
|
||||||
|
val serviceTypes: List<ServiceType>,
|
||||||
|
) : 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>(WizardUiState.Loading)
|
||||||
|
val uiState: StateFlow<WizardUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
// -- Wizard step navigation --
|
||||||
|
private val _currentStep = MutableStateFlow(WizardStep.VEHICLE)
|
||||||
|
val currentStep: StateFlow<WizardStep> = _currentStep.asStateFlow()
|
||||||
|
|
||||||
|
// -- Vehicle selection --
|
||||||
|
private val _vehicleTab = MutableStateFlow(VehicleTab.TRUCKS)
|
||||||
|
val vehicleTab: StateFlow<VehicleTab> = _vehicleTab.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedVehicleId = MutableStateFlow<Long?>(null)
|
||||||
|
val selectedVehicleId: StateFlow<Long?> = _selectedVehicleId.asStateFlow()
|
||||||
|
|
||||||
|
private val _vehicleSearchQuery = MutableStateFlow("")
|
||||||
|
val vehicleSearchQuery: StateFlow<String> = _vehicleSearchQuery.asStateFlow()
|
||||||
|
|
||||||
|
// -- Service selection --
|
||||||
|
private val _selectedServices = MutableStateFlow<List<SelectedService>>(emptyList())
|
||||||
|
val selectedServices: StateFlow<List<SelectedService>> = _selectedServices.asStateFlow()
|
||||||
|
|
||||||
|
private val _expandedCategoryId = MutableStateFlow<Long?>(null)
|
||||||
|
val expandedCategoryId: StateFlow<Long?> = _expandedCategoryId.asStateFlow()
|
||||||
|
|
||||||
|
// -- Summary fields --
|
||||||
|
private val _serviceDate = MutableStateFlow(
|
||||||
|
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||||
|
)
|
||||||
|
val serviceDate: StateFlow<String> = _serviceDate.asStateFlow()
|
||||||
|
|
||||||
|
private val _mileage = MutableStateFlow<Int?>(null)
|
||||||
|
val mileage: StateFlow<Int?> = _mileage.asStateFlow()
|
||||||
|
|
||||||
|
private val _generalNotes = MutableStateFlow<String?>(null)
|
||||||
|
val generalNotes: StateFlow<String?> = _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<Truck> = emptyList()
|
||||||
|
private var _trailers: List<Trailer> = emptyList()
|
||||||
|
private var _serviceTypes: List<ServiceType> = emptyList()
|
||||||
|
|
||||||
|
// Public accessors for reference data — cached across all UI states
|
||||||
|
val trucks: List<Truck> get() = _trucks
|
||||||
|
val trailers: List<Trailer> get() = _trailers
|
||||||
|
val serviceTypes: List<ServiceType> 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<ServiceType> {
|
||||||
|
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<Position>) {
|
||||||
|
_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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ServiceType>,
|
||||||
|
selectedServices: List<SelectedService>,
|
||||||
|
expandedCategoryId: Long?,
|
||||||
|
vehicleTab: AddServiceViewModel.VehicleTab,
|
||||||
|
onToggleService: (ServiceType) -> Unit,
|
||||||
|
onToggleCategory: (Long) -> Unit,
|
||||||
|
onUpdatePositions: (serviceTypeId: Long, positions: List<Position>) -> 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<ServiceType>,
|
||||||
|
selectedIds: Set<Long>,
|
||||||
|
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<Position>) -> 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<PositionGroup>,
|
||||||
|
selectedPositions: List<Position>,
|
||||||
|
onPositionsChanged: (List<Position>) -> 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<PositionGroup>,
|
||||||
|
vehicleTab: AddServiceViewModel.VehicleTab,
|
||||||
|
): List<PositionGroup> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Truck>,
|
||||||
|
trailers: List<Trailer>,
|
||||||
|
selectedServices: List<SelectedService>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Truck>,
|
||||||
|
trailers: List<Trailer>,
|
||||||
|
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<VehicleItem> = 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/src/main/java/pl/firmatpp/itstransport/ui/theme/Color.kt
Normal file
53
app/src/main/java/pl/firmatpp/itstransport/ui/theme/Color.kt
Normal file
@@ -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)
|
||||||
54
app/src/main/java/pl/firmatpp/itstransport/ui/theme/Theme.kt
Normal file
54
app/src/main/java/pl/firmatpp/itstransport/ui/theme/Theme.kt
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
73
app/src/main/java/pl/firmatpp/itstransport/ui/theme/Type.kt
Normal file
73
app/src/main/java/pl/firmatpp/itstransport/ui/theme/Type.kt
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">ITS Transport</string>
|
||||||
|
</resources>
|
||||||
9
build.gradle.kts
Normal file
9
build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
71
gradle/libs.versions.toml
Normal file
71
gradle/libs.versions.toml
Normal file
@@ -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" }
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
143
gradlew
vendored
Executable file
143
gradlew
vendored
Executable file
@@ -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" "$@"
|
||||||
93
gradlew.bat
vendored
Normal file
93
gradlew.bat
vendored
Normal file
@@ -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%
|
||||||
24
settings.gradle.kts
Normal file
24
settings.gradle.kts
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user