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:
admin
2026-03-25 13:45:52 +01:00
parent 8c75108ae8
commit 1c19456516
51 changed files with 5704 additions and 0 deletions

View 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>

View File

@@ -0,0 +1,7 @@
package pl.firmatpp.itstransport
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class ITSTransportApp : Application()

View 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)
}
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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>)
}

View File

@@ -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>
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View 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,
)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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."
}
}
}

View 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,
)
}
}

View File

@@ -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."
}
}
}

View 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)
}
}

View File

@@ -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()
},
)
}
}
}

View File

@@ -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",
),
}

View File

@@ -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', ' ')
}

View File

@@ -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,
)
}
}

View File

@@ -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."
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View 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)

View 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,
)
}

View 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,
),
)

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ITS Transport</string>
</resources>