diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 7643783..64a4c97 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,6 +1,47 @@ + + + + @@ -118,6 +159,11 @@ \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bbfc0f3..d56d964 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material3.window.size) implementation(libs.androidx.material.icons) implementation(libs.okhttp) implementation(libs.okhttp.coroutines) @@ -107,6 +108,7 @@ dependencies { implementation(libs.work.runtime.ktx) implementation(libs.kotlinx.coroutines.guava) implementation(libs.androidx.browser) + implementation(libs.jsoup) ksp(libs.room.compiler) ksp(libs.moshi.kotlin.codegen) diff --git a/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt b/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt index 281adf4..5f0efee 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt @@ -22,14 +22,16 @@ import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,9 +40,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.content.ContextCompat import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import dev.itsvic.parceltracker.api.APIKeyMissingException @@ -55,19 +60,25 @@ import dev.itsvic.parceltracker.db.ParcelWithStatus import dev.itsvic.parceltracker.db.deleteParcel import dev.itsvic.parceltracker.db.demoModeParcels import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme +import dev.itsvic.parceltracker.ui.components.BottomNavBar +import dev.itsvic.parceltracker.ui.components.EditParcelDialog import dev.itsvic.parceltracker.ui.views.AddEditParcelView import dev.itsvic.parceltracker.ui.views.HomeView import dev.itsvic.parceltracker.ui.views.ParcelView import dev.itsvic.parceltracker.ui.views.SettingsView +import dev.itsvic.parceltracker.ui.views.TabletNavigationItem +import dev.itsvic.parceltracker.ui.views.TabletView import java.time.LocalDateTime import java.time.ZoneId import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import okio.IOException class MainActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -78,10 +89,11 @@ class MainActivity : ComponentActivity() { setContent { val parcelToOpen by parcelToOpen + val windowSizeClass = calculateWindowSizeClass(this) ParcelTrackerTheme { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { - ParcelAppNavigation(parcelToOpen) + ParcelAppNavigation(parcelToOpen, windowSizeClass) } } } @@ -99,20 +111,18 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun handleNotificationPermissionStuff() { val requestPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean - -> - if (isGranted) { - Log.d("MainActivity", "Notification permissions granted") - } else { - Log.d("MainActivity", "Notification permissions NOT granted") - } + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + Log.d("MainActivity", "Notification permissions granted") + } else { + Log.d("MainActivity", "Notification permissions NOT granted") } + } - // Notification checks when { ContextCompat.checkSelfPermission( - applicationContext, - Manifest.permission.POST_NOTIFICATIONS, + applicationContext, + Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED -> { // We can post notifications } @@ -135,7 +145,7 @@ class MainActivity : ComponentActivity() { @Serializable data class EditParcelPage(val parcelDbId: Int) @Composable -fun ParcelAppNavigation(parcelToOpen: Int) { +fun ParcelAppNavigation(parcelToOpen: Int, windowSizeClass: androidx.compose.material3.windowsizeclass.WindowSizeClass) { val db = ParcelApplication.db val navController = rememberNavController() val scope = rememberCoroutineScope() @@ -148,31 +158,229 @@ fun ParcelAppNavigation(parcelToOpen: Int) { } } - val animDuration = 300 - - NavHost( - navController = navController, - startDestination = HomePage, - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(animDuration), - initialOffset = { it / 4 }) + fadeIn(tween(animDuration)) - }, - exitTransition = { fadeOut(tween(animDuration)) + scaleOut(tween(500), 0.9f) }, - popEnterTransition = { fadeIn(tween(animDuration)) + scaleIn(tween(500), 0.9f) }, - popExitTransition = { - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(animDuration), - targetOffset = { -it / 4 }) + fadeOut(tween(animDuration)) - }, - ) { - composable { - val parcels = - if (demoMode) derivedStateOf { demoModeParcels } - else db.parcelDao().getAllWithStatus().collectAsState(initial = emptyList()) + val animDuration = 400 + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route ?: "HomePage" + + val isTablet = windowSizeClass.widthSizeClass >= androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Medium + + var selectedParcel by remember { mutableStateOf(null) } + var apiParcel by remember { mutableStateOf(null) } + var isLoadingParcel by remember { mutableStateOf(false) } + var currentTabletNavItem by remember { mutableStateOf(TabletNavigationItem.HOME) } + + val parcels = + if (demoMode) derivedStateOf { demoModeParcels } + else db.parcelDao().getAllWithStatus().collectAsState(initial = emptyList()) + + LaunchedEffect(selectedParcel) { + if (selectedParcel != null) { + isLoadingParcel = true + launch(Dispatchers.IO) { + try { + if (selectedParcel!!.isArchived) { + val localHistory = db.parcelHistoryDao().getAllById(selectedParcel!!.id).first() + if (localHistory.isNotEmpty()) { + apiParcel = APIParcel( + selectedParcel!!.parcelId, + localHistory.map { dev.itsvic.parceltracker.api.ParcelHistoryItem(it.description, it.time, it.location) }, + Status.Delivered + ) + } else { + apiParcel = context.getParcel(selectedParcel!!.parcelId, selectedParcel!!.postalCode, selectedParcel!!.service) + } + } else { + apiParcel = context.getParcel(selectedParcel!!.parcelId, selectedParcel!!.postalCode, selectedParcel!!.service) + + if (!demoMode) { + val zone = ZoneId.systemDefault() + val lastChange = apiParcel!!.history.first().time.atZone(zone).toInstant() + val status = ParcelStatus(selectedParcel!!.id, apiParcel!!.currentStatus, lastChange) + val existingStatus = db.parcelStatusDao().get(selectedParcel!!.id) + if (existingStatus == null) { + db.parcelStatusDao().insert(status) + } else { + db.parcelStatusDao().update(status) + } + } + } + } catch (e: Exception) { + Log.w("MainActivity", "Failed fetch: $e") + apiParcel = APIParcel( + selectedParcel!!.parcelId, + listOf(ParcelHistoryItem(context.getString(R.string.network_failure_detail), LocalDateTime.now(), "")), + Status.NetworkFailure + ) + } + isLoadingParcel = false + } + } else { + apiParcel = null + isLoadingParcel = false + } + } + + if (isTablet) { + TabletView( + parcels = parcels.value, + selectedParcel = selectedParcel, + apiParcel = apiParcel, + isLoading = isLoadingParcel, + currentNavigationItem = currentTabletNavItem, + onNavigateToItem = { currentTabletNavItem = it }, + onNavigateToParcel = { + selectedParcel = it + currentTabletNavItem = TabletNavigationItem.HOME + }, + onNavigateToAddParcel = { currentTabletNavItem = TabletNavigationItem.ADD_PARCEL }, + onNavigateToSettings = { currentTabletNavItem = TabletNavigationItem.SETTINGS }, + onEditParcel = { parcel -> + selectedParcel = parcel + currentTabletNavItem = TabletNavigationItem.EDIT_PARCEL + }, + onDeleteParcel = { parcel -> + if (demoMode) { + Toast.makeText(context, context.getString(R.string.demo_mode_action_block), Toast.LENGTH_SHORT).show() + return@TabletView + } + scope.launch(Dispatchers.IO) { + deleteParcel(parcel) + selectedParcel = null + } + }, + onArchiveParcel = { parcel -> + if (parcel.isArchived || demoMode) { + if (demoMode) { + Toast.makeText(context, context.getString(R.string.demo_mode_action_block), Toast.LENGTH_SHORT).show() + } + return@TabletView + } + scope.launch(Dispatchers.IO) { + val updatedParcel = parcel.copy(isArchived = true) + db.parcelDao().update(updatedParcel) + if (apiParcel != null) { + db.parcelHistoryDao().insert( + apiParcel!!.history.map { + dev.itsvic.parceltracker.db.ParcelHistoryItem( + description = it.description, + location = it.location, + time = it.time, + parcelId = parcel.id + ) + } + ) + } + selectedParcel = updatedParcel + } + }, + onArchivePromptDismissal = { parcel -> + if (demoMode) { + Toast.makeText(context, context.getString(R.string.demo_mode_action_block), Toast.LENGTH_SHORT).show() + return@TabletView + } + scope.launch(Dispatchers.IO) { + val updatedParcel = parcel.copy(archivePromptDismissed = true) + db.parcelDao().update(updatedParcel) + selectedParcel = updatedParcel + } + }, + settingsContent = { + SettingsView() + }, + addParcelContent = { + AddEditParcelView( + null, + onBackPressed = { currentTabletNavItem = TabletNavigationItem.HOME }, + onCompleted = { parcel -> + if (demoMode) { + Toast.makeText(context, context.getString(R.string.demo_mode_action_block), Toast.LENGTH_SHORT).show() + return@AddEditParcelView + } + scope.launch(Dispatchers.IO) { + val id = db.parcelDao().insert(parcel) + currentTabletNavItem = TabletNavigationItem.HOME + selectedParcel = parcel.copy(id = id.toInt()) + } + } + ) + }, + editParcelContent = { + selectedParcel?.let { parcel -> + AddEditParcelView( + parcel, + onBackPressed = { currentTabletNavItem = TabletNavigationItem.HOME }, + onCompleted = { updatedParcel -> + if (demoMode) { + Toast.makeText(context, context.getString(R.string.demo_mode_action_block), Toast.LENGTH_SHORT).show() + return@AddEditParcelView + } + scope.launch(Dispatchers.IO) { + db.parcelDao().update(updatedParcel) + currentTabletNavItem = TabletNavigationItem.HOME + selectedParcel = updatedParcel + } + } + ) + } + } + ) + return + } + Scaffold( + bottomBar = { + if (currentRoute.contains("HomePage") || currentRoute.contains("SettingsPage") || currentRoute.contains("AddParcelPage")) { + BottomNavBar( + currentRoute = currentRoute, + onNavigateToHome = { + navController.navigate(route = HomePage) { + popUpTo(HomePage) { inclusive = true } + } + }, + onNavigateToAddParcel = { + navController.navigate(route = AddParcelPage) + }, + onNavigateToSettings = { + navController.navigate(route = SettingsPage) + } + ) + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = HomePage, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(animDuration, easing = androidx.compose.animation.core.FastOutSlowInEasing), + initialOffset = { it / 3 }) + fadeIn(tween(animDuration / 2, delayMillis = animDuration / 4)) + }, + exitTransition = { + fadeOut(tween(animDuration / 2)) + + scaleOut(tween(animDuration, easing = androidx.compose.animation.core.FastOutSlowInEasing), 0.95f) + + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(animDuration, easing = androidx.compose.animation.core.FastOutSlowInEasing), + targetOffset = { -it / 6 }) + }, + popEnterTransition = { + fadeIn(tween(animDuration / 2, delayMillis = animDuration / 4)) + + scaleIn(tween(animDuration, easing = androidx.compose.animation.core.FastOutSlowInEasing), 0.95f) + + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(animDuration, easing = androidx.compose.animation.core.FastOutSlowInEasing), + initialOffset = { -it / 6 }) + }, + popExitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(animDuration, easing = androidx.compose.animation.core.FastOutSlowInEasing), + targetOffset = { it / 3 }) + fadeOut(tween(animDuration / 2)) + }, + modifier = Modifier.padding(innerPadding) + ) { + composable { HomeView( parcels = parcels.value, onNavigateToAddParcel = { navController.navigate(route = AddParcelPage) }, @@ -181,7 +389,7 @@ fun ParcelAppNavigation(parcelToOpen: Int) { ) } - composable { SettingsView(onBackPressed = { navController.popBackStack() }) } + composable { SettingsView() } composable { backStackEntry -> val route: ParcelPage = backStackEntry.toRoute() @@ -202,7 +410,6 @@ fun ParcelAppNavigation(parcelToOpen: Int) { context.getParcel(dbParcel.parcelId, dbParcel.postalCode, dbParcel.service) if (!demoMode) { - // update parcel status val zone = ZoneId.systemDefault() val lastChange = apiParcel!!.history.first().time.atZone(zone).toInstant() val status = @@ -373,9 +580,9 @@ fun ParcelAppNavigation(parcelToOpen: Int) { CircularProgressIndicator() } - AddEditParcelView( - parcel, - onBackPressed = { navController.popBackStack() }, + EditParcelDialog( + parcel = parcel!!, + onDismissRequest = { navController.popBackStack() }, onCompleted = { if (demoMode) { Toast.makeText( @@ -383,7 +590,7 @@ fun ParcelAppNavigation(parcelToOpen: Int) { context.getString(R.string.demo_mode_action_block), Toast.LENGTH_SHORT) .show() - return@AddEditParcelView + return@EditParcelDialog } scope.launch(Dispatchers.IO) { @@ -394,4 +601,5 @@ fun ParcelAppNavigation(parcelToOpen: Int) { ) } } + } } diff --git a/app/src/main/java/dev/itsvic/parceltracker/Settings.kt b/app/src/main/java/dev/itsvic/parceltracker/Settings.kt index 1de2f70..0f8a308 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/Settings.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/Settings.kt @@ -11,6 +11,8 @@ import androidx.datastore.preferences.preferencesDataStore val Context.dataStore: DataStore by preferencesDataStore(name = "settings") val DEMO_MODE = booleanPreferencesKey("demoMode") val UNMETERED_ONLY = booleanPreferencesKey("unmeteredOnly") +val CLIPBOARD_PASTE_ENABLED = booleanPreferencesKey("clipboardPasteEnabled") +val PREFERRED_REGION = stringPreferencesKey("preferredRegion") // API key settings val DHL_API_KEY = stringPreferencesKey("dhlApiKey") diff --git a/app/src/main/java/dev/itsvic/parceltracker/api/Core.kt b/app/src/main/java/dev/itsvic/parceltracker/api/Core.kt index 2e1edee..4645c36 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/api/Core.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/api/Core.kt @@ -36,6 +36,7 @@ enum class Service { AN_POST, BELPOST, DPD_GER, + EXPRESS_ONE, GLS_HUNGARY, HERMES, MAGYAR_POSTA, @@ -55,6 +56,7 @@ enum class Service { // Asia EKART, SPX_TH, + IMILE, } val serviceOptions = @@ -80,6 +82,7 @@ fun getDeliveryService(service: Service): DeliveryService? { Service.AN_POST -> AnPostDeliveryService Service.BELPOST -> BelpostDeliveryService Service.DPD_GER -> DpdGerDeliveryService + Service.EXPRESS_ONE -> ExpressOneDeliveryService Service.GLS_HUNGARY -> GLSHungaryDeliveryService Service.HERMES -> HermesDeliveryService Service.MAGYAR_POSTA -> MagyarPostaDeliveryService @@ -98,6 +101,7 @@ fun getDeliveryService(service: Service): DeliveryService? { Service.EKART -> EKartDeliveryService Service.SPX_TH -> SPXThailandDeliveryService + Service.IMILE -> IMileDeliveryService Service.EXAMPLE -> ExampleDeliveryService else -> null diff --git a/app/src/main/java/dev/itsvic/parceltracker/api/ExpressOneDeliveryService.kt b/app/src/main/java/dev/itsvic/parceltracker/api/ExpressOneDeliveryService.kt new file mode 100644 index 0000000..a8facf0 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/api/ExpressOneDeliveryService.kt @@ -0,0 +1,119 @@ +package dev.itsvic.parceltracker.api + +import dev.itsvic.parceltracker.R +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import okhttp3.Request + +object ExpressOneDeliveryService : DeliveryService { + override val nameResource: Int = R.string.service_express_one + override val acceptsPostCode: Boolean = false + override val requiresPostCode: Boolean = false + + override suspend fun getParcel(trackingId: String, postalCode: String?): Parcel { + val url = "https://tracking.expressone.hu/?plc_number=${trackingId}&sender_id=" + + val request = Request.Builder().url(url).build() + + val response = api_client.newCall(request).execute() + + if (!response.isSuccessful) { + throw ParcelNonExistentException() + } + + val html = response.body?.string() ?: throw ParcelNonExistentException() + + if (html.contains("Nincs találat") || html.contains("No results") || html.contains("error")) { + throw ParcelNonExistentException() + } + + var currentStatus = Status.Unknown + val htmlLower = html.lowercase(Locale.getDefault()) + + when { + htmlLower.contains("kézbesítve") || htmlLower.contains("delivered") -> { + currentStatus = Status.Delivered + } + htmlLower.contains("kiszállítás") || + htmlLower.contains("out for delivery") || + htmlLower.contains("kiadva futárnak") -> { + currentStatus = Status.OutForDelivery + } + htmlLower.contains("depóba érkezett") || htmlLower.contains("arrived at depot") -> { + currentStatus = Status.InWarehouse + } + htmlLower.contains("feldolgozás") || + htmlLower.contains("processing") || + htmlLower.contains("átszállítás") -> { + currentStatus = Status.InTransit + } + htmlLower.contains("átvétel") || htmlLower.contains("pickup") -> { + currentStatus = Status.AwaitingPickup + } + else -> { + currentStatus = Status.InTransit + } + } + + val history = mutableListOf() + + val datePattern = "
(\\d{4}-\\d{2}-\\d{2})
".toRegex() + val rowPattern = + "]*>(\\d{2}:\\d{2}:\\d{2})\\s*]*>([^<]*)\\s*]*>([^<]*)" + .toRegex() + + val dateMatches = datePattern.findAll(html) + + for (dateMatch in dateMatches) { + val dateText = dateMatch.groupValues[1] + + val startIndex = dateMatch.range.last + val nextDateMatch = datePattern.find(html, startIndex + 1) + val endIndex = nextDateMatch?.range?.first ?: html.length + + val tableSection = html.substring(startIndex, endIndex) + val rowMatches = rowPattern.findAll(tableSection) + + for (rowMatch in rowMatches) { + val timeText = rowMatch.groupValues[1] + val codeText = rowMatch.groupValues[2].trim() + val descriptionText = rowMatch.groupValues[3].trim() + + if (timeText.isNotEmpty() && descriptionText.isNotEmpty()) { + try { + val dateTimeString = "$dateText $timeText" + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val dateTime = LocalDateTime.parse(dateTimeString, formatter) + + var cleanDescription = descriptionText.replace(" ", " ").trim() + val locationMatch = "\\(([^)]+)\\)".toRegex().find(cleanDescription) + val location = locationMatch?.groupValues?.get(1) ?: "" + + if (location.isNotEmpty()) { + cleanDescription = cleanDescription.replace("($location)", "").trim() + } + + history.add(ParcelHistoryItem(cleanDescription, dateTime, location)) + } catch (e: Exception) { + continue + } + } + } + } + + history.sortByDescending { it.time } + + if (history.isEmpty()) { + history.add( + ParcelHistoryItem( + "Csomag nyomon követése elindítva", + LocalDateTime.now(), + "Express One Hungary", + ) + ) + } + + return Parcel(trackingId, history, currentStatus) + } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/api/IMileDeliveryService.kt b/app/src/main/java/dev/itsvic/parceltracker/api/IMileDeliveryService.kt new file mode 100644 index 0000000..4feb4c3 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/api/IMileDeliveryService.kt @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.api + +import com.squareup.moshi.JsonClass +import dev.itsvic.parceltracker.R +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import retrofit2.Retrofit +import retrofit2.http.GET +import retrofit2.http.Query + +object IMileDeliveryService : DeliveryService { + override val nameResource: Int = R.string.service_imile + override val acceptsPostCode: Boolean = false + override val requiresPostCode: Boolean = false + + private const val BASE_URL = "https://www.imile.com/" + + private val retrofit = + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(api_client) + .addConverterFactory(api_factory) + .build() + + private val service = retrofit.create(API::class.java) + + override suspend fun getParcel(trackingId: String, postCode: String?): Parcel { + val response = service.getParcel(trackingId) + + if (response.status != "success" || response.resultObject == null) { + throw ParcelNonExistentException() + } + + val resultObject = response.resultObject + val trackInfos = resultObject.trackInfos + + if (trackInfos.isEmpty()) { + throw ParcelNonExistentException() + } + + val history = trackInfos.map { trackInfo -> + ParcelHistoryItem( + description = trackInfo.content, + time = LocalDateTime.parse(trackInfo.time, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + location = trackInfo.operateStationName ?: "" + ) + } + + val currentStatus = mapTrackStageToStatus(trackInfos.firstOrNull()?.trackStage ?: 0) + + return Parcel( + id = trackingId, + history = history, + currentStatus = currentStatus + ) + } + + private fun mapTrackStageToStatus(trackStage: Int): Status { + return when (trackStage) { + 1001 -> Status.Preadvice + 2004 -> Status.InWarehouse + 1002 -> Status.InTransit + 1003 -> Status.OutForDelivery + 2060 -> Status.Delivered + else -> logUnknownStatus("iMile", trackStage.toString()) + } + } + + private interface API { + @GET("saastms/mobileWeb/track/query") + suspend fun getParcel(@Query("waybillNo") waybillNo: String): IMileResponse + } + + @JsonClass(generateAdapter = true) + data class IMileResponse( + val status: String, + val resultCode: String, + val resultObject: IMileResultObject?, + val message: String + ) + + @JsonClass(generateAdapter = true) + data class IMileResultObject( + val waybillNo: String, + val sendSite: String?, + val dispatchStation: String?, + val country: String?, + val trackInfos: List + ) + + @JsonClass(generateAdapter = true) + data class IMileTrackInfo( + val content: String, + val trackStage: Int, + val trackStageTx: String, + val time: String, + val operateStationName: String?, + val proofs: Any? + ) +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/AboutDialog.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/AboutDialog.kt index deb0d9b..5e65b7f 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/components/AboutDialog.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/AboutDialog.kt @@ -4,11 +4,14 @@ package dev.itsvic.parceltracker.ui.components import android.content.Context import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent +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.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -16,6 +19,9 @@ import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -37,19 +43,32 @@ fun AboutDialog(onDismissRequest: () -> Unit) { Dialog(onDismissRequest = onDismissRequest) { Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) { Column(modifier = Modifier.padding(24.dp)) { - Text( - text = "Parcel", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - Text( - text = BuildConfig.VERSION_NAME, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.icon_foreground), + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = Color.Unspecified + ) + Spacer(Modifier.width(16.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Parcel", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Spacer(Modifier.height(24.dp)) diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/BottomNavBar.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/BottomNavBar.kt new file mode 100644 index 0000000..4babfac --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/BottomNavBar.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dev.itsvic.parceltracker.R + +@Composable +fun BottomNavBar( + currentRoute: String, + onNavigateToHome: () -> Unit, + onNavigateToAddParcel: () -> Unit, + onNavigateToSettings: () -> Unit, + onBackPressed: (() -> Unit)? = null, + showBackButton: Boolean = false, +) { + NavigationBar { + if (showBackButton && onBackPressed != null) { + NavigationBarItem( + icon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.go_back)) }, + label = { Text(stringResource(R.string.go_back)) }, + selected = false, + onClick = onBackPressed, + ) + } + NavigationBarItem( + icon = { Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.home)) }, + label = { Text(stringResource(R.string.home)) }, + selected = currentRoute.contains("HomePage"), + onClick = onNavigateToHome, + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add)) }, + label = { Text(stringResource(R.string.add)) }, + selected = currentRoute.contains("AddParcelPage"), + onClick = onNavigateToAddParcel, + ) + NavigationBarItem( + icon = { + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings)) + }, + label = { Text(stringResource(R.string.settings)) }, + selected = currentRoute.contains("SettingsPage"), + onClick = onNavigateToSettings, + ) + } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/EditParcelDialog.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/EditParcelDialog.kt new file mode 100644 index 0000000..050b68b --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/EditParcelDialog.kt @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.components + +import androidx.compose.foundation.layout.Column +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.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import dev.itsvic.parceltracker.R +import dev.itsvic.parceltracker.db.Parcel +import dev.itsvic.parceltracker.ui.views.AddEditParcelContent + +@Composable +fun EditParcelDialog( + parcel: Parcel, + onDismissRequest: () -> Unit, + onCompleted: (Parcel) -> Unit +) { + Dialog(onDismissRequest = onDismissRequest) { + Column( + modifier = Modifier + .fillMaxWidth() + .sizeIn(maxHeight = 700.dp, maxWidth = 500.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.edit_parcel), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AddEditParcelContent( + parcel = parcel, + onCompleted = onCompleted, + isDialog = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/FloatingCollapsibleActionBar.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/FloatingCollapsibleActionBar.kt new file mode 100644 index 0000000..41eccd4 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/FloatingCollapsibleActionBar.kt @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.itsvic.parceltracker.R +import dev.itsvic.parceltracker.api.Status + +@Composable +fun FloatingCollapsibleActionBar( + status: Status?, + onEdit: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + animationSpec = tween(300), + label = "rotation" + ) + + Box( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.BottomCenter + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.actions), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = if (isExpanded) stringResource(R.string.collapse) else stringResource(R.string.expand), + modifier = Modifier.rotate(rotationAngle), + tint = MaterialTheme.colorScheme.onSurface + ) + } + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically( + animationSpec = tween(300) + ) + fadeIn(animationSpec = tween(300)), + exit = shrinkVertically( + animationSpec = tween(300) + ) + fadeOut(animationSpec = tween(300)) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + + ActionButton( + icon = { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }, + text = stringResource(R.string.edit), + onClick = onEdit + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (status == Status.Delivered) { + ActionButton( + icon = { + Icon( + painterResource(R.drawable.archive), + contentDescription = stringResource(R.string.archive) + ) + }, + text = stringResource(R.string.archive), + onClick = onArchive + ) + Spacer(modifier = Modifier.height(8.dp)) + } + ActionButton( + icon = { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }, + text = stringResource(R.string.delete), + onClick = { showDeleteDialog = true }, + isDestructive = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.delete)) }, + text = { Text(stringResource(R.string.delete_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + onDelete() + } + ) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +fun ActionButton( + icon: @Composable () -> Unit, + text: String, + onClick: () -> Unit, + isDestructive: Boolean = false, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onClick() } + .background( + if (isDestructive) + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.1f) + else + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = if (isDestructive) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.onSurface + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelActionBar.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelActionBar.kt new file mode 100644 index 0000000..90f7ce7 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelActionBar.kt @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.painterResource +import androidx.compose.ui.res.stringResource +import dev.itsvic.parceltracker.R +import dev.itsvic.parceltracker.api.Status + +@Composable +fun ParcelActionBar( + status: Status?, + onEdit: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, + onBackPressed: (() -> Unit)? = null, +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + NavigationBar { + if (onBackPressed != null) { + NavigationBarItem( + icon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.go_back)) }, + label = { Text(stringResource(R.string.go_back)) }, + selected = false, + onClick = onBackPressed, + ) + } + + NavigationBarItem( + icon = { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }, + label = { Text(stringResource(R.string.edit)) }, + selected = false, + onClick = onEdit, + ) + + if (status == Status.Delivered) { + NavigationBarItem( + icon = { + Icon( + painterResource(R.drawable.archive), + contentDescription = stringResource(R.string.archive), + ) + }, + label = { Text(stringResource(R.string.archive)) }, + selected = false, + onClick = onArchive, + ) + } + + NavigationBarItem( + icon = { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }, + label = { Text(stringResource(R.string.delete)) }, + selected = false, + onClick = { showDeleteDialog = true }, + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.delete)) }, + text = { Text(stringResource(R.string.delete_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + onDelete() + } + ) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelCard.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelCard.kt new file mode 100644 index 0000000..5278e91 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelCard.kt @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.components + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.itsvic.parceltracker.R +import dev.itsvic.parceltracker.api.Service +import dev.itsvic.parceltracker.api.Status +import dev.itsvic.parceltracker.api.getDeliveryServiceName +import dev.itsvic.parceltracker.db.Parcel +import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme + +@Composable +fun ParcelCard(parcel: Parcel, status: Status?, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (status != null) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource( + when (status) { + Status.Preadvice -> R.drawable.outline_other_admission_24 + Status.LockerboxAcceptedParcel -> R.drawable.outline_deployed_code_update_24 + Status.PickedUpByCourier -> R.drawable.outline_deployed_code_account_24 + Status.InTransit -> R.drawable.outline_local_shipping_24 + Status.InWarehouse -> R.drawable.outline_warehouse_24 + Status.Customs -> R.drawable.outline_search_24 + Status.OutForDelivery -> R.drawable.outline_delivery_truck_speed_24 + Status.DeliveryFailure -> R.drawable.outline_error_24 + Status.PickupTimeEndingSoon -> R.drawable.outline_notifications_active_24 + Status.AwaitingPickup -> R.drawable.outline_pin_drop_24 + Status.Delivered, + Status.PickedUp -> R.drawable.outline_check_24 + Status.DeliveredToNeighbor -> R.drawable.outline_holiday_village_24 + Status.DeliveredToASafePlace -> R.drawable.outline_roofing_24 + Status.DroppedAtCustomerService -> R.drawable.outline_support_agent_24 + Status.ReturningToSender -> R.drawable.outline_arrow_top_left_24 + Status.ReturnedToSender -> R.drawable.outline_arrow_top_left_24 + Status.Delayed -> R.drawable.outline_deployed_code_history_24 + Status.Damaged -> R.drawable.outline_deployed_code_alert_24 + Status.Destroyed -> R.drawable.outline_destruction_24 + else -> R.drawable.outline_question_mark_24 + } + ), + contentDescription = stringResource(status.nameResource), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = parcel.humanName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = "${stringResource(getDeliveryServiceName(parcel.service)!!)}: ${parcel.parcelId}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (status != null) { + Text( + text = stringResource(status.nameResource), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +@PreviewLightDark +fun ParcelCardPreview() { + ParcelTrackerTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + ParcelCard( + Parcel(0, "My precious package", "EXMPL0001", null, Service.EXAMPLE), + status = Status.InTransit, + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelRow.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelRow.kt index 45c3289..16336c7 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelRow.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/components/ParcelRow.kt @@ -31,58 +31,68 @@ import dev.itsvic.parceltracker.db.Parcel import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme @Composable -fun ParcelRow(parcel: Parcel, status: Status?, onClick: () -> Unit) { +fun ParcelRow(parcel: Parcel, status: Status?, isSelected: Boolean = false, onClick: () -> Unit) { Row( - modifier = Modifier.clickable(onClick = onClick).fillMaxWidth().padding(16.dp, 12.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically) { - if (status != null) - Box( - modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center) { - Icon( - painterResource( - when (status) { - Status.Preadvice -> R.drawable.outline_other_admission_24 - Status.LockerboxAcceptedParcel -> - R.drawable.outline_deployed_code_update_24 - Status.PickedUpByCourier -> R.drawable.outline_deployed_code_account_24 - Status.InTransit -> R.drawable.outline_local_shipping_24 - Status.InWarehouse -> R.drawable.outline_warehouse_24 - Status.Customs -> R.drawable.outline_search_24 - Status.OutForDelivery -> R.drawable.outline_delivery_truck_speed_24 - Status.DeliveryFailure -> R.drawable.outline_error_24 - Status.PickupTimeEndingSoon -> - R.drawable.outline_notifications_active_24 - Status.AwaitingPickup -> R.drawable.outline_pin_drop_24 - Status.Delivered, - Status.PickedUp -> R.drawable.outline_check_24 - Status.DeliveredToNeighbor -> R.drawable.outline_holiday_village_24 - Status.DeliveredToASafePlace -> R.drawable.outline_roofing_24 - Status.DroppedAtCustomerService -> R.drawable.outline_support_agent_24 - Status.ReturningToSender -> R.drawable.outline_arrow_top_left_24 - Status.ReturnedToSender -> R.drawable.outline_arrow_top_left_24 - Status.Delayed -> R.drawable.outline_deployed_code_history_24 - Status.Damaged -> R.drawable.outline_deployed_code_alert_24 - Status.Destroyed -> R.drawable.outline_destruction_24 - else -> R.drawable.outline_question_mark_24 - }), - stringResource(status.nameResource), - tint = MaterialTheme.colorScheme.primary) - } + modifier = + Modifier.clickable(onClick = onClick) + .fillMaxWidth() + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.surface + ) + .padding(16.dp, 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (status != null) + Box( + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource( + when (status) { + Status.Preadvice -> R.drawable.outline_other_admission_24 + Status.LockerboxAcceptedParcel -> R.drawable.outline_deployed_code_update_24 + Status.PickedUpByCourier -> R.drawable.outline_deployed_code_account_24 + Status.InTransit -> R.drawable.outline_local_shipping_24 + Status.InWarehouse -> R.drawable.outline_warehouse_24 + Status.Customs -> R.drawable.outline_search_24 + Status.OutForDelivery -> R.drawable.outline_delivery_truck_speed_24 + Status.DeliveryFailure -> R.drawable.outline_error_24 + Status.PickupTimeEndingSoon -> R.drawable.outline_notifications_active_24 + Status.AwaitingPickup -> R.drawable.outline_pin_drop_24 + Status.Delivered, + Status.PickedUp -> R.drawable.outline_check_24 + Status.DeliveredToNeighbor -> R.drawable.outline_holiday_village_24 + Status.DeliveredToASafePlace -> R.drawable.outline_roofing_24 + Status.DroppedAtCustomerService -> R.drawable.outline_support_agent_24 + Status.ReturningToSender -> R.drawable.outline_arrow_top_left_24 + Status.ReturnedToSender -> R.drawable.outline_arrow_top_left_24 + Status.Delayed -> R.drawable.outline_deployed_code_history_24 + Status.Damaged -> R.drawable.outline_deployed_code_alert_24 + Status.Destroyed -> R.drawable.outline_destruction_24 + else -> R.drawable.outline_question_mark_24 + } + ), + stringResource(status.nameResource), + tint = MaterialTheme.colorScheme.primary, + ) + } - Column { - Text(parcel.humanName, color = MaterialTheme.colorScheme.onBackground) + Column { + Text(parcel.humanName, color = MaterialTheme.colorScheme.onBackground) - Text( - "${stringResource(getDeliveryServiceName(parcel.service)!!)}: ${parcel.parcelId}", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } + Text( + "${stringResource(getDeliveryServiceName(parcel.service)!!)}: ${parcel.parcelId}", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } @Composable @@ -91,9 +101,9 @@ fun ParcelRowPreview() { ParcelTrackerTheme { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { ParcelRow( - Parcel(0, "My precious package", "EXMPL0001", null, Service.EXAMPLE), - status = Status.InTransit, - onClick = {}, + Parcel(0, "My precious package", "EXMPL0001", null, Service.EXAMPLE), + status = Status.InTransit, + onClick = {}, ) } } diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/AdaptiveView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/AdaptiveView.kt new file mode 100644 index 0000000..0ac6eee --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/AdaptiveView.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.views + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +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 dev.itsvic.parceltracker.api.Parcel as APIParcel +import dev.itsvic.parceltracker.db.Parcel +import dev.itsvic.parceltracker.db.ParcelWithStatus + +@Composable +fun AdaptiveParcelApp( + windowSizeClass: WindowSizeClass, + parcels: List, + selectedParcel: Parcel?, + apiParcel: APIParcel?, + isLoading: Boolean, + onNavigateToParcel: (Parcel) -> Unit, + onNavigateToAddParcel: () -> Unit, + onNavigateToSettings: () -> Unit, + onEditParcel: (Parcel) -> Unit, + onDeleteParcel: (Parcel) -> Unit, + onArchiveParcel: (Parcel) -> Unit, + onArchivePromptDismissal: (Parcel) -> Unit, + settingsContent: @Composable () -> Unit, + addParcelContent: @Composable () -> Unit, + homeContent: @Composable () -> Unit, +) { + val isTablet = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium + + if (isTablet) { + var currentNavigationItem by remember { mutableStateOf(TabletNavigationItem.HOME) } + + TabletView( + parcels = parcels, + selectedParcel = selectedParcel, + apiParcel = apiParcel, + isLoading = isLoading, + currentNavigationItem = currentNavigationItem, + onNavigateToItem = { currentNavigationItem = it }, + onNavigateToParcel = onNavigateToParcel, + onNavigateToAddParcel = onNavigateToAddParcel, + onNavigateToSettings = onNavigateToSettings, + onEditParcel = onEditParcel, + onDeleteParcel = onDeleteParcel, + onArchiveParcel = onArchiveParcel, + onArchivePromptDismissal = onArchivePromptDismissal, + settingsContent = settingsContent, + addParcelContent = addParcelContent, + ) + } else { + homeContent() + } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/AddEditParcelView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/AddEditParcelView.kt index 43ebadb..a6ba415 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/views/AddEditParcelView.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/AddEditParcelView.kt @@ -2,19 +2,29 @@ package dev.itsvic.parceltracker.ui.views import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox @@ -24,10 +34,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,29 +50,73 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.itsvic.parceltracker.CLIPBOARD_PASTE_ENABLED +import dev.itsvic.parceltracker.PREFERRED_REGION import dev.itsvic.parceltracker.R import dev.itsvic.parceltracker.api.Service import dev.itsvic.parceltracker.api.getDeliveryService import dev.itsvic.parceltracker.api.getDeliveryServiceName import dev.itsvic.parceltracker.api.serviceOptions +import dev.itsvic.parceltracker.dataStore import dev.itsvic.parceltracker.db.Parcel import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme +import kotlinx.coroutines.flow.map @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AddEditParcelView( - parcel: Parcel?, - onBackPressed: () -> Unit, - onCompleted: (Parcel) -> Unit, +fun AddEditParcelView(parcel: Parcel?, onBackPressed: () -> Unit, onCompleted: (Parcel) -> Unit) { + val isEdit = parcel != null + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + topBar = { + LargeTopAppBar( + title = { + Text(stringResource(if (isEdit) R.string.edit_parcel else R.string.add_a_parcel)) + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AddEditParcelContent( + parcel = parcel, + onCompleted = onCompleted, + isDialog = false + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditParcelContent( + parcel: Parcel?, + onCompleted: (Parcel) -> Unit, + isDialog: Boolean = false ) { val isEdit = parcel != null + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val clipboardPasteEnabled by + context.dataStore.data.map { it[CLIPBOARD_PASTE_ENABLED] == true }.collectAsState(false) + val preferredRegion by + context.dataStore.data.map { it[PREFERRED_REGION] ?: "" }.collectAsState("") var humanName by remember { mutableStateOf(parcel?.humanName ?: "") } - var nameError by remember { mutableStateOf(false) } var trackingId by remember { mutableStateOf(parcel?.parcelId ?: "") } var idError by remember { mutableStateOf(false) } var specifyPostalCode by remember { mutableStateOf(parcel?.postalCode != null) } @@ -69,17 +128,11 @@ fun AddEditParcelView( val backend = if (service != Service.UNDEFINED) getDeliveryService(service) else null fun validateInputs(): Boolean { - // reset error states first - nameError = false idError = false serviceError = false postalCodeError = false var success = true - if (humanName.isBlank()) { - success = false - nameError = true - } if (trackingId.isBlank()) { success = false idError = true @@ -88,8 +141,10 @@ fun AddEditParcelView( success = false serviceError = true } - if (((backend?.acceptsPostCode == true && specifyPostalCode) || - (backend?.requiresPostCode == true)) && postalCode.isBlank()) { + if ( + ((backend?.acceptsPostCode == true && specifyPostalCode) || + (backend?.requiresPostCode == true)) && postalCode.isBlank() + ) { success = false postalCodeError = true } @@ -102,176 +157,436 @@ fun AddEditParcelView( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val sortedServiceOptions = - serviceOptions.sortedBy { getDeliveryService(it)?.acceptsFormat(trackingId)?.not() } + serviceOptions.sortedWith( + compareBy { + val isPreferredRegion = + when (preferredRegion) { + "international" -> + it in listOf(Service.CAINIAO, Service.DHL, Service.GLS, Service.UPS, Service.FPX) + "north_america" -> it == Service.UNIUNI + "europe" -> + it in + listOf( + Service.BELPOST, + Service.SAMEDAY_BG, + Service.DPD_UK, + Service.EVRI, + Service.AN_POST, + Service.ALLEGRO_ONEBOX, + Service.INPOST, + Service.ORLEN_PACZKA, + Service.POLISH_POST, + Service.GLS_HUNGARY, + Service.MAGYAR_POSTA, + Service.SAMEDAY_HU, + Service.IMILE, + Service.DPD_GER, + Service.HERMES, + Service.POSTE_ITALIANE, + Service.SAMEDAY_RO, + Service.POSTNORD, + Service.NOVA_POSHTA, + Service.UKRPOSHTA, + Service.PACKETA, + Service.EXPRESS_ONE, + ) + "asia" -> it in listOf(Service.EKART, Service.SPX_TH) + "belarus" -> it == Service.BELPOST + "bulgaria" -> it == Service.SAMEDAY_BG + "uk" -> it in listOf(Service.DPD_UK, Service.EVRI) + "ireland" -> it == Service.AN_POST + "poland" -> + it in + listOf( + Service.ALLEGRO_ONEBOX, + Service.INPOST, + Service.ORLEN_PACZKA, + Service.POLISH_POST, + ) + "hungary" -> + it in + listOf( + Service.GLS_HUNGARY, + Service.MAGYAR_POSTA, + Service.SAMEDAY_HU, + Service.EXPRESS_ONE, + Service.IMILE, + ) + "germany" -> it in listOf(Service.DPD_GER, Service.HERMES) + "italy" -> it == Service.POSTE_ITALIANE + "romania" -> it == Service.SAMEDAY_RO + "scandinavia" -> it == Service.POSTNORD + "ukraine" -> it in listOf(Service.NOVA_POSHTA, Service.UKRPOSHTA) + "india" -> it == Service.EKART + "thailand" -> it == Service.SPX_TH + else -> false + } + if (isPreferredRegion) 0 else 1 + } + .thenBy { + when (it) { + Service.CAINIAO, + Service.DHL, + Service.GLS, + Service.UPS, + Service.FPX -> 0 + Service.UNIUNI -> 1 + Service.BELPOST, + Service.SAMEDAY_BG, + Service.PACKETA, + Service.DPD_UK, + Service.EVRI, + Service.AN_POST, + Service.ALLEGRO_ONEBOX, + Service.INPOST, + Service.ORLEN_PACZKA, + Service.POLISH_POST, + Service.GLS_HUNGARY, + Service.MAGYAR_POSTA, + Service.SAMEDAY_HU, + Service.EXPRESS_ONE, + Service.DPD_GER, + Service.HERMES, + Service.POSTE_ITALIANE, + Service.SAMEDAY_RO, + Service.POSTNORD, + Service.NOVA_POSHTA, + Service.EKART, + Service.SPX_TH, + Service.IMILE, + Service.UKRPOSHTA -> 2 + else -> 4 + } as Comparable<*>? + } + .thenBy { + when (it) { + Service.BELPOST -> "A_Belarus" + Service.SAMEDAY_BG -> "B_Bulgaria" + Service.PACKETA -> "C_Europe" + Service.DPD_UK, + Service.EVRI -> "D_UK" + Service.AN_POST -> "E_Ireland" + Service.ALLEGRO_ONEBOX, + Service.INPOST, + Service.ORLEN_PACZKA, + Service.POLISH_POST -> "F_Poland" + Service.GLS_HUNGARY, + Service.MAGYAR_POSTA, + Service.SAMEDAY_HU, + Service.EXPRESS_ONE, + Service.IMILE -> "G_Hungary" + Service.DPD_GER, + Service.HERMES -> "H_Germany" + Service.POSTE_ITALIANE -> "I_Italy" + Service.SAMEDAY_RO -> "J_Romania" + Service.POSTNORD -> "K_Scandinavia" + Service.NOVA_POSHTA, + Service.UKRPOSHTA -> "L_Ukraine" + else -> it.name + } + } + .thenBy { + if (trackingId.isNotBlank()) { + val backend = getDeliveryService(it) + if (backend?.acceptsFormat(trackingId) == true) 0 else 1 + } else { + 0 + } + } + ) - Scaffold( - topBar = { - TopAppBar( - title = { - Text(stringResource(if (isEdit) R.string.edit_parcel else R.string.add_a_parcel)) - }, - navigationIcon = { - IconButton(onClick = onBackPressed) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.go_back)) - } - }, - scrollBehavior = scrollBehavior, - ) - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) { innerPadding -> - Column( - modifier = - Modifier.padding(innerPadding).fillMaxWidth().verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally) { - Column( - modifier = - Modifier.padding(horizontal = 16.dp).sizeIn(maxWidth = 488.dp).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = humanName, - onValueChange = { - humanName = it - nameError = false - }, - singleLine = true, - label = { Text(stringResource(R.string.parcel_name)) }, - modifier = Modifier.fillMaxWidth(), - isError = nameError, - supportingText = { - if (nameError) Text(stringResource(R.string.human_name_error_text)) - }) + Column( + modifier = Modifier + .padding(horizontal = if (isDialog) 0.dp else 16.dp) + .sizeIn(maxWidth = 600.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (!isDialog) { + Spacer(modifier = Modifier.height(8.dp)) + } + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + OutlinedTextField( + value = humanName, + onValueChange = { humanName = it }, + singleLine = true, + label = { Text(stringResource(R.string.parcel_name)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_label), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + OutlinedTextField( + value = trackingId, + onValueChange = { + trackingId = it + idError = false + }, + singleLine = true, + label = { Text(stringResource(R.string.tracking_id)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.package_2), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier.fillMaxWidth(), + isError = idError, + trailingIcon = { + if (clipboardPasteEnabled) { + IconButton( + onClick = { + clipboardManager.getText()?.text?.let { clipboardText -> + trackingId = clipboardText + idError = false + } + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_contentpaste), + contentDescription = stringResource(R.string.clipboard_paste), + modifier = Modifier.size(20.dp), + ) + } + } + }, + supportingText = { if (idError) Text(stringResource(R.string.tracking_id_error_text)) }, + shape = RoundedCornerShape(12.dp) + ) + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { OutlinedTextField( - value = trackingId, - onValueChange = { - trackingId = it - idError = false - }, - singleLine = true, - label = { Text(stringResource(R.string.tracking_id)) }, - modifier = Modifier.fillMaxWidth(), - isError = idError, - supportingText = { - if (idError) Text(stringResource(R.string.tracking_id_error_text)) - }) + value = + if (service == Service.UNDEFINED) "" + else stringResource(getDeliveryServiceName(service)!!), + onValueChange = {}, + modifier = + Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + readOnly = true, + label = { Text(stringResource(R.string.delivery_service)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.outline_local_shipping_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + isError = serviceError, + supportingText = { if (serviceError) Text(stringResource(R.string.service_error_text)) }, + shape = RoundedCornerShape(12.dp) + ) - // Service dropdown - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(12.dp) + ) + .border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + RoundedCornerShape(12.dp) + ) ) { - OutlinedTextField( - value = - if (service == Service.UNDEFINED) "" - else stringResource(getDeliveryServiceName(service)!!), - onValueChange = {}, - modifier = - Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) - .fillMaxWidth(), - readOnly = true, - label = { Text(stringResource(R.string.delivery_service)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), - isError = serviceError, - supportingText = { - if (serviceError) Text(stringResource(R.string.service_error_text)) - }) - - ExposedDropdownMenu( - expanded = expanded, onDismissRequest = { expanded = false }) { - sortedServiceOptions.forEach { option -> - DropdownMenuItem( - text = { Text(stringResource(getDeliveryServiceName(option)!!)) }, - onClick = { - service = option - expanded = false - serviceError = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } + var currentCategory = "" + sortedServiceOptions.forEach { option -> + val category = + when (option) { + Service.CAINIAO, + Service.DHL, + Service.GLS, + Service.UPS, + Service.FPX -> stringResource(R.string.category_international) + Service.UNIUNI -> stringResource(R.string.category_north_america) + Service.BELPOST -> stringResource(R.string.category_europe_belarus) + Service.SAMEDAY_BG -> stringResource(R.string.category_europe_bulgaria) + Service.PACKETA -> stringResource(R.string.category_europe_czech) + Service.DPD_UK, + Service.EVRI -> stringResource(R.string.category_europe_uk) + Service.AN_POST -> stringResource(R.string.category_europe_ireland) + Service.ALLEGRO_ONEBOX, + Service.INPOST, + Service.ORLEN_PACZKA, + Service.POLISH_POST -> stringResource(R.string.category_europe_poland) + Service.GLS_HUNGARY -> stringResource(R.string.category_europe_hungary) + Service.MAGYAR_POSTA -> stringResource(R.string.category_europe_hungary) + Service.SAMEDAY_HU -> stringResource(R.string.category_europe_hungary) + Service.DPD_GER, + Service.HERMES -> stringResource(R.string.category_europe_germany) + Service.POSTE_ITALIANE -> stringResource(R.string.category_europe_italy) + Service.SAMEDAY_RO -> stringResource(R.string.category_europe_romania) + Service.POSTNORD -> stringResource(R.string.category_europe_scandinavia) + Service.NOVA_POSHTA, + Service.UKRPOSHTA -> stringResource(R.string.category_europe_ukraine) + Service.EKART -> stringResource(R.string.category_asia_india) + Service.SPX_TH -> stringResource(R.string.category_asia_thailand) + Service.IMILE -> stringResource(R.string.category_europe_hungary) + Service.EXPRESS_ONE -> stringResource(R.string.category_europe_hungary) + else -> stringResource(R.string.category_other) } - } - AnimatedVisibility(backend?.acceptsPostCode == true && !backend.requiresPostCode) { - Row( + if (category != currentCategory) { + currentCategory = category + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Icon( + painter = painterResource(R.drawable.outline_local_shipping_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + }, + onClick = {}, + enabled = false, + modifier = Modifier.background( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) + ) + ) + } + + DropdownMenuItem( + text = { + Text( + text = " " + stringResource(getDeliveryServiceName(option)!!), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) + }, + onClick = { + service = option + expanded = false + serviceError = false + }, + modifier = Modifier + .background( + if (service == option) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + else Color.Transparent + ) + ) + } + } + } + + AnimatedVisibility( + backend?.acceptsPostCode == true || backend?.requiresPostCode == true + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AnimatedVisibility(backend?.acceptsPostCode == true && !backend.requiresPostCode) { + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.fillMaxWidth(0.8f)) { - Text(stringResource(R.string.specify_a_postal_code)) - Text( - stringResource(R.string.specify_postal_code_flavor_text), - fontSize = 14.sp, - lineHeight = 21.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Checkbox( - checked = specifyPostalCode, - onCheckedChange = { specifyPostalCode = it }, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth(0.8f)) { + Text(stringResource(R.string.specify_a_postal_code)) + Text( + stringResource(R.string.specify_postal_code_flavor_text), + fontSize = 14.sp, + lineHeight = 21.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } - - AnimatedVisibility( - backend?.requiresPostCode == true || - (backend?.requiresPostCode == false && - backend.acceptsPostCode && - specifyPostalCode)) { - OutlinedTextField( - value = postalCode, - onValueChange = { - postalCode = it - postalCodeError = false - }, - singleLine = true, - label = { Text(stringResource(R.string.postal_code)) }, - modifier = Modifier.fillMaxWidth(), - isError = postalCodeError, - supportingText = { - if (postalCodeError) - Text(stringResource(R.string.postal_code_error_text)) - }) + Checkbox(checked = specifyPostalCode, onCheckedChange = { specifyPostalCode = it }) } + } - Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - val isOk = validateInputs() - if (isOk) { - // data valid, pass it along - onCompleted( - Parcel( - id = parcel?.id ?: 0, - humanName = humanName, - parcelId = trackingId, - service = service, - postalCode = - if (backend?.requiresPostCode == true || - (backend?.acceptsPostCode == true && specifyPostalCode)) - postalCode - else null)) - } - }) { - Text(stringResource(if (isEdit) R.string.save else R.string.add_parcel)) - } + AnimatedVisibility( + backend?.requiresPostCode == true || + (backend?.requiresPostCode == false && backend.acceptsPostCode && specifyPostalCode) + ) { + OutlinedTextField( + value = postalCode, + onValueChange = { + postalCode = it + postalCodeError = false + }, + singleLine = true, + label = { Text(stringResource(R.string.postal_code)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.outline_pin_drop_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier.fillMaxWidth(), + isError = postalCodeError, + supportingText = { + if (postalCodeError) Text(stringResource(R.string.postal_code_error_text)) + }, + shape = RoundedCornerShape(12.dp) + ) + } } } } - } -} + } + Button( + onClick = { + val isOk = validateInputs() + if (isOk) { + onCompleted( + Parcel( + id = parcel?.id ?: 0, + humanName = humanName.ifBlank { context.getString(R.string.undefinied_packagename) }, + parcelId = trackingId, + service = service, + postalCode = + if ( + backend?.requiresPostCode == true || + (backend?.acceptsPostCode == true && specifyPostalCode) + ) + postalCode + else null, + ) + ) + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Text(stringResource(if (isEdit) R.string.save else R.string.add_parcel)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } @Composable @PreviewLightDark fun AddParcelPreview() { - ParcelTrackerTheme { - AddEditParcelView( - null, - onBackPressed = {}, - onCompleted = {}, - ) - } + ParcelTrackerTheme { AddEditParcelView(null, onBackPressed = {}, onCompleted = {}) } } @Composable @@ -279,9 +594,9 @@ fun AddParcelPreview() { fun EditParcelPreview() { ParcelTrackerTheme { AddEditParcelView( - Parcel(0, "Test", "Test", null, Service.EXAMPLE), - onBackPressed = {}, - onCompleted = {}, + Parcel(0, "Test", "Test", null, Service.EXAMPLE), + onBackPressed = {}, + onCompleted = {}, ) } } diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/HomeView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/HomeView.kt index cb015ee..5cbeff5 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/views/HomeView.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/HomeView.kt @@ -4,26 +4,12 @@ package dev.itsvic.parceltracker.ui.views import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults 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.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -35,84 +21,43 @@ import dev.itsvic.parceltracker.api.Status import dev.itsvic.parceltracker.db.Parcel import dev.itsvic.parceltracker.db.ParcelStatus import dev.itsvic.parceltracker.db.ParcelWithStatus -import dev.itsvic.parceltracker.ui.components.AboutDialog -import dev.itsvic.parceltracker.ui.components.ParcelRow -import dev.itsvic.parceltracker.ui.theme.MenuItemContentPadding +import dev.itsvic.parceltracker.ui.components.ParcelCard import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme import java.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeView( - parcels: List, - onNavigateToAddParcel: () -> Unit, - onNavigateToParcel: (Parcel) -> Unit, - onNavigateToSettings: () -> Unit, + parcels: List, + onNavigateToAddParcel: () -> Unit, + onNavigateToParcel: (Parcel) -> Unit, + onNavigateToSettings: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - var expanded by remember { mutableStateOf(false) } - var aboutDialogOpen by remember { mutableStateOf(false) } Scaffold( - topBar = { - LargeTopAppBar( - title = { Text(stringResource(R.string.app_name)) }, - scrollBehavior = scrollBehavior, - actions = { - IconButton(onClick = { expanded = !expanded }) { - Icon(Icons.Filled.MoreVert, stringResource(R.string.more_options)) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - leadingIcon = { - Icon(Icons.Filled.Settings, stringResource(R.string.settings)) - }, - text = { Text(stringResource(R.string.settings)) }, - onClick = { - expanded = false - onNavigateToSettings() - }, - contentPadding = MenuItemContentPadding, - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Filled.Info, stringResource(R.string.about_app)) }, - text = { Text(stringResource(R.string.about_app)) }, - onClick = { - expanded = false - aboutDialogOpen = true - }, - contentPadding = MenuItemContentPadding, - ) - } - }, - ) - }, - floatingActionButton = { - FloatingActionButton(onClick = onNavigateToAddParcel) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_a_parcel)) + topBar = { + LargeTopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + if (parcels.isEmpty()) + item { + Text( + stringResource(R.string.no_parcels_flavor), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) } - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) { innerPadding -> - LazyColumn(modifier = Modifier.padding(innerPadding)) { - if (parcels.isEmpty()) - item { - Text( - stringResource(R.string.no_parcels_flavor), - modifier = Modifier.padding(horizontal = 16.dp)) - } - items(parcels.reversed()) { parcel -> - ParcelRow(parcel.parcel, parcel.status?.status) { onNavigateToParcel(parcel.parcel) } - } - } - - if (aboutDialogOpen) { - AboutDialog { aboutDialogOpen = false } - } + items(parcels.reversed()) { parcel -> + ParcelCard(parcel.parcel, parcel.status?.status) { onNavigateToParcel(parcel.parcel) } } + } + } } @Composable @@ -120,14 +65,16 @@ fun HomeView( fun HomeViewPreview() { ParcelTrackerTheme { HomeView( - parcels = - listOf( - ParcelWithStatus( - Parcel(0, "My precious package", "EXMPL0001", null, Service.EXAMPLE), - ParcelStatus(0, Status.InTransit, Instant.now()))), - onNavigateToAddParcel = {}, - onNavigateToParcel = {}, - onNavigateToSettings = {}, + parcels = + listOf( + ParcelWithStatus( + Parcel(0, "My precious package", "EXMPL0001", null, Service.EXAMPLE), + ParcelStatus(0, Status.InTransit, Instant.now()), + ) + ), + onNavigateToAddParcel = {}, + onNavigateToParcel = {}, + onNavigateToSettings = {}, ) } } diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt index 807adfe..2209e6b 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt @@ -1,43 +1,40 @@ // SPDX-License-Identifier: GPL-3.0-or-later package dev.itsvic.parceltracker.ui.views +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.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.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.ui.res.painterResource import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBar 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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -48,196 +45,308 @@ import dev.itsvic.parceltracker.api.ParcelHistoryItem import dev.itsvic.parceltracker.api.Service import dev.itsvic.parceltracker.api.Status import dev.itsvic.parceltracker.api.getDeliveryServiceName +import dev.itsvic.parceltracker.ui.components.FloatingCollapsibleActionBar import dev.itsvic.parceltracker.ui.components.ParcelHistoryItemRow -import dev.itsvic.parceltracker.ui.theme.MenuItemContentPadding import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme import java.time.LocalDateTime @OptIn(ExperimentalMaterial3Api::class) @Composable fun ParcelView( - parcel: Parcel, - humanName: String, - service: Service, - isArchived: Boolean, - archivePromptDismissed: Boolean, - onBackPressed: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, - onArchive: () -> Unit, - onArchivePromptDismissal: () -> Unit, + parcel: Parcel, + humanName: String, + service: Service, + isArchived: Boolean, + archivePromptDismissed: Boolean, + onBackPressed: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onArchive: () -> Unit, + onArchivePromptDismissal: () -> Unit, + showBackButton: Boolean = true, ) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - var expanded by remember { mutableStateOf(false) } - - Scaffold( + Box { + Scaffold( topBar = { - MediumTopAppBar( - title = { Text(humanName) }, - navigationIcon = { + TopAppBar( + title = { + Text( + text = humanName, + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + if (showBackButton) { IconButton(onClick = onBackPressed) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.go_back)) - } - }, - actions = { - IconButton(onClick = { expanded = !expanded }) { - Icon(Icons.Filled.MoreVert, stringResource(R.string.more_options)) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - leadingIcon = { Icon(Icons.Filled.Edit, stringResource(R.string.edit)) }, - text = { Text(stringResource(R.string.edit)) }, - onClick = { - expanded = false - onEdit() - }, - contentPadding = MenuItemContentPadding, - ) - if (!isArchived) - DropdownMenuItem( - leadingIcon = { - Icon( - painterResource(R.drawable.archive), stringResource(R.string.archive)) - }, - text = { Text(stringResource(R.string.archive)) }, - onClick = onArchive, - contentPadding = MenuItemContentPadding, - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Filled.Delete, stringResource(R.string.delete)) }, - text = { Text(stringResource(R.string.delete)) }, - onClick = onDelete, - contentPadding = MenuItemContentPadding, + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.go_back) ) } - }, - scrollBehavior = scrollBehavior, + } + } ) }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).padding(16.dp, 0.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween) { - getDeliveryServiceName(service)?.let { - Text( - stringResource(it), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } + bottomBar = {}, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .padding(16.dp) + .padding(bottom = 45.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { - SelectionContainer { - Text( - parcel.id, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } + if (parcel.properties.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + MaterialTheme.colorScheme.secondaryContainer, + CircleShape + ), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.package_2), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(20.dp) + ) } - } - - items(parcel.properties.entries.toList()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween) { + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = stringResource(R.string.additional_info), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + + parcel.properties.entries.forEachIndexed { index, entry -> + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { Text( - stringResource(it.key), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant) + text = stringResource(entry.key), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Text( - it.value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End) + text = entry.value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) } + } + } } - - item { + } + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = when (parcel.currentStatus) { + Status.Delivered, Status.PickedUp -> MaterialTheme.colorScheme.primaryContainer + Status.InTransit, Status.OutForDelivery -> MaterialTheme.colorScheme.tertiaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + when (parcel.currentStatus) { + Status.Delivered, Status.PickedUp -> MaterialTheme.colorScheme.primary + Status.InTransit, Status.OutForDelivery -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.outline + }, + CircleShape + ), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.outline_deployed_code_history_24), + contentDescription = null, + tint = when (parcel.currentStatus) { + Status.Delivered, Status.PickedUp -> MaterialTheme.colorScheme.onPrimary + Status.InTransit, Status.OutForDelivery -> MaterialTheme.colorScheme.onTertiary + else -> MaterialTheme.colorScheme.surface + }, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = stringResource(R.string.current_status), + style = MaterialTheme.typography.titleMedium, + color = when (parcel.currentStatus) { + Status.Delivered, Status.PickedUp -> MaterialTheme.colorScheme.onPrimaryContainer + Status.InTransit, Status.OutForDelivery -> MaterialTheme.colorScheme.onTertiaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } Text( - LocalContext.current.getString(parcel.currentStatus.nameResource), - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.padding(vertical = 16.dp), + text = LocalContext.current.getString(parcel.currentStatus.nameResource), + style = MaterialTheme.typography.headlineMedium, + color = when (parcel.currentStatus) { + Status.Delivered, Status.PickedUp -> MaterialTheme.colorScheme.onPrimaryContainer + Status.InTransit, Status.OutForDelivery -> MaterialTheme.colorScheme.onTertiaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } ) } + } + } - if (!isArchived && - !archivePromptDismissed && - (parcel.currentStatus == Status.Delivered || parcel.currentStatus == Status.PickedUp)) - item { - Card( - shape = RoundedCornerShape(16.dp), - modifier = Modifier.padding(bottom = 16.dp)) { - Column( - Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - stringResource(R.string.archive_prompt_question), - style = MaterialTheme.typography.titleMedium) - Text(stringResource(R.string.archive_prompt_text)) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth()) { - FilledTonalButton( - onArchivePromptDismissal, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.ignore)) - } - Button(onArchive, modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.archive)) - } - } - } - } + if ( + !isArchived && + !archivePromptDismissed && + (parcel.currentStatus == Status.Delivered || parcel.currentStatus == Status.PickedUp) + ) + item { + Card(shape = RoundedCornerShape(16.dp), modifier = Modifier.padding(bottom = 16.dp)) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.archive_prompt_question), + style = MaterialTheme.typography.titleMedium, + ) + Text(stringResource(R.string.archive_prompt_text)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + FilledTonalButton(onArchivePromptDismissal, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.ignore)) + } + Button(onArchive, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.archive)) + } } - - items(parcel.history.size) { index -> - if (index > 0) HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 16.dp)) - ParcelHistoryItemRow(parcel.history[index]) + } + } + } + if (parcel.history.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + MaterialTheme.colorScheme.tertiaryContainer, + CircleShape + ), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.outline_deployed_code_history_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = stringResource(R.string.tracking_history), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + + parcel.history.forEachIndexed { index, historyItem -> + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + } + ParcelHistoryItemRow(historyItem) + } + } } } } + } + FloatingCollapsibleActionBar( + status = parcel.currentStatus, + onEdit = onEdit, + onArchive = onArchive, + onDelete = onDelete, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } } @Composable @PreviewLightDark -private fun ParcelViewPreview() { +fun ParcelViewPreview() { val parcel = - Parcel( - "EXMPL0001", - listOf( - ParcelHistoryItem( - "The package got lost. Whoops!", - LocalDateTime.of(2025, 1, 1, 12, 0, 0), - "Warsaw, Poland"), - ParcelHistoryItem( - "Arrived at local warehouse", - LocalDateTime.of(2025, 1, 1, 10, 0, 0), - "Warsaw, Poland"), - ParcelHistoryItem( - "En route to local warehouse", - LocalDateTime.of(2024, 12, 1, 12, 0, 0), - "Netherlands"), - ParcelHistoryItem( - "Label created", LocalDateTime.of(2024, 12, 1, 12, 0, 0), "Netherlands"), - ), - Status.DeliveryFailure) + Parcel( + "EXMPL0001", + listOf( + ParcelHistoryItem( + "The package got lost. Whoops!", + LocalDateTime.of(2025, 1, 1, 12, 0, 0), + "Warsaw, Poland", + ), + ParcelHistoryItem( + "Arrived at local warehouse", + LocalDateTime.of(2025, 1, 1, 10, 0, 0), + "Warsaw, Poland", + ), + ParcelHistoryItem( + "En route to local warehouse", + LocalDateTime.of(2024, 12, 1, 12, 0, 0), + "Netherlands", + ), + ParcelHistoryItem("Label created", LocalDateTime.of(2024, 12, 1, 12, 0, 0), "Netherlands"), + ), + Status.DeliveryFailure, + ) ParcelTrackerTheme { ParcelView( - parcel, - "My precious package", - Service.EXAMPLE, - isArchived = false, - archivePromptDismissed = false, - onBackPressed = {}, - onEdit = {}, - onDelete = {}, - onArchive = {}, - onArchivePromptDismissal = {}, + parcel, + "My precious package", + Service.EXAMPLE, + isArchived = false, + archivePromptDismissed = false, + onBackPressed = {}, + onEdit = {}, + onDelete = {}, + onArchive = {}, + onArchivePromptDismissal = {}, ) } } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/SettingsView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/SettingsView.kt index 087ca56..347f90f 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/views/SettingsView.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/SettingsView.kt @@ -5,19 +5,26 @@ import androidx.compose.foundation.clickable 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -25,15 +32,21 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextDecoration @@ -42,36 +55,39 @@ import androidx.compose.ui.unit.dp import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import dev.itsvic.parceltracker.BuildConfig +import dev.itsvic.parceltracker.CLIPBOARD_PASTE_ENABLED import dev.itsvic.parceltracker.DEMO_MODE import dev.itsvic.parceltracker.DHL_API_KEY +import dev.itsvic.parceltracker.PREFERRED_REGION import dev.itsvic.parceltracker.R import dev.itsvic.parceltracker.UNMETERED_ONLY -import dev.itsvic.parceltracker.api.ParcelHistoryItem -import dev.itsvic.parceltracker.api.Service -import dev.itsvic.parceltracker.api.Status import dev.itsvic.parceltracker.dataStore -import dev.itsvic.parceltracker.db.Parcel import dev.itsvic.parceltracker.enqueueNotificationWorker -import dev.itsvic.parceltracker.sendNotification -import dev.itsvic.parceltracker.ui.components.LogcatButton +import dev.itsvic.parceltracker.ui.components.AboutDialog import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme -import java.time.LocalDateTime import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsView( - onBackPressed: () -> Unit, -) { +fun SettingsView() { val context = LocalContext.current val demoMode by context.dataStore.data.map { it[DEMO_MODE] == true }.collectAsState(false) val unmeteredOnly by - context.dataStore.data.map { it[UNMETERED_ONLY] == true }.collectAsState(false) + context.dataStore.data.map { it[UNMETERED_ONLY] == true }.collectAsState(false) + val clipboardPasteEnabled by + context.dataStore.data.map { it[CLIPBOARD_PASTE_ENABLED] == true }.collectAsState(false) + val preferredRegion by + context.dataStore.data.map { it[PREFERRED_REGION] ?: "" }.collectAsState("") + val dhlApiKey by context.dataStore.data.map { it[DHL_API_KEY] ?: "" }.collectAsState("") + val coroutineScope = rememberCoroutineScope() + var regionDropdownExpanded by remember { mutableStateOf(false) } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var aboutDialogOpen by remember { mutableStateOf(false) } - val dhlApiKey by context.dataStore.data.map { it[DHL_API_KEY] ?: "" }.collectAsState("") + val testPackageName = stringResource(R.string.settings_test_package_name) + val testPackageStatus = stringResource(R.string.settings_test_package_status) fun setValue(key: Preferences.Key, value: T) { coroutineScope.launch { context.dataStore.edit { it[key] = value } } @@ -80,116 +96,352 @@ fun SettingsView( val setUnmeteredOnly: (Boolean) -> Unit = { value -> coroutineScope.launch { context.dataStore.edit { it[UNMETERED_ONLY] = value } - // reschedule notification worker to update constraints context.enqueueNotificationWorker() } } Scaffold( - topBar = { - LargeTopAppBar( - title = { Text(stringResource(R.string.settings)) }, - navigationIcon = { - IconButton(onClick = onBackPressed) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.go_back)) - } - }, - scrollBehavior = scrollBehavior, - ) - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text(stringResource(R.string.settings)) }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding -> - Column(Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { - Row( - modifier = - Modifier.clickable { setUnmeteredOnly(unmeteredOnly.not()) } - .padding(16.dp, 12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { aboutDialogOpen = true }, + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { - Column(modifier = Modifier.fillMaxWidth(0.8f)) { - Text(stringResource(R.string.unmetered_only_setting)) - Text( - stringResource(R.string.unmetered_only_setting_detail), - style = MaterialTheme.typography.bodyMedium) + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.icon_foreground), + contentDescription = null, + modifier = Modifier.size(100.dp), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.settings_version_label, BuildConfig.VERSION_NAME), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) } - Switch(checked = unmeteredOnly, onCheckedChange = { setUnmeteredOnly(it) }) } + + Spacer(modifier = Modifier.height(16.dp)) - Text( - stringResource(R.string.settings_api_keys), - modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 2.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + painterResource(R.drawable.ic_networkwifi), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.settings_network), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp) + ) + } + + Row( + modifier = Modifier + .clickable { setUnmeteredOnly(unmeteredOnly.not()) } + .padding(vertical = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.fillMaxWidth(0.8f)) { + Text(stringResource(R.string.unmetered_only_setting)) + Text( + stringResource(R.string.unmetered_only_setting_detail), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = unmeteredOnly, onCheckedChange = { setUnmeteredOnly(it) }) + } + + Row( + modifier = Modifier + .clickable { setValue(CLIPBOARD_PASTE_ENABLED, clipboardPasteEnabled.not()) } + .padding(vertical = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.fillMaxWidth(0.8f)) { + Text(stringResource(R.string.clipboard_paste_enabled)) + Text( + stringResource(R.string.clipboard_paste_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = clipboardPasteEnabled, + onCheckedChange = { setValue(CLIPBOARD_PASTE_ENABLED, it) }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - dhlApiKey, - { setValue(DHL_API_KEY, it) }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), - label = { Text(stringResource(R.string.service_dhl)) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - ) + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + painterResource(R.drawable.ic_language), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.settings_region), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp) + ) + } + + Text( + stringResource(R.string.preferred_region_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) - Text( - AnnotatedString.fromHtml( - stringResource(R.string.dhl_api_key_flavor_text), - linkStyles = - TextLinkStyles( - style = - SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary))), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + ExposedDropdownMenuBox( + expanded = regionDropdownExpanded, + onExpandedChange = { regionDropdownExpanded = !regionDropdownExpanded }, + ) { + OutlinedTextField( + value = when (preferredRegion) { + "international" -> stringResource(R.string.region_international) + "north_america" -> stringResource(R.string.region_north_america) + "europe" -> stringResource(R.string.region_europe) + "asia" -> stringResource(R.string.region_asia) + "belarus" -> stringResource(R.string.country_belarus) + "bulgaria" -> stringResource(R.string.country_bulgaria) + "czech" -> stringResource(R.string.country_czech) + "uk" -> stringResource(R.string.country_uk) + "ireland" -> stringResource(R.string.country_ireland) + "poland" -> stringResource(R.string.country_poland) + "hungary" -> stringResource(R.string.country_hungary) + "germany" -> stringResource(R.string.country_germany) + "italy" -> stringResource(R.string.country_italy) + "romania" -> stringResource(R.string.country_romania) + "scandinavia" -> stringResource(R.string.country_scandinavia) + "ukraine" -> stringResource(R.string.country_ukraine) + "india" -> stringResource(R.string.country_india) + "thailand" -> stringResource(R.string.country_thailand) + else -> stringResource(R.string.region_international) + }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.settings_region)) }, + leadingIcon = { + Icon( + painterResource(R.drawable.ic_language), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = regionDropdownExpanded) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) - Text( - stringResource(R.string.settings_experimental), - modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 2.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + ExposedDropdownMenu( + expanded = regionDropdownExpanded, + onDismissRequest = { regionDropdownExpanded = false }, + ) { + listOf( + "international" to R.string.region_international, + "north_america" to R.string.region_north_america, + "europe" to R.string.region_europe, + "asia" to R.string.region_asia, + "belarus" to R.string.country_belarus, + "bulgaria" to R.string.country_bulgaria, + "czech" to R.string.country_czech, + "uk" to R.string.country_uk, + "ireland" to R.string.country_ireland, + "poland" to R.string.country_poland, + "hungary" to R.string.country_hungary, + "germany" to R.string.country_germany, + "italy" to R.string.country_italy, + "romania" to R.string.country_romania, + "scandinavia" to R.string.country_scandinavia, + "ukraine" to R.string.country_ukraine, + "india" to R.string.country_india, + "thailand" to R.string.country_thailand + ).forEach { (key, stringRes) -> + DropdownMenuItem( + text = { Text(stringResource(stringRes)) }, + onClick = { + setValue(PREFERRED_REGION, key) + regionDropdownExpanded = false + }, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = - Modifier.clickable { setValue(DEMO_MODE, demoMode.not()) } - .padding(16.dp, 12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Column(modifier = Modifier.fillMaxWidth(0.8f)) { - Text(stringResource(R.string.demo_mode)) + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + painterResource(R.drawable.ic_vpnkey), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.settings_api_keys), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp) + ) + } + + OutlinedTextField( + value = dhlApiKey, + onValueChange = { setValue(DHL_API_KEY, it) }, + label = { Text(stringResource(R.string.settings_dhl_api_key_label)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + Text( - stringResource(R.string.demo_mode_detail), - style = MaterialTheme.typography.bodyMedium) + AnnotatedString.fromHtml( + stringResource(R.string.dhl_api_key_flavor_text), + linkStyles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary, + ) + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) } - Switch(checked = demoMode, onCheckedChange = { setValue(DEMO_MODE, it) }) } + + Spacer(modifier = Modifier.height(16.dp)) - if (BuildConfig.DEBUG) - FilledTonalButton( - onClick = { - context.sendNotification( - Parcel(0xf100f, "Cool stuff", "", null, Service.EXAMPLE), - Status.OutForDelivery, - ParcelHistoryItem( - "The courier has picked up the package", LocalDateTime.now(), "")) - }, - modifier = Modifier.padding(16.dp, 12.dp).fillMaxWidth()) { - Text("Send test notification") - } - - LogcatButton(modifier = Modifier.padding(16.dp, 12.dp).fillMaxWidth()) + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + painterResource(R.drawable.ic_science), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.settings_experimental), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp) + ) + } + + Row( + modifier = Modifier + .clickable { setValue(DEMO_MODE, demoMode.not()) } + .padding(vertical = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.fillMaxWidth(0.8f)) { + Text(stringResource(R.string.demo_mode)) + Text( + stringResource(R.string.demo_mode_detail), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = demoMode, onCheckedChange = { setValue(DEMO_MODE, it) }) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + } - Text( - "Parcel ${BuildConfig.VERSION_NAME}", - modifier = Modifier.padding(16.dp, 8.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (aboutDialogOpen) { + AboutDialog { aboutDialogOpen = false } } } } @@ -197,9 +449,5 @@ fun SettingsView( @Composable @PreviewLightDark private fun SettingsViewPreview() { - ParcelTrackerTheme { - SettingsView( - onBackPressed = {}, - ) - } + ParcelTrackerTheme { SettingsView() } } diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/TabletView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/TabletView.kt new file mode 100644 index 0000000..42ef185 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/TabletView.kt @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.itsvic.parceltracker.ui.views + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.itsvic.parceltracker.R +import dev.itsvic.parceltracker.db.Parcel +import dev.itsvic.parceltracker.db.ParcelWithStatus +import dev.itsvic.parceltracker.ui.components.ParcelRow +import dev.itsvic.parceltracker.api.Parcel as APIParcel + +enum class TabletNavigationItem { + HOME, + ADD_PARCEL, + EDIT_PARCEL, + SETTINGS, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TabletView( + parcels: List, + selectedParcel: Parcel?, + apiParcel: APIParcel?, + isLoading: Boolean, + currentNavigationItem: TabletNavigationItem, + onNavigateToItem: (TabletNavigationItem) -> Unit, + onNavigateToParcel: (Parcel) -> Unit, + onNavigateToAddParcel: () -> Unit, + onNavigateToSettings: () -> Unit, + onEditParcel: (Parcel) -> Unit, + onDeleteParcel: (Parcel) -> Unit, + onArchiveParcel: (Parcel) -> Unit, + onArchivePromptDismissal: (Parcel) -> Unit, + settingsContent: @Composable () -> Unit = {}, + addParcelContent: @Composable () -> Unit = {}, + editParcelContent: @Composable () -> Unit = {}, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Row(modifier = Modifier.fillMaxSize()) { + Card(modifier = Modifier.width(400.dp).fillMaxHeight().padding(8.dp)) { + Column { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 8.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 8.dp)) { + if (parcels.isEmpty()) { + item { + Card(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Text( + stringResource(R.string.no_parcels_flavor), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + } else { + items(parcels.reversed()) { parcel -> + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { + ParcelRow( + parcel.parcel, + parcel.status?.status, + isSelected = selectedParcel?.id == parcel.parcel.id, + ) { + onNavigateToParcel(parcel.parcel) + } + } + } + } + } + + HorizontalDivider() + + NavigationBar(modifier = Modifier.fillMaxWidth()) { + NavigationBarItem( + icon = { Icon(Icons.Filled.Home, contentDescription = null) }, + label = { Text(stringResource(R.string.home)) }, + selected = currentNavigationItem == TabletNavigationItem.HOME, + onClick = { onNavigateToItem(TabletNavigationItem.HOME) }, + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + label = { Text(stringResource(R.string.add_parcel)) }, + selected = currentNavigationItem == TabletNavigationItem.ADD_PARCEL, + onClick = { onNavigateToItem(TabletNavigationItem.ADD_PARCEL) }, + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, + label = { Text(stringResource(R.string.settings)) }, + selected = currentNavigationItem == TabletNavigationItem.SETTINGS, + onClick = { onNavigateToItem(TabletNavigationItem.SETTINGS) }, + ) + } + } + } + + // Right panel: Content area + Card(modifier = Modifier.weight(1f).fillMaxHeight().padding(8.dp)) { + when (currentNavigationItem) { + TabletNavigationItem.HOME -> { + if (selectedParcel != null) { + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (apiParcel != null) { + ParcelView( + parcel = apiParcel, + humanName = selectedParcel.humanName, + service = selectedParcel.service, + isArchived = selectedParcel.isArchived, + archivePromptDismissed = selectedParcel.archivePromptDismissed, + onBackPressed = { /* No back button in tablet mode */ }, + onEdit = { onEditParcel(selectedParcel) }, + onDelete = { onDeleteParcel(selectedParcel) }, + onArchive = { onArchiveParcel(selectedParcel) }, + onArchivePromptDismissal = { onArchivePromptDismissal(selectedParcel) }, + showBackButton = false, + ) + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp), + ) + Text( + text = stringResource(R.string.select_parcel_to_view), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + TabletNavigationItem.ADD_PARCEL -> { + addParcelContent() + } + TabletNavigationItem.EDIT_PARCEL -> { + editParcelContent() + } + TabletNavigationItem.SETTINGS -> { + settingsContent() + } + } + } + } +} diff --git a/app/src/main/res/drawable-anydpi/ic_contentpaste.xml b/app/src/main/res/drawable-anydpi/ic_contentpaste.xml new file mode 100644 index 0000000..3c13649 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_contentpaste.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_filterlist.xml b/app/src/main/res/drawable-anydpi/ic_filterlist.xml new file mode 100644 index 0000000..9820675 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_filterlist.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_label.xml b/app/src/main/res/drawable-anydpi/ic_label.xml new file mode 100644 index 0000000..e255848 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_label.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_language.xml b/app/src/main/res/drawable-anydpi/ic_language.xml new file mode 100644 index 0000000..8beebb7 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_language.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_localshipping.xml b/app/src/main/res/drawable-anydpi/ic_localshipping.xml new file mode 100644 index 0000000..76832c9 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_localshipping.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_locationon.xml b/app/src/main/res/drawable-anydpi/ic_locationon.xml new file mode 100644 index 0000000..4d88730 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_locationon.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_networkwifi.xml b/app/src/main/res/drawable-anydpi/ic_networkwifi.xml new file mode 100644 index 0000000..d508b16 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_networkwifi.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_qrcode.xml b/app/src/main/res/drawable-anydpi/ic_qrcode.xml new file mode 100644 index 0000000..ca6249b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_qrcode.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_science.xml b/app/src/main/res/drawable-anydpi/ic_science.xml new file mode 100644 index 0000000..a00f40b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_science.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_timeline.xml b/app/src/main/res/drawable-anydpi/ic_timeline.xml new file mode 100644 index 0000000..4566deb --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_timeline.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_vpnkey.xml b/app/src/main/res/drawable-anydpi/ic_vpnkey.xml new file mode 100644 index 0000000..ead0ae1 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_vpnkey.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 666ec55..57b0790 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -17,7 +17,6 @@ Upravit Upravit zásilku Přejít zpět - Název nesmí být prázdný. Ignorovat Licence Další možnosti @@ -66,4 +65,5 @@ Sledovací ID nesmí být prázdné. Aktualizovat pouze na neměřených sítích Pokud je povoleno, bude aplikace Parcel zjišťovat aktualizace pouze na neměřených připojeních, jako je vaše domácí Wi-Fi. + Balíček diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 56d766a..e179b26 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -20,7 +20,6 @@ Paket bearbeiten Dieser Zustelldienst erfordert einen API-Schlüssel, aber es wurde keiner angegeben. Weitere Informationen finden Sie in den App-Einstellungen. Zurück - Name darf nicht leer sein. Ignorieren Lizenz Weitere Optionen @@ -73,4 +72,5 @@ Sendungsnummer darf nicht leer sein. Nur bei unbegrenzten Netzwerken aktualisieren Wenn aktiviert, sucht die App nur über unbegrenzte Verbindungen wie WLAN nach Paketupdates. + Paket diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 206a034..de12071 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,23 +1,72 @@ App névjegye + Hozzáadás Csomag hozzáadása Csomag hozzáadása Archivál Biztosan archiválod ezt a csomagot? Ha archiválod, akkor a csomag előzmény az eszközön kerül eltárolásra és frissítés sem történik. + Mégse + Beillesztés vágólapról + Beillesztés gomb engedélyezése + Beillesztés gomb megjelenítése a csomagszám mező mellett + Előnyben részesített régió + A régiód futárszolgálatainak előnyben részesítése + Nemzetközi + Észak-Amerika + Európa + Ázsia + Fehéroroszország + Bulgária + Csehország + Egyesült Királyság + Írország + Lengyelország + Magyarország + Németország + Olaszország + Románia + Skandinávia + Ukrajna + India + Thaiföld + Nemzetközi + Észak-Amerika + Európa - Fehéroroszország + Európa - Bulgária + Európa - Csehország + Európa - Egyesült Királyság + Európa - Írország + Európa - Lengyelország + Európa - Magyarország + Európa - Németország + Európa - Olaszország + Európa - Románia + Európa - Skandinávia + Európa - Ukrajna + Ázsia - India + Ázsia - Thaiföld + Egyéb + Válassz egy csomagot a részletek megtekintéséhez + Főoldal + Biztosan törölni szeretnéd ezt a csomagot? Értesítés(ek) a csomag jelenlegi állapotáról Csomag események Jelenlegi állapot Törlés Futárszolgálat Teszt mód + Csomag részletei + %s részletei + Követési szám + További információk + Követési előzmények A művelet nem engedélyezett teszt módban Minta csomagokat jelenít meg az applikációban. Nem érinti a már meglévő adatokat. Támogatás Szerkesztés Csomag szerkesztése Vissza - A név nem lehet üres. Figyelmen kívül hagy Licenc További lehetőségek @@ -38,6 +87,8 @@ Ki kell választani egy futárszolgálatot. Minta Posta GLS Magyarország + Express One Magyarország + iMile Magyar Posta Lengyel Posta Olasz Posta @@ -67,4 +118,15 @@ A csomagszám nem lehet üres. Csak korlátlan hálózaton való frissítés A Parcel csak akkor fog frissítéseket keresni automatikusan, ha nem forgalmidíjas hálózatra van csatlakozva a készülék, pl. otthoni Wi-Fi. + Csomag + Hálózat + Régió + Fejlesztői eszközök + Verzió %s + Teszt értesítés küldése + DHL API kulcs + A DHL megköveteli, hogy a felhasználóktól API-kulcsot kérjünk. Ezt ingyenesen megszerezheted a <a href=\"https://developer.dhl.com\">DHL\'s API Developer Portal</a> és regisztrálhatsz a \"Shipment Tracking - Unified\" API-ra. + Teszt csomag + A futár felvette a csomagot + Műveletek diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c4146a4..058adda 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -20,7 +20,6 @@ 荷物を編集 この配送サービスには API キーが必要ですが、提供されていません。詳細はアプリの設定をご確認ください。 戻る - 名前は空白にできません。 無視 ライセンス その他のオプション @@ -73,4 +72,5 @@ 追跡 ID は空白にできません。 定額制ネットワークでのみ更新する 有効化すると Parcel は自宅の Wi-Fi などの定額制接続時でのみ更新を検索します。 + 荷物 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index da00d98..f79fb9d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,7 +20,6 @@ Edytuj paczkę Ten serwis wymaga klucza API, ale żaden został podany. Sprawdż ustawienia aplikacji po więcej informacji. Wróć - Nazwa nie może być pusta. Ignoruj Licencja Więcej opcji @@ -89,4 +88,5 @@ Identyfikator śledzenia nie może być pusty. Aktualizuj tylko w sieciach bez limitu Jeśli ta opcja jest włączona, Parcel będzie szukać aktualizacji przesyłek tylko w przypadku połączeń nielimitowanych, takich jak domowe Wi-Fi. + Paczka diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7d4d4a0..6966e91 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -19,7 +19,6 @@ Redigera paket Denna leveransservice kräver en API-nyckel men ingen angavs. Kontrollera appens inställningar för mer information. Tillbaka - Namn får inte lämnas tom. Tysta Licens Fler alternativ @@ -72,4 +71,5 @@ Spårningsnummer får inte lämnas tom. Uppdatera endast på obegränsade nätverk Om aktiverat söker Parcel efter uppdateringar endast via obegränsade anslutningar, t.ex. ditt hem-Wi-Fi. + Paket diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 8888227..f27f110 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -14,7 +14,6 @@ แก้ไข แก้ไขพัสดุ กลับ - ชื่อพัสดุไม่สามารถเว้นว่างได้ ลิขสิทธิ์ ตัวเลือกเพิ่มเติม อาจจะเป็นไปได้ว่า อุปกรณ์ของคุณไม่มีสัญญาณในขณะนี้ หรือเกิดปัญหาที่เซิฟเวอร์ฝั่งผู้ให้บริการขนส่ง หรือคุณใส่รายละเอียดของพัสดุไม่ถูกต้อง @@ -62,4 +61,5 @@ รหัสพัสดุไม่สามารถเว้นว่างได้ อัปเดตเมื่อเชื่อมต่อเครือข่ายที่ไม่จำกัดการใช้งานเท่านั้น เมื่อเปิด Parcel จะตรวจสอบสถานะของพัสดุเมื่อเชื่อมต่อเครือข่ายที่ไม่จำกัดการใช้งานเท่านั้น เช่น Wi-Fi บ้าน + พัสดุ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ab96fa1..c964512 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -19,7 +19,6 @@ Koli düzenle Bu teslimat servisi bir API anahtarı gerektiriyor, ancak hiçbiri sağlanmadı. Daha fazla bilgi için uygulamanın ayarlarını kontrol edin. Geri dön - İsim boş bırakılamaz. Yoksay Lisans Daha fazla seçenek @@ -71,4 +70,5 @@ Takip numarası boş bırakılamaz. Yalnızca sınırsız ağlarda güncelle Etkinleştirildiğinde, Parcel koli güncellemelerini yalnızca ev Wi-Fi gibi sınırsız bağlantılarda kontrol eder. + Koli diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e54ca18..a6ad9fe 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -19,7 +19,6 @@ Редагувати посилку Ця доставка потребує ключ API, але він не наданий. Перевірте налаштування застосунку для більшої інформації. Повернутись - Ім\'я не може бути пустим. Ігнорувати Ліцензія More options @@ -71,4 +70,5 @@ Трекінг-ID не може бути пустим. Оновлюватись тільки на необмежених мережах Якщо ввімкнено, то Parcel буде сповіщувати про дані посилки тільки на необмежених з\'єднаннях, такі як ваш домашній Wi-Fi. + Посилка diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2fdd3f..54cf68c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,17 +1,70 @@ About app + Add Add a parcel + Actions Add parcel Parcel Archive + Collapse Do you want to archive this parcel? If you archive this parcel, its history will be preserved on device. It will not be periodically checked for updates. + Cancel + Paste from clipboard + Enable clipboard paste button + Show a paste button next to the tracking number field + Preferred region + Prioritize delivery services from your region + International + North America + Europe + Asia + Belarus + Bulgaria + Czech Republic + United Kingdom + Ireland + Poland + Hungary + Germany + Italy + Romania + Scandinavia + Ukraine + India + Thailand + International + North America + Europe - Belarus + Europe - Bulgaria + Europe - Czech Republic + Europe - United Kingdom + Europe - Ireland + Europe - Poland + Europe - Hungary + Europe - Germany + Europe - Italy + Europe - Romania + Europe - Scandinavia + Europe - Ukraine + Asia - India + Asia - Thailand + Other + Select a parcel to view its details + Home + Are you sure you want to delete this parcel? Notifications about the current status of the parcel + Expand Parcel events Current status Delete Delivery service Demo mode + Parcel Details + %s details + Tracking Number + Additional Information + Tracking History Action not allowed in demo mode Shows example parcels for demo purposes. Does not affect user data. DHL requires us to ask users to provide an API key. You can get one for free from <a href=\"https://developer.dhl.com\">DHL\'s API Developer Portal</a> and signing up for the \"Shipment Tracking - Unified\" API. @@ -21,7 +74,6 @@ Edit parcel This delivery service requires an API key, but none was provided. Check the app\'s settings for more information. Go back - Name must not be blank. Ignore License More options @@ -49,10 +101,12 @@ You must select a service. Evri Example Post + Express One Hungary GLS GLS Hungary Hermes Hungarian Post + iMile InPost Nova Post Orlen Paczka (Poland) @@ -103,4 +157,13 @@ Tracking ID must not be blank. Update only on unmetered networks If enabled, Parcel will look for parcel updates only on unmetered connections, such as your home Wi-Fi. + Package + Network + Region + Developer tools + Version %s + Send test notification + DHL API key + Test package + Courier picked up the package diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a187b2..5f4679e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ work = "2.10.0" kotlinxCoroutinesGuava = "1.10.1" material3 = "1.4.0-alpha11" browser = "1.8.0" +jsoup = "1.17.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -37,6 +38,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class" } androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-core" } moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -57,6 +59,7 @@ work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = " work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesGuava" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }