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:
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>
|
||||
Reference in New Issue
Block a user