first commit

This commit is contained in:
admin
2026-06-30 19:13:44 +02:00
commit f38123a1ae
30 changed files with 1427 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
implementation(libs.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.camera.camera2)
implementation(libs.camera.core)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
implementation(libs.coil.compose)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
}
+1
View File
@@ -0,0 +1 @@
+30
View File
@@ -0,0 +1,30 @@
<?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.CAMERA" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,21 @@
package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel)
}
}
}
}
@@ -0,0 +1,65 @@
package pl.firmatpp.kierowca.data
import android.content.Context
import android.net.Uri
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
class DriverRepository(
private val context: Context,
private val api: MobileDriverApi = ApiFactory.mobileDriverApi(),
private val tokenStore: TokenStore = TokenStore(context),
) {
suspend fun requestOtp(phoneNumber: String): OtpResponse =
api.requestOtp(RequestOtpBody(phoneNumber))
suspend fun verifyOtp(phoneNumber: String, code: String, deviceName: String): DriverDto {
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName))
val token = response.accessToken ?: error("Brak tokenu sesji.")
tokenStore.save(token)
return response.driver ?: error("Brak danych kierowcy.")
}
suspend fun bootstrap(): BootstrapResponse =
api.bootstrap(authHeader(requireToken()))
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun uploadPhoto(routeId: String, uri: Uri, source: String): PhotoUploadResponse {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.")
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body)
val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull())
return api.uploadPhoto(authHeader(requireToken()), routeId, photo, sourceBody)
}
suspend fun logout() {
val token = tokenStore.read()
if (token != null) {
runCatching { api.logout(authHeader(token)) }
}
tokenStore.clear()
}
suspend fun hasToken(): Boolean = tokenStore.read() != null
suspend fun imageAuthHeader(): String? = tokenStore.read()?.let(::authHeader)
private suspend fun requireToken(): String = tokenStore.read() ?: error("Brak aktywnej sesji.")
private fun authHeader(token: String): String = "Bearer $token"
}
@@ -0,0 +1,28 @@
package pl.firmatpp.kierowca.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.driverDataStore by preferencesDataStore(name = "driver_session")
class TokenStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("access_token")
suspend fun save(token: String) {
context.driverDataStore.edit { preferences ->
preferences[tokenKey] = token
}
}
suspend fun read(): String? = context.driverDataStore.data
.map { it[tokenKey] }
.first()
suspend fun clear() {
context.driverDataStore.edit { it.remove(tokenKey) }
}
}
@@ -0,0 +1,26 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object ApiFactory {
fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.build()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MobileDriverApi::class.java)
}
}
@@ -0,0 +1,47 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.model.VerifyOtpResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
interface MobileDriverApi {
@POST("mobile/driver/auth/request-otp")
suspend fun requestOtp(@Body body: RequestOtpBody): OtpResponse
@POST("mobile/driver/auth/verify-otp")
suspend fun verifyOtp(@Body body: VerifyOtpBody): VerifyOtpResponse
@POST("mobile/driver/auth/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
@GET("mobile/driver/bootstrap")
suspend fun bootstrap(@Header("Authorization") authorization: String): BootstrapResponse
@GET("mobile/driver/routes/{routeId}")
suspend fun route(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
): RouteResponse
@Multipart
@POST("mobile/driver/routes/{routeId}/photos")
suspend fun uploadPhoto(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
@Part photo: MultipartBody.Part,
@Part("source") source: RequestBody,
): PhotoUploadResponse
}
@@ -0,0 +1,82 @@
package pl.firmatpp.kierowca.data.model
data class RequestOtpBody(val phoneNumber: String)
data class VerifyOtpBody(
val phoneNumber: String,
val code: String,
val deviceName: String,
)
data class OtpResponse(
val ok: Boolean,
val phoneMasked: String?,
val expiresInSeconds: Int?,
)
data class VerifyOtpResponse(
val ok: Boolean,
val accessToken: String?,
val expiresAt: String?,
val driver: DriverDto?,
)
data class DriverDto(
val id: String,
val displayName: String,
val anonymousLabel: String?,
)
data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
)
data class DriverSessionDto(
val driver: DriverDto,
val lastSyncAt: String,
)
data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(),
)
data class RouteResponse(
val route: DriverRouteDto,
)
data class DriverRouteDto(
val id: String,
val startsAt: String,
val originName: String,
val destinationName: String,
val contractorName: String,
val contractName: String?,
val contractCode: String?,
val relationLabel: String,
val status: String,
val distanceKm: Double?,
val notes: String?,
val truck: TruckDto?,
val photos: List<RoutePhotoDto> = emptyList(),
)
data class TruckDto(
val id: String,
val registration: String?,
val name: String?,
)
data class RoutePhotoDto(
val id: String,
val routeId: String,
val source: String,
val mimeType: String?,
val size: Long,
val url: String,
val createdAt: String?,
)
data class PhotoUploadResponse(
val photo: RoutePhotoDto,
)
@@ -0,0 +1,12 @@
package pl.firmatpp.kierowca.domain
object PhoneNumberFormatter {
fun normalize(input: String): String {
var digits = input.filter(Char::isDigit)
if (digits.startsWith("00")) digits = digits.drop(2)
if (digits.length == 9) digits = "48$digits"
return digits
}
fun mask(normalized: String): String = "+48 *** *** ${normalized.takeLast(3)}"
}
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.domain
import kotlin.math.roundToInt
import pl.firmatpp.kierowca.data.model.DriverRouteDto
data class RouteCardDisplay(
val id: String,
val origin: String,
val destination: String,
val contractLabel: String,
val time: String,
val status: String,
val distanceLabel: String,
)
object RouteDisplayMapper {
fun toCard(route: DriverRouteDto): RouteCardDisplay {
val contract = route.contractCode
?.takeIf { it.isNotBlank() }
?: route.contractName?.takeIf { it.isNotBlank() }
?: route.contractorName
val distance = route.distanceKm
?.takeIf { it > 0.0 }
?.roundToInt()
?.let { "$it km" }
?: ""
return RouteCardDisplay(
id = route.id,
origin = route.originName.ifBlank { "Start" },
destination = route.destinationName.ifBlank { "Cel" },
contractLabel = contract,
time = route.startsAt.ifBlank { "--:--" },
status = route.status,
distanceLabel = distance,
)
}
}
@@ -0,0 +1,374 @@
package pl.firmatpp.kierowca.ui
import android.content.Context
import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import coil.request.ImageRequest
import java.io.File
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.TppColors
@Composable
fun DriverApp(viewModel: DriverViewModel) {
val state by viewModel.state.collectAsState()
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
when (state.screen) {
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen(state, viewModel::refreshRoutes, viewModel::openRoute, viewModel::logout)
DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
}
if (state.loading) {
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.42f)), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = TppColors.Forest)
}
}
}
}
@Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phone by remember { mutableStateOf(state.phone) }
AuthShell(title = "TPP Kierowca", subtitle = "Zaloguj sie numerem telefonu kierowcy.") {
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Numer telefonu") },
leadingIcon = { Icon(Icons.Outlined.Phone, contentDescription = null) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
ErrorText(state.error)
PrimaryButton("Wyslij kod") { onSubmit(phone) }
}
}
@Composable
private fun OtpScreen(state: DriverUiState, onSubmit: (String) -> Unit, onBack: () -> Unit) {
var code by remember { mutableStateOf("") }
AuthShell(title = "Kod SMS", subtitle = "Wpisz kod wyslany na ${state.maskedPhone}.", onBack = onBack) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
label = { Text("Kod OTP") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
ErrorText(state.error)
PrimaryButton("Zaloguj") { onSubmit(code) }
}
}
@Composable
private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.Center,
) {
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") }
Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppColors.Forest)
Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp))
content()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoutesScreen(
state: DriverUiState,
onRefresh: () -> Unit,
onRoute: (String) -> Unit,
onLogout: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Dzisiejsze kursy") },
actions = {
IconButton(onClick = onRefresh) { Icon(Icons.Outlined.Refresh, contentDescription = "Odswiez") }
IconButton(onClick = onLogout) { Icon(Icons.Outlined.Logout, contentDescription = "Wyloguj") }
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = TppColors.Surface),
)
},
containerColor = TppColors.Surface,
) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Text("Kierowca: ${state.driver?.displayName.orEmpty()}", color = TppColors.Muted)
}
if (state.routes.isEmpty()) {
item { EmptyState("Brak kursow na dzisiaj") }
} else {
items(state.routes, key = { it.id }) { route -> RouteCard(route, onRoute) }
}
item { ErrorText(state.error) }
}
}
}
@Composable
private fun RouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
val display = RouteDisplayMapper.toCard(route)
Card(
modifier = Modifier.fillMaxWidth().clickable { onRoute(route.id) },
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
border = CardDefaults.outlinedCardBorder(),
) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.size(width = 5.dp, height = 132.dp).background(TppColors.Forest))
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(display.time, fontFamily = FontFamily.Monospace, color = TppColors.Navy, fontWeight = FontWeight.Bold)
Text("${display.origin} -> ${display.destination}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(display.contractLabel, fontFamily = FontFamily.Monospace, color = TppColors.Muted)
Text(listOf(display.status, display.distanceLabel).filter { it.isNotBlank() }.joinToString(" / "), color = TppColors.Muted)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DetailScreen(
state: DriverUiState,
onBack: () -> Unit,
onUpload: (Uri, String) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
) {
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri
if (ok && capturedUri != null) onUpload(capturedUri, "camera")
}
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery")
}
val route = state.selectedRoute
Scaffold(
topBar = {
TopAppBar(
title = { Text("Szczegoly kursu") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = TppColors.Surface),
)
},
containerColor = TppColors.Surface,
) { padding ->
if (route == null) return@Scaffold
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { ManifestSection(route) }
item {
Text("Zdjecia ladunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
PhotoGrid(route.photos, state.imageAuthHeader, onPhoto, onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
cameraLauncher.launch(newUri)
}, onGallery = {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
})
}
item { ErrorText(state.error) }
}
}
}
@Composable
private fun ManifestSection(route: DriverRouteDto) {
Card(colors = CardDefaults.cardColors(containerColor = Color.White), shape = RoundedCornerShape(8.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Outlined.LocalShipping, contentDescription = null, tint = TppColors.Forest)
Text(route.relationLabel, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Kontrakt: ${route.contractCode ?: route.contractName ?: route.contractorName}", fontFamily = FontFamily.Monospace)
Text("Status: ${route.status}", color = TppColors.Muted)
if (!route.notes.isNullOrBlank()) Text(route.notes, color = TppColors.Muted)
}
}
}
@Composable
private fun PhotoGrid(
photos: List<RoutePhotoDto>,
imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit,
onCamera: () -> Unit,
onGallery: () -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.height(360.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ActionTile("Aparat", Icons.Outlined.CameraAlt, onCamera) }
item { ActionTile("Galeria", Icons.Outlined.AddPhotoAlternate, onGallery) }
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = imageRequest(photo.url, imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(1f).clickable { onPhoto(photo) },
)
}
}
}
@Composable
private fun ActionTile(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
Card(
modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = TppColors.Panel),
shape = RoundedCornerShape(12.dp),
) {
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(icon, contentDescription = label, tint = TppColors.Forest)
Text(label, color = TppColors.Muted)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PhotoScreen(state: DriverUiState, onBack: () -> Unit) {
val photo = state.selectedPhoto
Scaffold(
topBar = {
TopAppBar(
title = { Text("Podglad zdjecia") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Black, titleContentColor = Color.White, navigationIconContentColor = Color.White),
)
},
containerColor = Color.Black,
) { padding ->
AsyncImage(
model = imageRequest(photo?.url, state.imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(padding),
)
}
}
@Composable
private fun PrimaryButton(label: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
) { Text(label, fontWeight = FontWeight.Bold) }
}
@Composable
private fun EmptyState(text: String) {
Card(colors = CardDefaults.cardColors(containerColor = Color.White), shape = RoundedCornerShape(8.dp)) {
Text(text, modifier = Modifier.padding(24.dp), color = TppColors.Muted)
}
}
@Composable
private fun ErrorText(error: String?) {
if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp))
}
private fun createCameraUri(context: Context): Uri {
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("ladunek-", ".jpg", dir)
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
@Composable
private fun imageRequest(url: String?, authorization: String?): ImageRequest {
val context = LocalContext.current
return ImageRequest.Builder(context)
.data(url)
.apply {
if (!authorization.isNullOrBlank()) addHeader("Authorization", authorization)
}
.crossfade(true)
.build()
}
@@ -0,0 +1,114 @@
package pl.firmatpp.kierowca.ui
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
enum class DriverScreen { Phone, Otp, Routes, Detail, Photo }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Phone,
val loading: Boolean = false,
val phone: String = "",
val maskedPhone: String = "",
val driver: DriverDto? = null,
val routes: List<DriverRouteDto> = emptyList(),
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val imageAuthHeader: String? = null,
val error: String? = null,
)
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DriverRepository(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
val state: StateFlow<DriverUiState> = _state
init {
viewModelScope.launch {
if (repository.hasToken()) refreshRoutes() else _state.update { it.copy(loading = false) }
}
}
fun requestOtp(phone: String) = runLoading {
val response = repository.requestOtp(phone)
_state.update {
it.copy(
screen = DriverScreen.Otp,
phone = phone,
maskedPhone = response.phoneMasked.orEmpty(),
error = null,
)
}
}
fun verifyOtp(code: String) = runLoading {
val driver = repository.verifyOtp(_state.value.phone, code, android.os.Build.MODEL ?: "Android")
_state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) }
refreshRoutes()
}
fun refreshRoutes() = runLoading {
val response = repository.bootstrap()
_state.update {
it.copy(
screen = DriverScreen.Routes,
driver = response.session.driver,
routes = response.routes.today,
imageAuthHeader = repository.imageAuthHeader(),
error = null,
)
}
}
fun openRoute(routeId: String) = runLoading {
val response = repository.route(routeId)
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) }
}
fun uploadPhoto(uri: Uri, source: String) = runLoading {
val route = _state.value.selectedRoute ?: return@runLoading
repository.uploadPhoto(route.id, uri, source)
openRoute(route.id)
}
fun openPhoto(photo: RoutePhotoDto) {
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
fun back() {
_state.update {
when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.Detail -> it.copy(screen = DriverScreen.Routes, selectedRoute = null)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
else -> it
}
}
}
fun logout() = runLoading {
repository.logout()
_state.update { DriverUiState(screen = DriverScreen.Phone) }
}
private fun runLoading(block: suspend () -> Unit) {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { block() }
.onFailure { throwable ->
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") }
}
_state.update { it.copy(loading = false) }
}
}
}
@@ -0,0 +1,42 @@
package pl.firmatpp.kierowca.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
object TppColors {
val Forest = Color(0xFF154212)
val ContainerGreen = Color(0xFF2D5A27)
val LogoLeaf = Color(0xFF609830)
val Navy = Color(0xFF1B365D)
val Surface = Color(0xFFF7FAF8)
val Panel = Color(0xFFEBEFED)
val Ink = Color(0xFF181C1B)
val Muted = Color(0xFF42493E)
val Outline = Color(0xFFC2C9BB)
val Error = Color(0xFFBA1A1A)
}
private val TppScheme = lightColorScheme(
primary = TppColors.Forest,
onPrimary = Color.White,
primaryContainer = TppColors.ContainerGreen,
secondary = TppColors.Navy,
background = TppColors.Surface,
surface = Color.White,
surfaceVariant = TppColors.Panel,
onBackground = TppColors.Ink,
onSurface = TppColors.Ink,
onSurfaceVariant = TppColors.Muted,
outline = TppColors.Outline,
error = TppColors.Error,
)
@Composable
fun TppKierowcaTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = TppScheme,
content = content,
)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TPP Kierowca</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TppKierowca" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#F7FAF8</item>
<item name="android:navigationBarColor">#F7FAF8</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="camera" path="camera/" />
</paths>
@@ -0,0 +1,18 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class PhoneNumberFormatterTest {
@Test
fun normalizesPolishDriverPhoneForBackend() {
assertEquals("48501222333", PhoneNumberFormatter.normalize("501 222 333"))
assertEquals("48501222333", PhoneNumberFormatter.normalize("+48 501-222-333"))
assertEquals("48501222333", PhoneNumberFormatter.normalize("0048 501 222 333"))
}
@Test
fun masksPhoneLikeBackendResponse() {
assertEquals("+48 *** *** 333", PhoneNumberFormatter.mask("48501222333"))
}
}
@@ -0,0 +1,34 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
class RouteDisplayMapperTest {
@Test
fun mapsRouteToDriverCardText() {
val route = DriverRouteDto(
id = "42",
startsAt = "09:15",
originName = "TPP Baza Krakow",
destinationName = "Instalacja Odpadowa Slask",
contractorName = "TPP Baza Krakow",
contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA",
distanceKm = 91.4,
notes = null,
truck = null,
photos = emptyList(),
)
val display = RouteDisplayMapper.toCard(route)
assertEquals("TPP Baza Krakow", display.origin)
assertEquals("Instalacja Odpadowa Slask", display.destination)
assertEquals("TPP-ODP-24", display.contractLabel)
assertEquals("09:15", display.time)
assertEquals("91 km", display.distanceLabel)
}
}