From efaafffedff64658a1a0f309566a5ebaa822eb43 Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Sat, 20 Jul 2024 23:40:34 -0400 Subject: [PATCH 1/8] Add merch module and basic navigation flow --- app/build.gradle.kts | 1 + .../org/robojackets/apiary/MainActivity.kt | 36 ++++++ app/src/main/res/values/strings.xml | 1 + .../attendance/ui/AttendableSelection.kt | 12 +- .../apiary/base/ui/form/ItemList.kt | 37 ++++++ merchandise/.gitignore | 1 + merchandise/build.gradle.kts | 85 ++++++++++++++ merchandise/consumer-rules.pro | 0 merchandise/proguard-rules.pro | 21 ++++ merchandise/src/main/AndroidManifest.xml | 4 + .../merchandise/di/MerchandiseModule.kt | 17 +++ .../apiary/merchandise/model/Merchandise.kt | 15 +++ .../merchandise/model/MerchandiseViewModel.kt | 109 ++++++++++++++++++ .../network/MerchandiseApiService.kt | 10 ++ .../network/MerchandiseRepository.kt | 11 ++ .../ui/MerchandiseDistributionScreen.kt | 100 ++++++++++++++++ .../merchandise/ui/MerchandiseIndexScreen.kt | 47 ++++++++ .../ui/MerchandiseItemSelection.kt | 33 ++++++ .../apiary/navigation/NavigationDirections.kt | 19 +++ settings.gradle.kts | 1 + 20 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt create mode 100644 merchandise/.gitignore create mode 100644 merchandise/build.gradle.kts create mode 100644 merchandise/consumer-rules.pro create mode 100644 merchandise/proguard-rules.pro create mode 100644 merchandise/src/main/AndroidManifest.xml create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3dcadf..0093c5e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(mapOf("path" to ":attendance"))) implementation(project(mapOf("path" to ":auth"))) implementation(project(mapOf("path" to ":base"))) + implementation(project(mapOf("path" to ":merchandise"))) implementation(project(mapOf("path" to ":navigation"))) // Dependencies diff --git a/app/src/main/java/org/robojackets/apiary/MainActivity.kt b/app/src/main/java/org/robojackets/apiary/MainActivity.kt index 4abb8a3..8c4da04 100644 --- a/app/src/main/java/org/robojackets/apiary/MainActivity.kt +++ b/app/src/main/java/org/robojackets/apiary/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Contactless +import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material.navigation.ModalBottomSheetLayout import androidx.compose.material.navigation.bottomSheet import androidx.compose.material.navigation.rememberBottomSheetNavigator @@ -59,6 +60,8 @@ import org.robojackets.apiary.base.GlobalSettings import org.robojackets.apiary.base.model.AttendableType import org.robojackets.apiary.base.ui.nfc.NfcRequired import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme +import org.robojackets.apiary.merchandise.ui.MerchandiseDistributionScreen +import org.robojackets.apiary.merchandise.ui.MerchandiseIndexScreen import org.robojackets.apiary.navigation.NavigationActions import org.robojackets.apiary.navigation.NavigationDestinations import org.robojackets.apiary.navigation.NavigationManager @@ -87,6 +90,14 @@ sealed class Screen( "contactless" ) + object Merchandise : + Screen( + NavigationDestinations.merchandiseSubgraph, + R.string.merchandise, + Icons.Outlined.Storefront, + "storefront", + ) + object Settings : Screen( NavigationDestinations.settingsSubgraph, @@ -139,6 +150,7 @@ class MainActivity : ComponentActivity() { val navItems = listOf( Screen.Attendance, + Screen.Merchandise, Screen.Settings, ) @@ -311,6 +323,30 @@ class MainActivity : ComponentActivity() { ) } } + + navigation( + startDestination = NavigationDestinations.merchandiseIndex, + route = NavigationDestinations.merchandiseSubgraph + ) { + composable(NavigationDestinations.merchandiseIndex) { + MerchandiseIndexScreen(hiltViewModel()) + } + + composable( + route = "${NavigationDestinations.merchandiseDistribution}/{merchandiseItemId}", + arguments = listOf( + navArgument("merchandiseItemId") { type = NavType.IntType }, + ) + ) { + val merchandiseItemId = it.arguments?.getInt("merchandiseItemId") + + MerchandiseDistributionScreen( + hiltViewModel(), + merchandiseItemId as Int + ) + } + } + navigation( startDestination = NavigationDestinations.settings, route = NavigationDestinations.settingsSubgraph, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 681728f..752ad7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ MyRJ Attendance + Merchandise Settings \ No newline at end of file diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt index ad127f5..20e3279 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt @@ -1,12 +1,18 @@ package org.robojackets.apiary.attendance.ui import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -46,7 +52,7 @@ private fun AttendableList( } ) if (idx < attendables.size - 1) { - Divider() + HorizontalDivider() } } } diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt new file mode 100644 index 0000000..d72fc7f --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt @@ -0,0 +1,37 @@ +package org.robojackets.apiary.base.ui.form + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ListItem +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ItemList( + items: List, + onItemSelected: (item: T) -> Unit, + title: @Composable () -> Unit, + callout: @Composable () -> Unit = {}, + preItem: @Composable (idx: Int) -> Unit = {}, + postItem: @Composable (idx: Int) -> Unit = {}, + itemContent: @Composable (item: T) -> Unit, +) { + Column { + title() + callout() + LazyColumn { + itemsIndexed(items) { idx, item -> + preItem(idx) + ListItem( + headlineContent = { itemContent(item) }, + Modifier.clickable { + onItemSelected(item) + } + ) + postItem(idx) + } + } + } +} \ No newline at end of file diff --git a/merchandise/.gitignore b/merchandise/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/merchandise/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/merchandise/build.gradle.kts b/merchandise/build.gradle.kts new file mode 100644 index 0000000..4b7b8c9 --- /dev/null +++ b/merchandise/build.gradle.kts @@ -0,0 +1,85 @@ +plugins { + id("com.android.library") + kotlin("android") + id("kotlin-android") + id("com.google.devtools.ksp") + id("dagger.hilt.android.plugin") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") +} + +dependencies { + // Other modules + implementation(project(mapOf("path" to ":base"))) + implementation(project(mapOf("path" to ":navigation"))) + implementation(project(mapOf("path" to ":auth"))) + + + // Dependencies + coreLibraryDesugaring(AndroidToolDependencies.android_tools_desugar_jdk) + implementation(AndroidToolDependencies.timber) + + implementation(ComposeDependencies.compose_material3) + implementation(ComposeDependencies.compose_ui) + implementation(ComposeDependencies.compose_ui_tooling) + implementation(ComposeDependencies.lifecycle_viewmodel_compose) + + implementation(HiltDependencies.hilt) + ksp(HiltDependencies.hilt_android_compiler) + + implementation(MaterialDependencies.material_android) + implementation(ComposeDependencies.compose_material_icons_core) + implementation(ComposeDependencies.compose_material_icons_extended) + + implementation(NetworkDependencies.moshi) + ksp(NetworkDependencies.moshi_kotlin_codegen) + implementation(NetworkDependencies.okhttp) + implementation(platform(NetworkDependencies.okhttp_bom)) + implementation(NetworkDependencies.retrofit) + implementation(NetworkDependencies.retrofuture) + implementation(NetworkDependencies.sandwich) + implementation(NetworkDependencies.sandwich_retrofit) + implementation(NetworkDependencies.sandwich_retrofit_serialization) + + implementation(platform(NfcDependencies.nfc_firebase_bom)) + implementation(NfcDependencies.nfc_firebase_analytics) // Firebase BoM and Analytics (f/k/a Core) are required when including TapLinx (line below) manually + compileOnly(files(NfcDependencies.nxp_nfc_android_aar_path)) + + // Test dependencies + androidTestImplementation(ComposeDependencies.compose_ui_test) + + androidTestImplementation(TestDependencies.junit) +} + +android { + compileSdk = 35 + defaultConfig { + minSdk = 21 + vectorDrawables { + useSupportLibrary = true + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + buildFeatures { + compose = true + buildConfig = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlinOptions { + jvmTarget = "17" + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + namespace = "org.robojackets.apiary.merchandise" + hilt { + enableExperimentalClasspathAggregation = true + } +} diff --git a/merchandise/consumer-rules.pro b/merchandise/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/merchandise/proguard-rules.pro b/merchandise/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/merchandise/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/merchandise/src/main/AndroidManifest.xml b/merchandise/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/merchandise/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt new file mode 100644 index 0000000..ac4f13d --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt @@ -0,0 +1,17 @@ +package org.robojackets.apiary.merchandise.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import org.robojackets.apiary.merchandise.network.MerchandiseApiService +import retrofit2.Retrofit + +@Module +@InstallIn(ActivityRetainedComponent::class) +object MerchandiseModule { + @Provides + fun providesMerchandiseApiService( + retrofit: Retrofit + ): MerchandiseApiService = retrofit.create(MerchandiseApiService::class.java) +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt new file mode 100644 index 0000000..166ee0a --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt @@ -0,0 +1,15 @@ +package org.robojackets.apiary.merchandise.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MerchandiseItemsHolder( + val merchandise: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class MerchandiseItem( + val id: Int, + val name: String, + // TODO: do the other fields matter? +) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt new file mode 100644 index 0000000..1819d0b --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -0,0 +1,109 @@ +package org.robojackets.apiary.merchandise.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.skydoves.sandwich.onError +import com.skydoves.sandwich.onException +import com.skydoves.sandwich.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.robojackets.apiary.merchandise.network.MerchandiseRepository +import org.robojackets.apiary.navigation.NavigationActions +import org.robojackets.apiary.navigation.NavigationManager +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class MerchandiseViewModel @Inject constructor( + val merchandiseRepository: MerchandiseRepository, + val navManager: NavigationManager +) : ViewModel() { + private val _state = MutableStateFlow(MerchandiseState()) + val state: StateFlow + get() = _state + + private val merchandiseItems = MutableStateFlow>(emptyList()) + private val loadingMerchandiseItems = MutableStateFlow(false) + private val error = MutableStateFlow(null) + private val selectedItem = MutableStateFlow(null) + + init { + viewModelScope.launch { + combine( + listOf( + merchandiseItems, + loadingMerchandiseItems, + error, + selectedItem, + ) + ) { + flows -> + MerchandiseState( + merchandiseItems = flows[0] as List, + loadingMerchandiseItems = flows[1] as Boolean, + error = flows[2] as String?, + selectedItem = flows[3] as MerchandiseItem?, + ) + } + .catch { throwable -> throw throwable } + .collect { _state.value = it } + } + } + + fun loadMerchandiseItems( + forceRefresh: Boolean = false, + selectedItemId: Int? = null + ) { + if (merchandiseItems.value.isNotEmpty() && !forceRefresh) { + Timber.d("Using cached merchandise items") + selectedItemId?.let { selectItemForDistribution(it) } + return + } + + loadingMerchandiseItems.value = true + viewModelScope.launch { + merchandiseRepository.listMerchandiseItems().onSuccess { + merchandiseItems.value = this.data.merchandise + selectedItemId?.let { selectItemForDistribution(it) } + loadingMerchandiseItems.value = false + }.onError { + Timber.e(this.toString(), "Could not fetch merchandise items due to an error") + error.value = "Unable to fetch merchandise items" + loadingMerchandiseItems.value = false + }.onException { + Timber.e(this.message, "Could not fetch merchandise items due to an exception") + error.value = "Unable to fetch merchandise items" + loadingMerchandiseItems.value = false + } + } + } + + fun navigateToMerchandiseItemDistribution(item: MerchandiseItem) { + navManager.navigate(NavigationActions.Merchandise.merchandiseIndexToDistribution(item.id)) + } + + fun navigateToMerchandiseIndex() { + navManager.navigate(NavigationActions.Merchandise.merchandiseDistributionToIndex()) + } + + private fun selectItemForDistribution(merchandiseItemId: Int) { + val item = merchandiseItems.value.find { it.id == merchandiseItemId } + if (item != null) { + selectedItem.value = item + } else { + error.value = "Could not find merchandise item with ID $merchandiseItemId" + Timber.e("Could not find merchandise item with ID $merchandiseItemId") + } + } +} + +data class MerchandiseState( + val merchandiseItems: List = emptyList(), + val loadingMerchandiseItems: Boolean = false, + val error: String? = null, + val selectedItem: MerchandiseItem? = null, +) \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt new file mode 100644 index 0000000..554b4e5 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt @@ -0,0 +1,10 @@ +package org.robojackets.apiary.merchandise.network + +import com.skydoves.sandwich.ApiResponse +import org.robojackets.apiary.merchandise.model.MerchandiseItemsHolder +import retrofit2.http.GET + +interface MerchandiseApiService { + @GET("/api/v1/merchandise") + suspend fun getMerchandiseItems(): ApiResponse +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt new file mode 100644 index 0000000..18d0b5c --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt @@ -0,0 +1,11 @@ +package org.robojackets.apiary.merchandise.network + +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +@ActivityRetainedScoped +class MerchandiseRepository @Inject constructor( + val merchandiseApiService: MerchandiseApiService, +) { + suspend fun listMerchandiseItems() = merchandiseApiService.getMerchandiseItems() +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt new file mode 100644 index 0000000..255f8bf --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -0,0 +1,100 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.foundation.layout.Arrangement +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.material.icons.Icons +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.robojackets.apiary.base.ui.util.ContentPadding +import org.robojackets.apiary.merchandise.model.MerchandiseItem +import org.robojackets.apiary.merchandise.model.MerchandiseViewModel + +@Composable +fun MerchandiseDistributionScreen( + viewModel: MerchandiseViewModel, + merchandiseItemId: Int, +) { + LaunchedEffect(merchandiseItemId) { + viewModel.loadMerchandiseItems(selectedItemId = merchandiseItemId) + } + + val state by viewModel.state.collectAsState() + + ContentPadding { + Column() { + Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) + when(state.selectedItem) { + null -> Text("No merchandise item selected") + else -> CurrentlySelectedItem( + item = state.selectedItem!!, + onChangeItem = { viewModel.navigateToMerchandiseIndex() } + ) + } + HorizontalDivider() + } + } +} + +@Composable +fun CurrentlySelectedItem( + item: MerchandiseItem, + onChangeItem: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.weight(1f, false) // In combination with the text field + // config below, fill=false keeps the Change button in view even when the + // selected merch item's name gets ellipsized. See https://stackoverflow.com/a/76758541 + ) { + Icon(Icons.Outlined.Storefront, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) + Text( + item.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + } + + TextButton(onClick = onChangeItem) { Text("Change") } + } +} + +@Preview +@Composable +fun MerchandiseDistributionScreenPreview() { + ContentPadding { + Column() { + Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) + CurrentlySelectedItem( + MerchandiseItem( + 2, + "Test item with a super duper long name so it will get cut off" + ) + ) {} + HorizontalDivider() + } + } +} + diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt new file mode 100644 index 0000000..a74b878 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt @@ -0,0 +1,47 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.robojackets.apiary.base.ui.util.ContentPadding +import org.robojackets.apiary.base.ui.util.LoadingSpinner +import org.robojackets.apiary.merchandise.model.MerchandiseViewModel + +@Composable +fun MerchandiseIndexScreen( + viewModel: MerchandiseViewModel, +) { + LaunchedEffect("merch") { + viewModel.loadMerchandiseItems() + } + + val state by viewModel.state.collectAsState() + + + // ContentPadding ensures the outer container fills the entire available width + height. + // This is important for navigation to avoid weird animations/effects when moving between + // screens, even with nav transition animations disabled. + ContentPadding { + Column { + when (state.loadingMerchandiseItems) { + true -> LoadingSpinner() + false -> MerchandiseItemSelection( + title = { + Text( + "Pick a merchandise item to distribute", + style = MaterialTheme.typography.headlineSmall + ) + }, + items = state.merchandiseItems, + onItemSelected = { + viewModel.navigateToMerchandiseItemDistribution(it) + } + ) + } + } + } +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt new file mode 100644 index 0000000..ef0c007 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt @@ -0,0 +1,33 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import org.robojackets.apiary.base.ui.form.ItemList +import org.robojackets.apiary.merchandise.model.MerchandiseItem + + +@Composable +fun MerchandiseItemSelection( + title: @Composable () -> Unit, + items: List, + onItemSelected: (item: MerchandiseItem) -> Unit, +) { + if (items.isEmpty()) { + // todo + return + } + + ItemList( + items = items, + onItemSelected = onItemSelected, + title = title, + postItem = { idx -> + if (idx < (items.size - 1)) { + HorizontalDivider() + } + }, + ) { + Text(it.name) + } +} diff --git a/navigation/src/main/java/org/robojackets/apiary/navigation/NavigationDirections.kt b/navigation/src/main/java/org/robojackets/apiary/navigation/NavigationDirections.kt index dd43110..ce90da3 100644 --- a/navigation/src/main/java/org/robojackets/apiary/navigation/NavigationDirections.kt +++ b/navigation/src/main/java/org/robojackets/apiary/navigation/NavigationDirections.kt @@ -14,6 +14,9 @@ object NavigationDestinations { const val optionalUpdatePrompt = "optionalUpdatePrompt" const val requiredUpdatePrompt = "requiredUpdatePrompt" const val updateInProgress = "updateInProgress" + const val merchandiseSubgraph = "merchandiseSubgraph" + const val merchandiseIndex = "merchandiseIndex" + const val merchandiseDistribution = "$merchandiseIndex/distribution" } object NavigationActions { @@ -88,4 +91,20 @@ object NavigationActions { .build() } } + + object Merchandise { + fun merchandiseIndexToDistribution( + merchandiseId: Int, + ) = object : NavigationAction { + override val destination = "${NavigationDestinations.merchandiseDistribution}/$merchandiseId" + } + + fun merchandiseDistributionToIndex() = object : NavigationAction { + override val destination = NavigationDestinations.merchandiseIndex + override val navOptions: NavOptions + get() = NavOptions.Builder() + .setPopUpTo(NavigationDestinations.merchandiseIndex, inclusive = true) + .build() + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index e8742c2..906ba93 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,3 +12,4 @@ include(":navigation") include(":auth") include(":attendance") include(":base") +include(":merchandise") From 9fe2f3d34187a1b46768d042e07fb7275a78a61c Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Sun, 21 Jul 2024 23:48:43 -0400 Subject: [PATCH 2/8] Continued API + UI work for merch --- .../org/robojackets/apiary/MainActivity.kt | 1 + .../apiary/base/model/BasicUser.kt | 6 + .../apiary/base/ui/dialog/DetailsDialog.kt | 39 +++ .../robojackets/apiary/base/ui/theme/Color.kt | 2 +- .../apiary/merchandise/model/Distribution.kt | 19 ++ .../merchandise/model/DistributionState.kt | 12 + .../MerchandiseDistributionScreenState.kt | 6 + .../merchandise/model/MerchandiseViewModel.kt | 25 ++ .../network/MerchandiseApiService.kt | 8 + .../network/MerchandiseRepository.kt | 1 + .../ui/ConfirmDistributionDialog.kt | 227 ++++++++++++++++++ .../merchandise/ui/CurrentlySelectedItem.kt | 66 +++++ .../merchandise/ui/MerchandiseDistribution.kt | 47 ++++ .../ui/MerchandiseDistributionScreen.kt | 87 +------ 14 files changed, 468 insertions(+), 78 deletions(-) create mode 100644 base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/DistributionState.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt diff --git a/app/src/main/java/org/robojackets/apiary/MainActivity.kt b/app/src/main/java/org/robojackets/apiary/MainActivity.kt index 8c4da04..3d9061b 100644 --- a/app/src/main/java/org/robojackets/apiary/MainActivity.kt +++ b/app/src/main/java/org/robojackets/apiary/MainActivity.kt @@ -342,6 +342,7 @@ class MainActivity : ComponentActivity() { MerchandiseDistributionScreen( hiltViewModel(), + nfcLib, merchandiseItemId as Int ) } diff --git a/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt index 8fc4960..59dc2fb 100644 --- a/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt +++ b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt @@ -10,3 +10,9 @@ data class BasicUser( val name: String, val preferred_first_name: String, ) + +@JsonClass(generateAdapter = true) +data class UserRef( + val id: Int, + val full_name: String, +) \ No newline at end of file diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt b/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt new file mode 100644 index 0000000..9fbea36 --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt @@ -0,0 +1,39 @@ +package org.robojackets.apiary.base.ui.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun DetailsDialog( + icon: @Composable () -> Unit, + iconContentColor: Color, + title: @Composable () -> Unit, + details: List<@Composable () -> Unit>, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + confirmButton: @Composable () -> Unit = {}, + dismissButton: @Composable () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + icon = icon, + iconContentColor = iconContentColor, + title = title, + text = { + Column { + details.map { + HorizontalDivider() + it() + } + HorizontalDivider() + } + }, + confirmButton = confirmButton, + dismissButton = dismissButton, + modifier = modifier, + ) +} \ No newline at end of file diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt b/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt index 281d309..b6b77ba 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt @@ -25,7 +25,7 @@ val warningLightMuted = Color(0x66D4A72C) // outline // Dark mode warning callout val warningDarkSubtle = Color(0x26BB8009) // bg val warningDarkMuted = Color(0x66BB8009) // outline -val success = Color(0xFF4CAF50) +val success = Color(0xFF36B92B) val webNavBarBackground = Color(0xFF343A40) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt new file mode 100644 index 0000000..aef132b --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt @@ -0,0 +1,19 @@ +package org.robojackets.apiary.merchandise.model + +import com.squareup.moshi.JsonClass +import org.robojackets.apiary.base.model.BasicUser +import org.robojackets.apiary.base.model.UserRef + +@JsonClass(generateAdapter = true) +data class DistributionHolder( + val merchandise: MerchandiseItem, + val user: BasicUser, + val distribution: Distribution, + val can_distribute: Boolean, +) + +@JsonClass(generateAdapter = true) +data class Distribution( + val id: Int, + val provided_by: UserRef, +) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/DistributionState.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/DistributionState.kt new file mode 100644 index 0000000..c176f7c --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/DistributionState.kt @@ -0,0 +1,12 @@ +package org.robojackets.apiary.merchandise.model + +import org.robojackets.apiary.base.model.BasicUser +import org.robojackets.apiary.base.ui.nfc.BuzzCardTap + +data class DistributionState( + val tap: BuzzCardTap, + val canDistribute: Boolean, + val user: BasicUser, + val name: String? = null, + val pastDistribution: Boolean = false, +) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt new file mode 100644 index 0000000..41a62a3 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt @@ -0,0 +1,6 @@ +package org.robojackets.apiary.merchandise.model + +enum class MerchandiseDistributionScreenState { + ReadyForTap, + Loading, +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index 1819d0b..c440bdd 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.skydoves.sandwich.onError import com.skydoves.sandwich.onException +import com.skydoves.sandwich.onFailure import com.skydoves.sandwich.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.robojackets.apiary.base.ui.nfc.BuzzCardTap import org.robojackets.apiary.merchandise.network.MerchandiseRepository import org.robojackets.apiary.navigation.NavigationActions import org.robojackets.apiary.navigation.NavigationManager @@ -99,6 +101,29 @@ class MerchandiseViewModel @Inject constructor( Timber.e("Could not find merchandise item with ID $merchandiseItemId") } } + + fun onBuzzCardTap(buzzCardTap: BuzzCardTap) { + Timber.d("onbuzzcardtap") + val selectedItemId = selectedItem.value?.id + + if (selectedItemId == null) { // FIXME + Timber.d("No merchandise item selected") + return + } + + viewModelScope.launch { + merchandiseRepository.getDistributionStatus(selectedItemId, buzzCardTap.gtid) + .onSuccess { + Timber.d("Successfully fetched distribution status") + Timber.d(this.data.toString()) + } + .onFailure { + Timber.e("Failed to fetch distribution status") + Timber.e(this.toString()) + } + } + + } } data class MerchandiseState( diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt index 554b4e5..58a27be 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt @@ -1,10 +1,18 @@ package org.robojackets.apiary.merchandise.network import com.skydoves.sandwich.ApiResponse +import org.robojackets.apiary.merchandise.model.DistributionHolder import org.robojackets.apiary.merchandise.model.MerchandiseItemsHolder import retrofit2.http.GET +import retrofit2.http.Path interface MerchandiseApiService { @GET("/api/v1/merchandise") suspend fun getMerchandiseItems(): ApiResponse + + @GET("/api/v1/merchandise/{itemId}/distribute/{gtid}") + suspend fun getDistributionStatus( + @Path("itemId") itemId: Int, + @Path("gtid") gtid: Int, + ): ApiResponse } \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt index 18d0b5c..62f31cd 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt @@ -8,4 +8,5 @@ class MerchandiseRepository @Inject constructor( val merchandiseApiService: MerchandiseApiService, ) { suspend fun listMerchandiseItems() = merchandiseApiService.getMerchandiseItems() + suspend fun getDistributionStatus(itemId: Int, gtid: Int) = merchandiseApiService.getDistributionStatus(itemId, gtid) } \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt new file mode 100644 index 0000000..ed4a19f --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt @@ -0,0 +1,227 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.TaskAlt +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.robojackets.apiary.base.ui.dialog.DetailsDialog +import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme +import org.robojackets.apiary.base.ui.theme.danger +import org.robojackets.apiary.base.ui.theme.success + +@Composable +fun ConfirmDistributionDialog( + isVisible: Boolean, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.TaskAlt, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = success, + title = { + Text("Confirm pickup?") + }, + details = listOf( + { DistributeTo("Kristaps Berzinch") }, + { ItemSizeInfo("Small", "S") } + ), + confirmButton = { + Button( + onClick = { + onConfirm() + onDismissRequest() + }, + // FIXME: Button is too bright in dark mode + colors = ButtonDefaults.buttonColors(containerColor = success, contentColor = Color.White) + ) { + Text("Mark picked up") + } + }, + dismissButton = { + TextButton( + onClick = onDismissRequest + ) { + Text("Cancel") + } + }, + modifier = Modifier.padding(0.dp) + ) +} + +@Composable +fun AlreadyPickedUpDialog( + isVisible: Boolean, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = danger, + title = { + Text("Item already picked up") + }, + details = listOf( + { DistributeTo("Kristaps Berzinch") }, + { ItemPickupInfo("Already distributed by Zach Slaton on Nov 9, 2022") } + ), + dismissButton = { + Button( + onClick = onDismissRequest + ) { + Text("Go back") + } + }, + modifier = Modifier.padding(0.dp) + ) +} + +@Composable +fun DistributionErrorDialog( + error: String, + isVisible: Boolean, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = danger, + title = { + Text("Ineligible for item") + }, + details = listOf( + { DistributeTo("Kristaps Berzinch") }, + { DistributionErrorDetails(error) } + ), + dismissButton = { + Button( + onClick = onDismissRequest + ) { + Text("Go back") + } + }, + modifier = Modifier.padding(0.dp) + ) +} + +@Composable +private fun DistributeTo(name: String) { + ListItem( + leadingContent = { + Icon(Icons.Outlined.AccountCircle, contentDescription = null) + }, + headlineContent = { + Text(name) + } + ) +} + +@Composable +private fun ItemSizeInfo(size: String, abbreviation: String) { + ListItem( + leadingContent = { + Text(abbreviation, style = MaterialTheme.typography.headlineSmall) + }, + headlineContent = { + Text(size) + } + ) +} + +// FIXME +@Composable +private fun ItemPickupInfo(details: String) { + ListItem( + leadingContent = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = "Past pickup info") + }, + headlineContent = { + Text(details) + } + ) +} + +// FIXME +@Composable +private fun DistributionErrorDetails(details: String) { + ListItem( + leadingContent = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = "Distribution error") + }, + headlineContent = { + Text(details) + } + ) +} + +@Preview +@Composable +fun ConfirmDistributionDialogPreview() { + Apiary_MobileTheme { + ConfirmDistributionDialog( + isVisible = true, + onConfirm = {}, + onDismissRequest = {}, + ) + } +} + +@Preview +@Composable +fun AlreadyPickedUpDialogPreview() { + Apiary_MobileTheme { + AlreadyPickedUpDialog( + isVisible = true, + onConfirm = {}, + onDismissRequest = {}, + ) + } +} + +@Preview +@Composable +fun NoPaidTransactionErrorDialogPreview() { + Apiary_MobileTheme { + DistributionErrorDialog( + isVisible = true, + onConfirm = {}, + onDismissRequest = {}, + error = "This person doesn't have a paid transaction for this item." + ) + } +} + +@Preview +@Composable +fun NotDistributableDialogPreview() { + Apiary_MobileTheme { + DistributionErrorDialog( + isVisible = true, + onConfirm = {}, + onDismissRequest = {}, + error = "This item cannot be distributed." + ) + } +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt new file mode 100644 index 0000000..24a1ccb --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt @@ -0,0 +1,66 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.robojackets.apiary.base.ui.util.ContentPadding +import org.robojackets.apiary.merchandise.model.MerchandiseItem + +@Composable +fun CurrentlySelectedItem( + item: MerchandiseItem, + onChangeItem: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.weight(1f, false) // In combination with the text field + // config below, fill=false keeps the Change button in view even when the + // selected merch item's name gets ellipsized. See https://stackoverflow.com/a/76758541 + ) { + Icon( + Icons.Outlined.Storefront, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp) + ) + Text( + item.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + } + + TextButton(onClick = onChangeItem) { Text("Change") } + } +} + +@Preview +@Composable +fun PreviewCurrentlySelectedItem() { + ContentPadding { + CurrentlySelectedItem( + MerchandiseItem( + 2, + "Test item with a super duper long name so it will get cut off" + ) + ) {} + } +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt new file mode 100644 index 0000000..a9e418a --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -0,0 +1,47 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.nxp.nfclib.NxpNfcLib +import org.robojackets.apiary.base.ui.nfc.BuzzCardPrompt +import org.robojackets.apiary.base.ui.nfc.BuzzCardTap +import org.robojackets.apiary.merchandise.model.MerchandiseState +import timber.log.Timber + +@Composable +fun MerchandiseDistribution( + state: MerchandiseState, + nfcLib: NxpNfcLib, + onBuzzcardTap: (buzzcardTap: BuzzCardTap) -> Unit, + onNavigateToMerchandiseIndex: () -> Unit, +) { + ConfirmDistributionDialog( + isVisible = true, + onConfirm = { }, + ) { } + Column() { + Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) + when(state.selectedItem) { + null -> Text("No merchandise item selected") + else -> CurrentlySelectedItem( + item = state.selectedItem, + onChangeItem = onNavigateToMerchandiseIndex + ) + } + HorizontalDivider() + + // TODO: the rest of the handling + BuzzCardPrompt( + hidePrompt = false, + nfcLib = nfcLib, + onBuzzCardTap = { + Timber.d("Buzzcard tapped: ${it.gtid}, ${it.source}") + onBuzzcardTap(it) + }, + externalError = null + ) + } +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt index 255f8bf..09826bf 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -1,33 +1,17 @@ package org.robojackets.apiary.merchandise.ui -import androidx.compose.foundation.layout.Arrangement -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.material.icons.Icons -import androidx.compose.material.icons.outlined.Storefront -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import com.nxp.nfclib.NxpNfcLib import org.robojackets.apiary.base.ui.util.ContentPadding -import org.robojackets.apiary.merchandise.model.MerchandiseItem import org.robojackets.apiary.merchandise.model.MerchandiseViewModel @Composable fun MerchandiseDistributionScreen( viewModel: MerchandiseViewModel, + nfcLib: NxpNfcLib, merchandiseItemId: Int, ) { LaunchedEffect(merchandiseItemId) { @@ -37,64 +21,13 @@ fun MerchandiseDistributionScreen( val state by viewModel.state.collectAsState() ContentPadding { - Column() { - Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) - when(state.selectedItem) { - null -> Text("No merchandise item selected") - else -> CurrentlySelectedItem( - item = state.selectedItem!!, - onChangeItem = { viewModel.navigateToMerchandiseIndex() } - ) - } - HorizontalDivider() - } + MerchandiseDistribution( + state = state, + nfcLib = nfcLib, + onBuzzcardTap = { + viewModel.onBuzzCardTap(it) + }, + onNavigateToMerchandiseIndex = { viewModel.navigateToMerchandiseIndex() } + ) } } - -@Composable -fun CurrentlySelectedItem( - item: MerchandiseItem, - onChangeItem: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - modifier = Modifier.weight(1f, false) // In combination with the text field - // config below, fill=false keeps the Change button in view even when the - // selected merch item's name gets ellipsized. See https://stackoverflow.com/a/76758541 - ) { - Icon(Icons.Outlined.Storefront, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) - Text( - item.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - } - - TextButton(onClick = onChangeItem) { Text("Change") } - } -} - -@Preview -@Composable -fun MerchandiseDistributionScreenPreview() { - ContentPadding { - Column() { - Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) - CurrentlySelectedItem( - MerchandiseItem( - 2, - "Test item with a super duper long name so it will get cut off" - ) - ) {} - HorizontalDivider() - } - } -} - From 5014a9cc8ba8733fec8d07ab8a0946ef21245963 Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Mon, 22 Jul 2024 23:57:59 -0400 Subject: [PATCH 3/8] Merch work: parse dates from API, start wiring together UI --- app/build.gradle.kts | 1 + .../apiary/di/MainActivityModule.kt | 3 + .../apiary/base/model/BasicUser.kt | 2 + .../apiary/base/model/ShirtSize.kt | 37 +++ .../apiary/base/ui/icons/ExtraIcons.kt | 39 +-- .../res/drawable/ic_outline_apparel_24dp.xml | 9 + buildSrc/src/main/java/Dependencies.kt | 1 + .../apiary/merchandise/model/Distribution.kt | 4 +- .../MerchandiseDistributionScreenState.kt | 4 +- .../merchandise/model/MerchandiseViewModel.kt | 22 ++ .../ui/ConfirmDistributionDialog.kt | 227 ------------------ .../merchandise/ui/MerchandiseDistribution.kt | 32 ++- .../ui/MerchandiseDistributionScreen.kt | 4 +- .../ui/pickup_dialog/AlreadyPickedUpDialog.kt | 56 +++++ .../ui/pickup_dialog/ConfirmPickupDialog.kt | 62 +++++ .../ui/pickup_dialog/DistributeTo.kt | 20 ++ .../pickup_dialog/DistributionErrorDetails.kt | 20 ++ .../pickup_dialog/DistributionErrorDialog.kt | 43 ++++ .../ui/pickup_dialog/ItemPickupInfo.kt | 20 ++ .../ui/pickup_dialog/PickupDialogPreviews.kt | 64 +++++ .../ui/pickup_dialog/ShirtSizeInfo.kt | 18 ++ 21 files changed, 435 insertions(+), 253 deletions(-) create mode 100644 base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt create mode 100644 base/src/main/res/drawable/ic_outline_apparel_24dp.xml delete mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0093c5e..7bb6a67 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(MaterialDependencies.material_android) + implementation(NetworkDependencies.moshi_adapters) implementation(NetworkDependencies.moshi_converter_factory) implementation(NetworkDependencies.okhttp) implementation(platform(NetworkDependencies.okhttp_bom)) diff --git a/app/src/main/java/org/robojackets/apiary/di/MainActivityModule.kt b/app/src/main/java/org/robojackets/apiary/di/MainActivityModule.kt index 1acf3b5..72fcefd 100644 --- a/app/src/main/java/org/robojackets/apiary/di/MainActivityModule.kt +++ b/app/src/main/java/org/robojackets/apiary/di/MainActivityModule.kt @@ -5,6 +5,7 @@ import com.nxp.nfclib.NxpNfcLib import com.skydoves.sandwich.retrofit.adapters.ApiResponseCallAdapterFactory import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,6 +26,7 @@ import org.robojackets.apiary.base.service.ServerInfoApiService import org.robojackets.apiary.network.UserAgentInterceptor import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.Date @Module @InstallIn(ActivityRetainedComponent::class) @@ -68,6 +70,7 @@ object MainActivityModule { Types.newParameterizedType(List::class.java, Permission::class.java), SkipNotFoundEnumInEnumListAdapter(Permission::class.java) ) + .add(Date::class.java, Rfc3339DateJsonAdapter()) .build() } diff --git a/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt index 59dc2fb..a99e7c4 100644 --- a/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt +++ b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt @@ -9,6 +9,8 @@ data class BasicUser( val uid: String, val name: String, val preferred_first_name: String, + val shirt_size: ShirtSize?, + val polo_size: ShirtSize?, ) @JsonClass(generateAdapter = true) diff --git a/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt b/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt new file mode 100644 index 0000000..29a1159 --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt @@ -0,0 +1,37 @@ +package org.robojackets.apiary.base.model + +import com.squareup.moshi.Json +import java.util.Locale + +enum class ShirtSize { + @Json(name = "s") + SMALL, + + @Json(name = "m") + MEDIUM, + + @Json(name = "l") + LARGE, + + @Json(name = "xl") + EXTRA_LARGE, + + @Json(name = "xxl") + XXL, + + @Json(name = "xxxl") + XXXL; + + override fun toString(): String { + return when (name) { + "EXTRA_LARGE" -> "Extra Large" + "XXL" -> "XXL" + "XXXL" -> "XXXL" + else -> name + .lowercase(Locale.getDefault()) + .replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + } + } +} \ No newline at end of file diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/icons/ExtraIcons.kt b/base/src/main/java/org/robojackets/apiary/base/ui/icons/ExtraIcons.kt index baf992f..12f300e 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/icons/ExtraIcons.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/icons/ExtraIcons.kt @@ -13,65 +13,65 @@ import org.robojackets.apiary.base.R @Composable fun ContactlessIcon( modifier: Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( painter = painterResource(id = R.drawable.ic_outline_contactless_24dp), contentDescription = "NFC symbol", modifier = modifier, - tint = tint + tint = tint, ) } @Composable fun WarningIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( Icons.Default.Warning, tint = tint, modifier = modifier, - contentDescription = "warning" + contentDescription = "warning", ) } @Composable fun ErrorIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_error_outline_24), tint = tint, modifier = modifier, - contentDescription = "error" + contentDescription = "error", ) } @Composable fun CreditCardIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_credit_card_24), tint = tint, modifier = modifier, - contentDescription = "credit card" + contentDescription = "credit card", ) } @Composable fun PendingIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( painter = painterResource(id = R.drawable.ic_outline_pending_24dp), tint = tint, modifier = modifier, - contentDescription = "pending" + contentDescription = "pending", ) } @@ -84,45 +84,46 @@ fun GroupsIcon( painter = painterResource(id = R.drawable.ic_outline_groups_24dp), tint = tint, modifier = modifier, - contentDescription = "groups" + contentDescription = "groups", ) } @Composable fun EventIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( painter = painterResource(id = R.drawable.ic_outline_event_24dp), tint = tint, modifier = modifier, - contentDescription = "event" + contentDescription = "event", ) } @Composable fun UpdateIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_update_24), tint = tint, modifier = modifier, - contentDescription = "update" + contentDescription = "update", ) } @Composable -fun TaskAltIcon( +fun ApparelIcon( modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface + tint: Color = MaterialTheme.colorScheme.onSurface, + contentDescription: String = "apparel", ) { Icon( - painter = painterResource(id = R.drawable.ic_outline_task_alt_24dp), + painter = painterResource(id = R.drawable.ic_outline_apparel_24dp), tint = tint, modifier = modifier, - contentDescription = "checkmark" + contentDescription = contentDescription, ) } diff --git a/base/src/main/res/drawable/ic_outline_apparel_24dp.xml b/base/src/main/res/drawable/ic_outline_apparel_24dp.xml new file mode 100644 index 0000000..d3954b3 --- /dev/null +++ b/base/src/main/res/drawable/ic_outline_apparel_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 6ddc2fd..9e6afe8 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -136,6 +136,7 @@ object NetworkDependencies { } const val moshi = "com.squareup.moshi:moshi:${Versions.moshi_version}" + const val moshi_adapters = "com.squareup.moshi:moshi-adapters:${Versions.moshi_version}" const val moshi_converter_factory = "com.squareup.retrofit2:converter-moshi:${Versions.moshi_converter_factory_version}" const val moshi_kotlin_codegen = "com.squareup.moshi:moshi-kotlin-codegen:${Versions.moshi_version}" diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt index aef132b..70ddf12 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt @@ -3,6 +3,7 @@ package org.robojackets.apiary.merchandise.model import com.squareup.moshi.JsonClass import org.robojackets.apiary.base.model.BasicUser import org.robojackets.apiary.base.model.UserRef +import java.util.Date @JsonClass(generateAdapter = true) data class DistributionHolder( @@ -15,5 +16,6 @@ data class DistributionHolder( @JsonClass(generateAdapter = true) data class Distribution( val id: Int, - val provided_by: UserRef, + val provided_by: UserRef?, + val provided_at: Date?, ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt index 41a62a3..2e95124 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt @@ -2,5 +2,7 @@ package org.robojackets.apiary.merchandise.model enum class MerchandiseDistributionScreenState { ReadyForTap, - Loading, + LoadingDistributionStatus, + Error, + ShowStatusDialog, } \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index c440bdd..9a870e3 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -32,6 +32,8 @@ class MerchandiseViewModel @Inject constructor( private val loadingMerchandiseItems = MutableStateFlow(false) private val error = MutableStateFlow(null) private val selectedItem = MutableStateFlow(null) + private val screenState = MutableStateFlow(MerchandiseDistributionScreenState.ReadyForTap) + private val lastDistributionStatus: MutableStateFlow = MutableStateFlow(null) init { viewModelScope.launch { @@ -41,6 +43,8 @@ class MerchandiseViewModel @Inject constructor( loadingMerchandiseItems, error, selectedItem, + screenState, + lastDistributionStatus, ) ) { flows -> @@ -49,6 +53,8 @@ class MerchandiseViewModel @Inject constructor( loadingMerchandiseItems = flows[1] as Boolean, error = flows[2] as String?, selectedItem = flows[3] as MerchandiseItem?, + screenState = flows[4] as MerchandiseDistributionScreenState, + lastDistributionStatus = flows[5] as DistributionHolder?, ) } .catch { throwable -> throw throwable } @@ -111,18 +117,32 @@ class MerchandiseViewModel @Inject constructor( return } + screenState.value = MerchandiseDistributionScreenState.LoadingDistributionStatus + viewModelScope.launch { merchandiseRepository.getDistributionStatus(selectedItemId, buzzCardTap.gtid) .onSuccess { Timber.d("Successfully fetched distribution status") Timber.d(this.data.toString()) + lastDistributionStatus.value = this.data + screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog } .onFailure { Timber.e("Failed to fetch distribution status") Timber.e(this.toString()) + screenState.value = MerchandiseDistributionScreenState.ReadyForTap + error.value = "Failed to fetch distribution status" } } + } + + fun confirmPickup() { + // FIXME + screenState.value = MerchandiseDistributionScreenState.ReadyForTap + } + fun dismissPickupDialog() { + screenState.value = MerchandiseDistributionScreenState.ReadyForTap } } @@ -131,4 +151,6 @@ data class MerchandiseState( val loadingMerchandiseItems: Boolean = false, val error: String? = null, val selectedItem: MerchandiseItem? = null, + val screenState: MerchandiseDistributionScreenState = MerchandiseDistributionScreenState.ReadyForTap, + val lastDistributionStatus: DistributionHolder? = null, ) \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt deleted file mode 100644 index ed4a19f..0000000 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/ConfirmDistributionDialog.kt +++ /dev/null @@ -1,227 +0,0 @@ -package org.robojackets.apiary.merchandise.ui - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AccountCircle -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.TaskAlt -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.robojackets.apiary.base.ui.dialog.DetailsDialog -import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme -import org.robojackets.apiary.base.ui.theme.danger -import org.robojackets.apiary.base.ui.theme.success - -@Composable -fun ConfirmDistributionDialog( - isVisible: Boolean, - onConfirm: () -> Unit, - onDismissRequest: () -> Unit, -) { - DetailsDialog( - onDismissRequest = onDismissRequest, - icon = { - Icon(Icons.Outlined.TaskAlt, contentDescription = null, Modifier.size(40.dp)) - }, - iconContentColor = success, - title = { - Text("Confirm pickup?") - }, - details = listOf( - { DistributeTo("Kristaps Berzinch") }, - { ItemSizeInfo("Small", "S") } - ), - confirmButton = { - Button( - onClick = { - onConfirm() - onDismissRequest() - }, - // FIXME: Button is too bright in dark mode - colors = ButtonDefaults.buttonColors(containerColor = success, contentColor = Color.White) - ) { - Text("Mark picked up") - } - }, - dismissButton = { - TextButton( - onClick = onDismissRequest - ) { - Text("Cancel") - } - }, - modifier = Modifier.padding(0.dp) - ) -} - -@Composable -fun AlreadyPickedUpDialog( - isVisible: Boolean, - onConfirm: () -> Unit, - onDismissRequest: () -> Unit, -) { - DetailsDialog( - onDismissRequest = onDismissRequest, - icon = { - Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) - }, - iconContentColor = danger, - title = { - Text("Item already picked up") - }, - details = listOf( - { DistributeTo("Kristaps Berzinch") }, - { ItemPickupInfo("Already distributed by Zach Slaton on Nov 9, 2022") } - ), - dismissButton = { - Button( - onClick = onDismissRequest - ) { - Text("Go back") - } - }, - modifier = Modifier.padding(0.dp) - ) -} - -@Composable -fun DistributionErrorDialog( - error: String, - isVisible: Boolean, - onConfirm: () -> Unit, - onDismissRequest: () -> Unit, -) { - DetailsDialog( - onDismissRequest = onDismissRequest, - icon = { - Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) - }, - iconContentColor = danger, - title = { - Text("Ineligible for item") - }, - details = listOf( - { DistributeTo("Kristaps Berzinch") }, - { DistributionErrorDetails(error) } - ), - dismissButton = { - Button( - onClick = onDismissRequest - ) { - Text("Go back") - } - }, - modifier = Modifier.padding(0.dp) - ) -} - -@Composable -private fun DistributeTo(name: String) { - ListItem( - leadingContent = { - Icon(Icons.Outlined.AccountCircle, contentDescription = null) - }, - headlineContent = { - Text(name) - } - ) -} - -@Composable -private fun ItemSizeInfo(size: String, abbreviation: String) { - ListItem( - leadingContent = { - Text(abbreviation, style = MaterialTheme.typography.headlineSmall) - }, - headlineContent = { - Text(size) - } - ) -} - -// FIXME -@Composable -private fun ItemPickupInfo(details: String) { - ListItem( - leadingContent = { - Icon(Icons.Outlined.ErrorOutline, contentDescription = "Past pickup info") - }, - headlineContent = { - Text(details) - } - ) -} - -// FIXME -@Composable -private fun DistributionErrorDetails(details: String) { - ListItem( - leadingContent = { - Icon(Icons.Outlined.ErrorOutline, contentDescription = "Distribution error") - }, - headlineContent = { - Text(details) - } - ) -} - -@Preview -@Composable -fun ConfirmDistributionDialogPreview() { - Apiary_MobileTheme { - ConfirmDistributionDialog( - isVisible = true, - onConfirm = {}, - onDismissRequest = {}, - ) - } -} - -@Preview -@Composable -fun AlreadyPickedUpDialogPreview() { - Apiary_MobileTheme { - AlreadyPickedUpDialog( - isVisible = true, - onConfirm = {}, - onDismissRequest = {}, - ) - } -} - -@Preview -@Composable -fun NoPaidTransactionErrorDialogPreview() { - Apiary_MobileTheme { - DistributionErrorDialog( - isVisible = true, - onConfirm = {}, - onDismissRequest = {}, - error = "This person doesn't have a paid transaction for this item." - ) - } -} - -@Preview -@Composable -fun NotDistributableDialogPreview() { - Apiary_MobileTheme { - DistributionErrorDialog( - isVisible = true, - onConfirm = {}, - onDismissRequest = {}, - error = "This item cannot be distributed." - ) - } -} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt index a9e418a..5531956 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -8,7 +8,10 @@ import androidx.compose.runtime.Composable import com.nxp.nfclib.NxpNfcLib import org.robojackets.apiary.base.ui.nfc.BuzzCardPrompt import org.robojackets.apiary.base.ui.nfc.BuzzCardTap +import org.robojackets.apiary.merchandise.model.MerchandiseDistributionScreenState import org.robojackets.apiary.merchandise.model.MerchandiseState +import org.robojackets.apiary.merchandise.ui.pickup_dialog.AlreadyPickedUpDialog +import org.robojackets.apiary.merchandise.ui.pickup_dialog.ConfirmPickupDialog import timber.log.Timber @Composable @@ -16,12 +19,33 @@ fun MerchandiseDistribution( state: MerchandiseState, nfcLib: NxpNfcLib, onBuzzcardTap: (buzzcardTap: BuzzCardTap) -> Unit, + onConfirmPickup: () -> Unit, + onDismissPickupDialog: () -> Unit, onNavigateToMerchandiseIndex: () -> Unit, ) { - ConfirmDistributionDialog( - isVisible = true, - onConfirm = { }, - ) { } + when(state.screenState) { + MerchandiseDistributionScreenState.ShowStatusDialog -> { + if (state.lastDistributionStatus != null) { + if (state.lastDistributionStatus.can_distribute) { + ConfirmPickupDialog( + userFullName = state.lastDistributionStatus.user.name, + userShirtSize = state.lastDistributionStatus.user.shirt_size!!, // FIXME + onConfirm = { onConfirmPickup() }, + onDismissRequest = { onDismissPickupDialog() } + ) + } else if (!state.lastDistributionStatus.can_distribute) { + AlreadyPickedUpDialog( + distributeTo = state.lastDistributionStatus.user, + providedBy = state.lastDistributionStatus.distribution.provided_by!!, // FIXME: Remove these !!s + providedAt = state.lastDistributionStatus.distribution.provided_at!!.toInstant(), + onDismissRequest = onDismissPickupDialog + ) + } + } + } + else -> {} + } + Column() { Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) when(state.selectedItem) { diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt index 09826bf..3d1e47c 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -27,7 +27,9 @@ fun MerchandiseDistributionScreen( onBuzzcardTap = { viewModel.onBuzzCardTap(it) }, - onNavigateToMerchandiseIndex = { viewModel.navigateToMerchandiseIndex() } + onNavigateToMerchandiseIndex = { viewModel.navigateToMerchandiseIndex() }, + onConfirmPickup = { viewModel.confirmPickup() }, + onDismissPickupDialog = { viewModel.dismissPickupDialog() }, ) } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt new file mode 100644 index 0000000..887b93b --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt @@ -0,0 +1,56 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.robojackets.apiary.base.model.BasicUser +import org.robojackets.apiary.base.model.UserRef +import org.robojackets.apiary.base.ui.dialog.DetailsDialog +import org.robojackets.apiary.base.ui.theme.danger +import java.time.Instant +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import java.util.TimeZone + + +@Composable +fun AlreadyPickedUpDialog( + distributeTo: BasicUser, + providedBy: UserRef, + providedAt: Instant, + onDismissRequest: () -> Unit, +) { + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + val localProvidedAt = LocalDateTime.ofInstant(providedAt, TimeZone.getDefault().toZoneId()) + + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = danger, + title = { + Text("Item already picked up") + }, + details = listOf( + { DistributeTo(distributeTo.name) }, + { ItemPickupInfo("Distributed by ${providedBy.full_name} on ${localProvidedAt.format(dateFormatter)}") } + ), + dismissButton = { + Button( + onClick = onDismissRequest) { + Text("Go back") + } + }, + modifier = Modifier.padding(0.dp) + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt new file mode 100644 index 0000000..31cbf45 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt @@ -0,0 +1,62 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.TaskAlt +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.robojackets.apiary.base.model.ShirtSize +import org.robojackets.apiary.base.ui.dialog.DetailsDialog +import org.robojackets.apiary.base.ui.theme.success + +@Composable +fun ConfirmPickupDialog( + userFullName: String, + userShirtSize: ShirtSize, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.TaskAlt, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = success, + title = { + Text("Confirm pickup?") + }, + details = listOf( + { DistributeTo(userFullName) }, + { ShirtSizeInfo(userShirtSize.toString()) } + ), + confirmButton = { + Button( + onClick = { + onConfirm() + onDismissRequest() + }, + // FIXME: Button is too bright in dark mode + colors = ButtonDefaults.buttonColors(containerColor = success, contentColor = Color.White) + ) { + Text("Mark picked up") + } + }, + dismissButton = { + TextButton( + onClick = onDismissRequest + ) { + Text("Cancel") + } + }, + modifier = Modifier.padding(0.dp) + ) +} + diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt new file mode 100644 index 0000000..78ea4c8 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt @@ -0,0 +1,20 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun DistributeTo(name: String) { + ListItem( + leadingContent = { + Icon(Icons.Outlined.AccountCircle, contentDescription = "Distribute to") + }, + headlineContent = { + Text(name) + } + ) +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt new file mode 100644 index 0000000..73f12c5 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt @@ -0,0 +1,20 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun DistributionErrorDetails(details: String) { + ListItem( + leadingContent = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = "Distribution error") + }, + headlineContent = { + Text(details) + } + ) +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt new file mode 100644 index 0000000..8ea608c --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt @@ -0,0 +1,43 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.robojackets.apiary.base.ui.dialog.DetailsDialog +import org.robojackets.apiary.base.ui.theme.danger + +@Composable +fun DistributionErrorDialog( + error: String, + onDismissRequest: () -> Unit, +) { + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = danger, + title = { + Text("Ineligible for item") + }, + details = listOf( + { DistributeTo("Kristaps Berzinch") }, + { DistributionErrorDetails(error) } + ), + dismissButton = { + Button( + onClick = onDismissRequest + ) { + Text("Go back") + } + }, + modifier = Modifier.padding(0.dp) + ) +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt new file mode 100644 index 0000000..01970f3 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt @@ -0,0 +1,20 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun ItemPickupInfo(details: String) { + ListItem( + leadingContent = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = "Past pickup info") + }, + headlineContent = { + Text(details) + } + ) +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt new file mode 100644 index 0000000..b2d51b8 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt @@ -0,0 +1,64 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import org.robojackets.apiary.base.model.BasicUser +import org.robojackets.apiary.base.model.ShirtSize +import org.robojackets.apiary.base.model.UserRef +import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme +import java.time.Instant + +@Preview +@Composable +fun ConfirmDistributionDialogPreview() { + Apiary_MobileTheme { + ConfirmPickupDialog( + userFullName = "Kristaps Berzinch", + userShirtSize = ShirtSize.SMALL, + onConfirm = {}, + onDismissRequest = {}, + ) + } +} + +@Preview +@Composable +fun NoPaidTransactionErrorDialogPreview() { + Apiary_MobileTheme { + DistributionErrorDialog( + onDismissRequest = {}, + error = "This person doesn't have a paid transaction for this item." + ) + } +} + +@Preview +@Composable +fun NotDistributableDialogPreview() { + Apiary_MobileTheme { + DistributionErrorDialog( + onDismissRequest = {}, + error = "This item cannot be distributed." + ) + } +} + +@Preview +@Composable +fun AlreadyPickedUpDialogPreview() { + Apiary_MobileTheme { + AlreadyPickedUpDialog( + distributeTo = BasicUser( + id = 18, + uid = "gburdell3", + name = "George Burdell", + preferred_first_name = "George", + shirt_size = ShirtSize.SMALL, + polo_size = ShirtSize.SMALL, + ), + onDismissRequest = {}, + providedBy = UserRef(full_name = "Zach Slaton", id = 3), + providedAt = Instant.now() + ) + } +} \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt new file mode 100644 index 0000000..2bb4e61 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt @@ -0,0 +1,18 @@ +package org.robojackets.apiary.merchandise.ui.pickup_dialog + +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import org.robojackets.apiary.base.ui.icons.ApparelIcon + +@Composable +fun ShirtSizeInfo(sizeName: String) { + ListItem( + leadingContent = { + ApparelIcon(contentDescription = "Shirt size") + }, + headlineContent = { + Text(sizeName) + } + ) +} \ No newline at end of file From 6afd95022822ef9c68ebd9ad4c255746fbdb53a9 Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Sun, 28 Jul 2024 23:34:42 -0400 Subject: [PATCH 4/8] More merch progress --- base/build.gradle.kts | 2 + .../apiary/base/model/ApiErrorMessage.kt | 9 ++ .../apiary/base/ui/nfc/BuzzCardPrompt.kt | 97 +++++++++++-------- .../apiary/base/ui/nfc/BuzzCardTapSource.kt | 1 + build.gradle.kts | 2 + buildSrc/src/main/java/Dependencies.kt | 2 + merchandise/build.gradle.kts | 2 + .../apiary/merchandise/model/Distribution.kt | 1 + .../apiary/merchandise/model/Merchandise.kt | 1 - .../MerchandiseDistributionScreenState.kt | 1 + .../merchandise/model/MerchandiseSize.kt | 9 ++ .../merchandise/model/MerchandiseViewModel.kt | 96 ++++++++++++++++-- .../network/MerchandiseApiService.kt | 13 ++- .../network/MerchandiseRepository.kt | 1 + .../merchandise/ui/MerchandiseDistribution.kt | 8 +- .../ui/pickup_dialog/ConfirmPickupDialog.kt | 12 ++- .../pickup_dialog/DistributionErrorDialog.kt | 5 +- .../ui/pickup_dialog/PickupDialogPreviews.kt | 13 +-- 18 files changed, 211 insertions(+), 64 deletions(-) create mode 100644 base/src/main/java/org/robojackets/apiary/base/model/ApiErrorMessage.kt create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 81c6e58..16661d1 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("com.google.devtools.ksp") id("dagger.hilt.android.plugin") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") + kotlin("plugin.serialization") version "2.0.0" } dependencies { @@ -27,6 +28,7 @@ dependencies { implementation(MaterialDependencies.material_android) + implementation(NetworkDependencies.kotlinx_serialization_json) implementation(NetworkDependencies.moshi) ksp(NetworkDependencies.moshi_kotlin_codegen) implementation(NetworkDependencies.retrofit) diff --git a/base/src/main/java/org/robojackets/apiary/base/model/ApiErrorMessage.kt b/base/src/main/java/org/robojackets/apiary/base/model/ApiErrorMessage.kt new file mode 100644 index 0000000..252028c --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/model/ApiErrorMessage.kt @@ -0,0 +1,9 @@ +package org.robojackets.apiary.base.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiErrorMessage( + val status: String?, + val message: String?, +) diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt index 1ca0477..c3c73ce 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt @@ -11,23 +11,37 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.nxp.nfclib.CardType import com.nxp.nfclib.NxpNfcLib import com.nxp.nfclib.desfire.DESFireFactory import com.nxp.nfclib.exceptions.NxpNfcLibException +import kotlinx.coroutines.android.awaitFrame +import org.robojackets.apiary.base.BuildConfig import org.robojackets.apiary.base.ui.ActionPrompt import org.robojackets.apiary.base.ui.IconWithText import org.robojackets.apiary.base.ui.icons.ContactlessIcon import org.robojackets.apiary.base.ui.icons.CreditCardIcon import org.robojackets.apiary.base.ui.icons.ErrorIcon import org.robojackets.apiary.base.ui.icons.WarningIcon -import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.* -import org.robojackets.apiary.base.ui.nfc.BuzzCardTapSource.* +import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.InvalidBuzzCardData +import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.NotABuzzCard +import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.TagLost +import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.UnknownNfcError +import org.robojackets.apiary.base.ui.nfc.BuzzCardTapSource.Debug +import org.robojackets.apiary.base.ui.nfc.BuzzCardTapSource.Keyboard import org.robojackets.apiary.base.ui.theme.danger import timber.log.Timber import java.nio.charset.StandardCharsets @@ -55,6 +69,7 @@ fun BuzzCardPrompt( externalError: BuzzCardPromptExternalError?, ) { var error by remember { mutableStateOf(null) } + var lastTap by remember { mutableStateOf(null) } val nfcPresenceDelayCheckMs = 50 // the minimum number of ms allowed between successive NFC // tag reads. Lower is better, but too low seems to cause an increase in NFC read errors when // tapping many BuzzCards as quickly as possible @@ -104,7 +119,9 @@ fun BuzzCardPrompt( } error = null - onBuzzCardTap(BuzzCardTap(gtid.toInt())) + val buzzCardTap = BuzzCardTap(gtid.toInt()) + lastTap = buzzCardTap + onBuzzCardTap(buzzCardTap) } else { Timber.i("Unknown card type ($cardType) presented") error = NotABuzzCard @@ -153,12 +170,38 @@ fun BuzzCardPrompt( ) { Text("Enter GTID manually") } + + if (BuildConfig.DEBUG) { + when(lastTap) { + null -> { + Button( + onClick = { + onBuzzCardTap(BuzzCardTap(BuildConfig.localGTID.toInt(), source = Debug)) + }, + Modifier.align(CenterHorizontally) + ) { + Text("Tap ${BuildConfig.localGTID.toInt()} again") + } + } + else -> { + Button( + onClick = { + onBuzzCardTap(BuzzCardTap(lastTap!!.gtid, source = Debug)) + }, + Modifier.align(CenterHorizontally) + ) { + Text("Tap ${lastTap?.gtid ?: "unknown GTID"} again") + } + } + } + } } } if (showGtidPrompt) { ManualGtidEntryPrompt( onGtidEntered = { + lastTap = it onBuzzCardTap(it) error = null }, @@ -174,6 +217,13 @@ fun ManualGtidEntryPrompt( onGtidEntered: (entry: BuzzCardTap) -> Unit, ) { var gtid by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(focusRequester) { + awaitFrame() + focusRequester.requestFocus() + } + AlertDialog( onDismissRequest = { gtid = "" @@ -209,7 +259,9 @@ fun ManualGtidEntryPrompt( label = { Text("GTID") }, singleLine = true, isError = gtid.isNotEmpty() && !GTID_REGEX.matches(gtid), - modifier = Modifier.padding(top = 14.dp), + modifier = Modifier.padding(top = 14.dp) + .focusRequester(focusRequester), // Focuses this field and shows the keyboard + // when this text field is visible on screen. See https://stackoverflow.com/a/76321706 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), leadingIcon = { CreditCardIcon() } ) @@ -263,14 +315,6 @@ fun NfcInvalidBuzzCardDataError() { } } -@Composable -fun NfcInvalidTypedGtidError() { - ActionPrompt( - icon = { ContactlessIcon(Modifier.size(114.dp), tint = danger) }, - title = "Invalid typed GTID", - ) -} - @Composable fun NfcReadUnknownError() { ActionPrompt( @@ -291,30 +335,3 @@ fun ExternalError(externalError: BuzzCardPromptExternalError) { subtitle = externalError.message, ) } - -// Unused for now but useful once NFC disabled support is added back -// @Composable -// fun NfcUnsupportedError() { -// ActionPrompt( -// icon = { ErrorIcon(Modifier.size(114.dp), tint = danger) }, -// title = "NFC is unavailable", -// subtitle = "Your device cannot read BuzzCards because it does not support NFC", -// ) { -// IconWithText(icon = { WarningIcon(tint = danger) }, text = "NFC adapter was null") -// } -// } -// -// @Composable -// fun NfcDisabledError() { -// ActionPrompt( -// icon = { ContactlessIcon(Modifier.size(114.dp), tint = danger) }, -// title = "NFC is disabled", -// subtitle = "", -// ) { -// Button(onClick = { -// -// }) { -// Text(text = "Enable NFC") -// } -// } -// } diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardTapSource.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardTapSource.kt index 2a6e0b4..a9d9c13 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardTapSource.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardTapSource.kt @@ -3,4 +3,5 @@ package org.robojackets.apiary.base.ui.nfc enum class BuzzCardTapSource { Nfc, Keyboard, + Debug, } diff --git a/build.gradle.kts b/build.gradle.kts index 4e4923e..d6ece75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,8 @@ plugins { id("io.gitlab.arturbosch.detekt").version("1.23.0") id("com.autonomousapps.dependency-analysis").version("1.21.0") id("com.github.ben-manes.versions").version("0.46.0") + id("com.android.library") version "8.5.1" apply false + id("org.jetbrains.kotlin.android") version "1.9.0" apply false } tasks.withType().configureEach { diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 9e6afe8..ad849b9 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -129,12 +129,14 @@ object NetworkDependencies { object Versions { const val moshi_version = "1.15.1" const val moshi_converter_factory_version = "2.11.0" + const val kotlinx_serialization_json_version = "1.6.3" const val okhttp_bom_version = "4.12.0" const val retrofit_version = "2.11.0" const val retrofuture_version = "1.7.4" const val sandwich_version = "2.0.8" } + const val kotlinx_serialization_json = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.kotlinx_serialization_json_version}" const val moshi = "com.squareup.moshi:moshi:${Versions.moshi_version}" const val moshi_adapters = "com.squareup.moshi:moshi-adapters:${Versions.moshi_version}" const val moshi_converter_factory = diff --git a/merchandise/build.gradle.kts b/merchandise/build.gradle.kts index 4b7b8c9..8b5e845 100644 --- a/merchandise/build.gradle.kts +++ b/merchandise/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("com.google.devtools.ksp") id("dagger.hilt.android.plugin") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") + kotlin("plugin.serialization") version "2.0.0" } dependencies { @@ -30,6 +31,7 @@ dependencies { implementation(ComposeDependencies.compose_material_icons_core) implementation(ComposeDependencies.compose_material_icons_extended) + implementation(NetworkDependencies.kotlinx_serialization_json) implementation(NetworkDependencies.moshi) ksp(NetworkDependencies.moshi_kotlin_codegen) implementation(NetworkDependencies.okhttp) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt index 70ddf12..0796334 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt @@ -18,4 +18,5 @@ data class Distribution( val id: Int, val provided_by: UserRef?, val provided_at: Date?, + val size: MerchandiseSize?, ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt index 166ee0a..63da67c 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt @@ -11,5 +11,4 @@ data class MerchandiseItemsHolder( data class MerchandiseItem( val id: Int, val name: String, - // TODO: do the other fields matter? ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt index 2e95124..898e0c8 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt @@ -4,5 +4,6 @@ enum class MerchandiseDistributionScreenState { ReadyForTap, LoadingDistributionStatus, Error, + SavingPickupStatus, ShowStatusDialog, } \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt new file mode 100644 index 0000000..0ea5f04 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt @@ -0,0 +1,9 @@ +package org.robojackets.apiary.merchandise.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MerchandiseSize( + val short: String?, + val display_name: String?, +) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index 9a870e3..54fdcb4 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -2,16 +2,19 @@ package org.robojackets.apiary.merchandise.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.skydoves.sandwich.StatusCode import com.skydoves.sandwich.onError import com.skydoves.sandwich.onException -import com.skydoves.sandwich.onFailure import com.skydoves.sandwich.onSuccess +import com.skydoves.sandwich.retrofit.serialization.deserializeErrorBody +import com.skydoves.sandwich.retrofit.statusCode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.robojackets.apiary.base.model.ApiErrorMessage import org.robojackets.apiary.base.ui.nfc.BuzzCardTap import org.robojackets.apiary.merchandise.network.MerchandiseRepository import org.robojackets.apiary.navigation.NavigationActions @@ -34,6 +37,7 @@ class MerchandiseViewModel @Inject constructor( private val selectedItem = MutableStateFlow(null) private val screenState = MutableStateFlow(MerchandiseDistributionScreenState.ReadyForTap) private val lastDistributionStatus: MutableStateFlow = MutableStateFlow(null) + private val lastAcceptedBuzzCardTap: MutableStateFlow = MutableStateFlow(null) init { viewModelScope.launch { @@ -45,6 +49,7 @@ class MerchandiseViewModel @Inject constructor( selectedItem, screenState, lastDistributionStatus, + lastAcceptedBuzzCardTap, ) ) { flows -> @@ -55,6 +60,7 @@ class MerchandiseViewModel @Inject constructor( selectedItem = flows[3] as MerchandiseItem?, screenState = flows[4] as MerchandiseDistributionScreenState, lastDistributionStatus = flows[5] as DistributionHolder?, + lastAcceptedBuzzCardTap = flows[6] as BuzzCardTap?, ) } .catch { throwable -> throw throwable } @@ -83,7 +89,7 @@ class MerchandiseViewModel @Inject constructor( error.value = "Unable to fetch merchandise items" loadingMerchandiseItems.value = false }.onException { - Timber.e(this.message, "Could not fetch merchandise items due to an exception") + Timber.e(this.throwable, "Could not fetch merchandise items due to an exception") error.value = "Unable to fetch merchandise items" loadingMerchandiseItems.value = false } @@ -109,6 +115,8 @@ class MerchandiseViewModel @Inject constructor( } fun onBuzzCardTap(buzzCardTap: BuzzCardTap) { + // TODO: What happens if this is called while the screen is in the wrong state? + Timber.d("onbuzzcardtap") val selectedItemId = selectedItem.value?.id @@ -117,6 +125,11 @@ class MerchandiseViewModel @Inject constructor( return } + if (screenState.value != MerchandiseDistributionScreenState.ReadyForTap) { + Timber.d("Screen state is not ready for tap") + } + + lastAcceptedBuzzCardTap.value = buzzCardTap screenState.value = MerchandiseDistributionScreenState.LoadingDistributionStatus viewModelScope.launch { @@ -127,18 +140,84 @@ class MerchandiseViewModel @Inject constructor( lastDistributionStatus.value = this.data screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog } - .onFailure { - Timber.e("Failed to fetch distribution status") - Timber.e(this.toString()) - screenState.value = MerchandiseDistributionScreenState.ReadyForTap - error.value = "Failed to fetch distribution status" + .onError { + // `this.errorBody` can only be consumed once. If you add a log statement + // including it, then the deserializeErrorBody call will fail + var errorModel: ApiErrorMessage? = null + try { + // Sandwich docs on deserializing errors: https://skydoves.github.io/sandwich/retrofit/#error-body-deserialization + // where A is the return type of the outer API call (getDistributionStatus) + // and B is the type to parse the error body as + // If Android Studio is constantly suggesting to import the deserializeErrorBody + // method, or you get build errors like "None of the following candidates is + // applicable because of receiver type mismatch," it's probably because + // you're specifying the wrong type for A + errorModel = this.deserializeErrorBody() + } catch (e: Exception) { + Timber.e(e, "Could not deserialize error body") + } + Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") + screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog + + error.value = when { + this.statusCode == StatusCode.NotFound && errorModel?.status == null -> { + "No user found for this GTID" + } + else -> { + errorModel?.message ?: "Failed to fetch distribution status" + } + } } } } fun confirmPickup() { // FIXME - screenState.value = MerchandiseDistributionScreenState.ReadyForTap + screenState.value = MerchandiseDistributionScreenState.SavingPickupStatus + viewModelScope.launch { + // FIXME: nested lets is so gross + selectedItem.value?.let { itemId -> + lastAcceptedBuzzCardTap.value?.let { buzzCardTap -> + merchandiseRepository.distributeItem( + itemId = itemId.id, + gtid = buzzCardTap.gtid, + providedVia = "MyRoboJackets Android - ${buzzCardTap.source}" // FIXME: figure out how to get tap source here + ).onSuccess { + screenState.value = MerchandiseDistributionScreenState.ReadyForTap + }.onError { // TODO: Reduce code duplication + // TODO: If distribution fails, what should we do? + // TODO: Implement loading state while saving distribution + // `this.errorBody` can only be consumed once. If you add a log statement + // including it, then the deserializeErrorBody call will fail + var errorModel: ApiErrorMessage? = null + try { + // Sandwich docs on deserializing errors: https://skydoves.github.io/sandwich/retrofit/#error-body-deserialization + // where A is the return type of the outer API call (getDistributionStatus) + // and B is the type to parse the error body as + // If Android Studio is constantly suggesting to import the deserializeErrorBody + // method, or you get build errors like "None of the following candidates is + // applicable because of receiver type mismatch," it's probably because + // you're specifying the wrong type for A + errorModel = this.deserializeErrorBody() + } catch (e: Exception) { + Timber.e(e, "Could not deserialize error body") + } + Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") + screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog + + error.value = when { + this.statusCode == StatusCode.NotFound && errorModel?.status == null -> { + "No user found for this GTID" + } + + else -> { + errorModel?.message ?: "Failed to fetch distribution status" + } + } + } + } + } + } } fun dismissPickupDialog() { @@ -153,4 +232,5 @@ data class MerchandiseState( val selectedItem: MerchandiseItem? = null, val screenState: MerchandiseDistributionScreenState = MerchandiseDistributionScreenState.ReadyForTap, val lastDistributionStatus: DistributionHolder? = null, + val lastAcceptedBuzzCardTap: BuzzCardTap? = null, ) \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt index 58a27be..f626cd2 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt @@ -3,7 +3,10 @@ package org.robojackets.apiary.merchandise.network import com.skydoves.sandwich.ApiResponse import org.robojackets.apiary.merchandise.model.DistributionHolder import org.robojackets.apiary.merchandise.model.MerchandiseItemsHolder +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Path interface MerchandiseApiService { @@ -15,4 +18,12 @@ interface MerchandiseApiService { @Path("itemId") itemId: Int, @Path("gtid") gtid: Int, ): ApiResponse -} \ No newline at end of file + + @FormUrlEncoded + @POST("/api/v1/merchandise/{itemId}/distribute/{gtid}") + suspend fun distributeItem( + @Path("itemId") itemId: Int, + @Path("gtid") gtid: Int, + @Field("provided_via") provided_via: String, + ): ApiResponse +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt index 62f31cd..a437639 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt @@ -9,4 +9,5 @@ class MerchandiseRepository @Inject constructor( ) { suspend fun listMerchandiseItems() = merchandiseApiService.getMerchandiseItems() suspend fun getDistributionStatus(itemId: Int, gtid: Int) = merchandiseApiService.getDistributionStatus(itemId, gtid) + suspend fun distributeItem(itemId: Int, gtid: Int, providedVia: String) = merchandiseApiService.distributeItem(itemId, gtid, providedVia) } \ No newline at end of file diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt index 5531956..23b8b89 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -12,6 +12,7 @@ import org.robojackets.apiary.merchandise.model.MerchandiseDistributionScreenSta import org.robojackets.apiary.merchandise.model.MerchandiseState import org.robojackets.apiary.merchandise.ui.pickup_dialog.AlreadyPickedUpDialog import org.robojackets.apiary.merchandise.ui.pickup_dialog.ConfirmPickupDialog +import org.robojackets.apiary.merchandise.ui.pickup_dialog.DistributionErrorDialog import timber.log.Timber @Composable @@ -29,7 +30,7 @@ fun MerchandiseDistribution( if (state.lastDistributionStatus.can_distribute) { ConfirmPickupDialog( userFullName = state.lastDistributionStatus.user.name, - userShirtSize = state.lastDistributionStatus.user.shirt_size!!, // FIXME + userShirtSize = state.lastDistributionStatus.distribution.size, // FIXME onConfirm = { onConfirmPickup() }, onDismissRequest = { onDismissPickupDialog() } ) @@ -41,6 +42,11 @@ fun MerchandiseDistribution( onDismissRequest = onDismissPickupDialog ) } + } else if (state.error != null) { + DistributionErrorDialog( + error = state.error, + onDismissRequest = onDismissPickupDialog + ) } } else -> {} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt index 31cbf45..f83b713 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt @@ -13,14 +13,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import org.robojackets.apiary.base.model.ShirtSize import org.robojackets.apiary.base.ui.dialog.DetailsDialog import org.robojackets.apiary.base.ui.theme.success +import org.robojackets.apiary.merchandise.model.MerchandiseSize @Composable fun ConfirmPickupDialog( userFullName: String, - userShirtSize: ShirtSize, + userShirtSize: MerchandiseSize?, onConfirm: () -> Unit, onDismissRequest: () -> Unit, ) { @@ -35,7 +35,13 @@ fun ConfirmPickupDialog( }, details = listOf( { DistributeTo(userFullName) }, - { ShirtSizeInfo(userShirtSize.toString()) } + { + when { + userShirtSize?.display_name != null && userShirtSize.short != null -> { + ShirtSizeInfo(userShirtSize.display_name) + } // FIXME if backend logic changes + } + } ), confirmButton = { Button( diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt index 8ea608c..a89c3b8 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt @@ -27,10 +27,7 @@ fun DistributionErrorDialog( title = { Text("Ineligible for item") }, - details = listOf( - { DistributeTo("Kristaps Berzinch") }, - { DistributionErrorDetails(error) } - ), + details = listOf { DistributionErrorDetails(error) }, dismissButton = { Button( onClick = onDismissRequest diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt index b2d51b8..0408b35 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt @@ -6,6 +6,7 @@ import org.robojackets.apiary.base.model.BasicUser import org.robojackets.apiary.base.model.ShirtSize import org.robojackets.apiary.base.model.UserRef import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme +import org.robojackets.apiary.merchandise.model.MerchandiseSize import java.time.Instant @Preview @@ -13,8 +14,8 @@ import java.time.Instant fun ConfirmDistributionDialogPreview() { Apiary_MobileTheme { ConfirmPickupDialog( - userFullName = "Kristaps Berzinch", - userShirtSize = ShirtSize.SMALL, + userFullName = "George Burdell", + userShirtSize = MerchandiseSize("s", "Small"), onConfirm = {}, onDismissRequest = {}, ) @@ -26,8 +27,8 @@ fun ConfirmDistributionDialogPreview() { fun NoPaidTransactionErrorDialogPreview() { Apiary_MobileTheme { DistributionErrorDialog( - onDismissRequest = {}, - error = "This person doesn't have a paid transaction for this item." + error = "This person doesn't have a paid transaction for this item.", + onDismissRequest = {} ) } } @@ -37,8 +38,8 @@ fun NoPaidTransactionErrorDialogPreview() { fun NotDistributableDialogPreview() { Apiary_MobileTheme { DistributionErrorDialog( - onDismissRequest = {}, - error = "This item cannot be distributed." + error = "This item cannot be distributed.", + onDismissRequest = {} ) } } From 126f6feaac2ab4da01961735b993e591aa5335d2 Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Sun, 4 Aug 2024 23:43:22 -0400 Subject: [PATCH 5/8] So much merch work --- .../apiary/base/model/BasicUser.kt | 3 +- .../apiary/base/model/ShirtSize.kt | 5 +- .../apiary/base/ui/dialog/DetailsDialog.kt | 2 +- .../apiary/base/ui/form/ItemList.kt | 2 +- .../apiary/base/ui/nfc/BuzzCardPrompt.kt | 25 ++- .../robojackets/apiary/base/ui/theme/Color.kt | 2 - buildSrc/src/main/java/Dependencies.kt | 3 +- ci/detekt/detekt.yml | 2 +- .../merchandise/di/MerchandiseModule.kt | 2 +- .../apiary/merchandise/model/Distribution.kt | 12 +- .../MerchandiseDistributionScreenState.kt | 6 +- .../merchandise/model/MerchandiseSize.kt | 6 +- .../merchandise/model/MerchandiseViewModel.kt | 183 +++++++++--------- .../merchandise/model/StorePickupStatus.kt | 8 + .../network/MerchandiseApiService.kt | 2 +- .../network/MerchandiseRepository.kt | 8 +- .../merchandise/ui/CurrentlySelectedItem.kt | 3 +- .../merchandise/ui/MerchandiseDistribution.kt | 93 ++++++--- .../ui/MerchandiseDistributionScreen.kt | 36 +++- .../merchandise/ui/MerchandiseIndexScreen.kt | 18 +- .../ui/MerchandiseItemSelection.kt | 38 ++-- .../AlreadyPickedUpDialog.kt | 6 +- .../ConfirmPickupDialog.kt | 11 +- .../DistributeTo.kt | 4 +- .../DistributionErrorDetails.kt | 4 +- .../DistributionErrorDialog.kt | 9 +- .../ItemPickupInfo.kt | 4 +- .../ItemSizeInfo.kt} | 8 +- .../PickupDialogPreviews.kt | 4 +- 29 files changed, 307 insertions(+), 202 deletions(-) create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/model/StorePickupStatus.kt rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/AlreadyPickedUpDialog.kt (93%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/ConfirmPickupDialog.kt (83%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/DistributeTo.kt (89%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/DistributionErrorDetails.kt (89%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/DistributionErrorDialog.kt (87%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/ItemPickupInfo.kt (89%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog/ShirtSizeInfo.kt => pickupdialog/ItemSizeInfo.kt} (66%) rename merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/{pickup_dialog => pickupdialog}/PickupDialogPreviews.kt (96%) diff --git a/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt index a99e7c4..546e11c 100644 --- a/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt +++ b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt @@ -13,8 +13,9 @@ data class BasicUser( val polo_size: ShirtSize?, ) +@Suppress("ConstructorParameterNaming") @JsonClass(generateAdapter = true) data class UserRef( val id: Int, val full_name: String, -) \ No newline at end of file +) diff --git a/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt b/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt index 29a1159..111b91d 100644 --- a/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt +++ b/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt @@ -27,11 +27,12 @@ enum class ShirtSize { "EXTRA_LARGE" -> "Extra Large" "XXL" -> "XXL" "XXXL" -> "XXXL" - else -> name + else -> + name .lowercase(Locale.getDefault()) .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } } } -} \ No newline at end of file +} diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt b/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt index 9fbea36..add2b5d 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/dialog/DetailsDialog.kt @@ -36,4 +36,4 @@ fun DetailsDialog( dismissButton = dismissButton, modifier = modifier, ) -} \ No newline at end of file +} diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt index d72fc7f..c1d7aa9 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt @@ -34,4 +34,4 @@ fun ItemList( } } } -} \ No newline at end of file +} diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt index c3c73ce..70ef4b7 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt @@ -60,6 +60,7 @@ import java.nio.charset.StandardCharsets */ val GTID_REGEX = Regex("90[0-9]{7}") const val GTID_LENGTH = 9 + @Suppress("MagicNumber", "LongMethod", "ComplexMethod") @Composable fun BuzzCardPrompt( @@ -151,8 +152,8 @@ fun BuzzCardPrompt( } var showGtidPrompt by remember { mutableStateOf(false) } - Column { - if (!hidePrompt) { + if (!hidePrompt) { + Column { if (externalError != null) { ExternalError(externalError) } else { @@ -172,17 +173,23 @@ fun BuzzCardPrompt( } if (BuildConfig.DEBUG) { - when(lastTap) { + when (lastTap) { null -> { Button( onClick = { - onBuzzCardTap(BuzzCardTap(BuildConfig.localGTID.toInt(), source = Debug)) + onBuzzCardTap( + BuzzCardTap( + BuildConfig.localGTID.toInt(), + source = Debug + ) + ) }, Modifier.align(CenterHorizontally) ) { Text("Tap ${BuildConfig.localGTID.toInt()} again") } } + else -> { Button( onClick = { @@ -240,7 +247,12 @@ fun ManualGtidEntryPrompt( Text("Submit") } }, - title = { Text(text = "Manual GTID entry", style = MaterialTheme.typography.headlineSmall) }, + title = { + Text( + text = "Manual GTID entry", + style = MaterialTheme.typography.headlineSmall + ) + }, text = { Column { Text("Type the entire 9-digit GTID, starting with 90") @@ -259,7 +271,8 @@ fun ManualGtidEntryPrompt( label = { Text("GTID") }, singleLine = true, isError = gtid.isNotEmpty() && !GTID_REGEX.matches(gtid), - modifier = Modifier.padding(top = 14.dp) + modifier = Modifier + .padding(top = 14.dp) .focusRequester(focusRequester), // Focuses this field and shows the keyboard // when this text field is visible on screen. See https://stackoverflow.com/a/76321706 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt b/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt index b6b77ba..ab7076f 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/theme/Color.kt @@ -15,7 +15,6 @@ val danger = Color(0xFFB00020) // Warning colors val warningLightSubtle = Color(0xFFFFF8C5) -val warning = warningLightSubtle val warningLightEmphasis = Color(0xFFBF8700) // Light mode warning callout @@ -24,7 +23,6 @@ val warningLightMuted = Color(0x66D4A72C) // outline // Dark mode warning callout val warningDarkSubtle = Color(0x26BB8009) // bg -val warningDarkMuted = Color(0x66BB8009) // outline val success = Color(0xFF36B92B) val webNavBarBackground = Color(0xFF343A40) diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index ad849b9..e9195d2 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -136,7 +136,8 @@ object NetworkDependencies { const val sandwich_version = "2.0.8" } - const val kotlinx_serialization_json = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.kotlinx_serialization_json_version}" + const val kotlinx_serialization_json = + "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.kotlinx_serialization_json_version}" const val moshi = "com.squareup.moshi:moshi:${Versions.moshi_version}" const val moshi_adapters = "com.squareup.moshi:moshi-adapters:${Versions.moshi_version}" const val moshi_converter_factory = diff --git a/ci/detekt/detekt.yml b/ci/detekt/detekt.yml index c2b990c..dacb3c0 100644 --- a/ci/detekt/detekt.yml +++ b/ci/detekt/detekt.yml @@ -113,7 +113,7 @@ complexity: ignoreAnnotated: [] LongParameterList: active: true - functionThreshold: 6 + functionThreshold: 9 constructorThreshold: 7 ignoreDefaultParameters: false ignoreDataClasses: true diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt index ac4f13d..fd4b721 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/di/MerchandiseModule.kt @@ -14,4 +14,4 @@ object MerchandiseModule { fun providesMerchandiseApiService( retrofit: Retrofit ): MerchandiseApiService = retrofit.create(MerchandiseApiService::class.java) -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt index 0796334..05624a7 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt @@ -1,22 +1,28 @@ package org.robojackets.apiary.merchandise.model +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import org.robojackets.apiary.base.model.BasicUser import org.robojackets.apiary.base.model.UserRef import java.util.Date +@Suppress("ConstructorParameterNaming") @JsonClass(generateAdapter = true) data class DistributionHolder( val merchandise: MerchandiseItem, val user: BasicUser, val distribution: Distribution, - val can_distribute: Boolean, + @Json(name = "can_distribute") + val canDistribute: Boolean, ) +@Suppress("ConstructorParameterNaming") @JsonClass(generateAdapter = true) data class Distribution( val id: Int, - val provided_by: UserRef?, - val provided_at: Date?, + @Json(name = "provided_by") + val providedBy: UserRef?, + @Json(name = "provided_at") + val providedAt: Date?, val size: MerchandiseSize?, ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt index 898e0c8..54271e2 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt @@ -3,7 +3,7 @@ package org.robojackets.apiary.merchandise.model enum class MerchandiseDistributionScreenState { ReadyForTap, LoadingDistributionStatus, - Error, SavingPickupStatus, - ShowStatusDialog, -} \ No newline at end of file + ShowPickupStatusDialog, + ShowDistributionErrorDialog, +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt index 0ea5f04..bd80f1c 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt @@ -1,9 +1,11 @@ package org.robojackets.apiary.merchandise.model +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class MerchandiseSize( - val short: String?, - val display_name: String?, + val short: String, + @Json(name = "display_name") + val displayName: String, ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index 54fdcb4..6b613ac 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -2,12 +2,10 @@ package org.robojackets.apiary.merchandise.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.skydoves.sandwich.StatusCode import com.skydoves.sandwich.onError import com.skydoves.sandwich.onException import com.skydoves.sandwich.onSuccess import com.skydoves.sandwich.retrofit.serialization.deserializeErrorBody -import com.skydoves.sandwich.retrofit.statusCode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -25,19 +23,22 @@ import javax.inject.Inject @HiltViewModel class MerchandiseViewModel @Inject constructor( val merchandiseRepository: MerchandiseRepository, - val navManager: NavigationManager + val navManager: NavigationManager, ) : ViewModel() { private val _state = MutableStateFlow(MerchandiseState()) val state: StateFlow get() = _state - private val merchandiseItems = MutableStateFlow>(emptyList()) + private val merchandiseItems = MutableStateFlow?>(null) private val loadingMerchandiseItems = MutableStateFlow(false) + private val merchandiseItemsListError = MutableStateFlow(null) private val error = MutableStateFlow(null) private val selectedItem = MutableStateFlow(null) private val screenState = MutableStateFlow(MerchandiseDistributionScreenState.ReadyForTap) - private val lastDistributionStatus: MutableStateFlow = MutableStateFlow(null) + private val lastDistributionStatus: MutableStateFlow = + MutableStateFlow(null) private val lastAcceptedBuzzCardTap: MutableStateFlow = MutableStateFlow(null) + private val lastStorePickupStatus: MutableStateFlow = MutableStateFlow(null) init { viewModelScope.launch { @@ -50,29 +51,33 @@ class MerchandiseViewModel @Inject constructor( screenState, lastDistributionStatus, lastAcceptedBuzzCardTap, + lastStorePickupStatus, + merchandiseItemsListError, + ) + ) { flows -> + MerchandiseState( + merchandiseItems = flows[0] as List?, + loadingMerchandiseItems = flows[1] as Boolean, + error = flows[2] as String?, + selectedItem = flows[3] as MerchandiseItem?, + screenState = flows[4] as MerchandiseDistributionScreenState, + lastDistributionStatus = flows[5] as DistributionHolder?, + lastAcceptedBuzzCardTap = flows[6] as BuzzCardTap?, + lastStorePickupStatus = flows[7] as StorePickupStatus?, + merchandiseItemsListError = flows[8] as String?, ) - ) { - flows -> - MerchandiseState( - merchandiseItems = flows[0] as List, - loadingMerchandiseItems = flows[1] as Boolean, - error = flows[2] as String?, - selectedItem = flows[3] as MerchandiseItem?, - screenState = flows[4] as MerchandiseDistributionScreenState, - lastDistributionStatus = flows[5] as DistributionHolder?, - lastAcceptedBuzzCardTap = flows[6] as BuzzCardTap?, - ) } - .catch { throwable -> throw throwable } - .collect { _state.value = it } + .catch { throwable -> throw throwable } + .collect { _state.value = it } } } fun loadMerchandiseItems( forceRefresh: Boolean = false, - selectedItemId: Int? = null + selectedItemId: Int? = null, ) { - if (merchandiseItems.value.isNotEmpty() && !forceRefresh) { + merchandiseItemsListError.value = null + if (merchandiseItems.value?.isNotEmpty() == true && !forceRefresh) { Timber.d("Using cached merchandise items") selectedItemId?.let { selectItemForDistribution(it) } return @@ -86,11 +91,11 @@ class MerchandiseViewModel @Inject constructor( loadingMerchandiseItems.value = false }.onError { Timber.e(this.toString(), "Could not fetch merchandise items due to an error") - error.value = "Unable to fetch merchandise items" + merchandiseItemsListError.value = "Could not load available merchandise items" loadingMerchandiseItems.value = false }.onException { Timber.e(this.throwable, "Could not fetch merchandise items due to an exception") - error.value = "Unable to fetch merchandise items" + merchandiseItemsListError.value = "Could not load available merchandise items" loadingMerchandiseItems.value = false } } @@ -105,7 +110,7 @@ class MerchandiseViewModel @Inject constructor( } private fun selectItemForDistribution(merchandiseItemId: Int) { - val item = merchandiseItems.value.find { it.id == merchandiseItemId } + val item = merchandiseItems.value?.find { it.id == merchandiseItemId } if (item != null) { selectedItem.value = item } else { @@ -114,23 +119,27 @@ class MerchandiseViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun onBuzzCardTap(buzzCardTap: BuzzCardTap) { - // TODO: What happens if this is called while the screen is in the wrong state? + if (screenState.value != MerchandiseDistributionScreenState.ReadyForTap) { + Timber.d("onBuzzCardTap: Screen state is not ready for tap, ignoring") + return + } + + screenState.value = MerchandiseDistributionScreenState.LoadingDistributionStatus - Timber.d("onbuzzcardtap") val selectedItemId = selectedItem.value?.id - if (selectedItemId == null) { // FIXME - Timber.d("No merchandise item selected") + if (selectedItemId == null) { + error.value = "No merchandise item selected" + Timber.e("onBuzzCardTap called with no merchandise item selected") return } - if (screenState.value != MerchandiseDistributionScreenState.ReadyForTap) { - Timber.d("Screen state is not ready for tap") - } - + error.value = null + lastStorePickupStatus.value = null + lastDistributionStatus.value = null lastAcceptedBuzzCardTap.value = buzzCardTap - screenState.value = MerchandiseDistributionScreenState.LoadingDistributionStatus viewModelScope.launch { merchandiseRepository.getDistributionStatus(selectedItemId, buzzCardTap.gtid) @@ -138,7 +147,7 @@ class MerchandiseViewModel @Inject constructor( Timber.d("Successfully fetched distribution status") Timber.d(this.data.toString()) lastDistributionStatus.value = this.data - screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog + screenState.value = MerchandiseDistributionScreenState.ShowPickupStatusDialog } .onError { // `this.errorBody` can only be consumed once. If you add a log statement @@ -152,70 +161,68 @@ class MerchandiseViewModel @Inject constructor( // method, or you get build errors like "None of the following candidates is // applicable because of receiver type mismatch," it's probably because // you're specifying the wrong type for A - errorModel = this.deserializeErrorBody() + errorModel = + this.deserializeErrorBody() } catch (e: Exception) { Timber.e(e, "Could not deserialize error body") } Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") - screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog - - error.value = when { - this.statusCode == StatusCode.NotFound && errorModel?.status == null -> { - "No user found for this GTID" - } - else -> { - errorModel?.message ?: "Failed to fetch distribution status" - } - } + screenState.value = MerchandiseDistributionScreenState.ShowPickupStatusDialog + + error.value = errorModel?.message ?: "Failed to fetch distribution status" + }.onException { + Timber.e(this.throwable, "Failed to fetch distribution status due to an exception") + error.value = "Failed to fetch distribution status" + screenState.value = MerchandiseDistributionScreenState.ShowPickupStatusDialog } } } + @Suppress("TooGenericExceptionCaught") fun confirmPickup() { - // FIXME screenState.value = MerchandiseDistributionScreenState.SavingPickupStatus viewModelScope.launch { - // FIXME: nested lets is so gross - selectedItem.value?.let { itemId -> - lastAcceptedBuzzCardTap.value?.let { buzzCardTap -> - merchandiseRepository.distributeItem( - itemId = itemId.id, - gtid = buzzCardTap.gtid, - providedVia = "MyRoboJackets Android - ${buzzCardTap.source}" // FIXME: figure out how to get tap source here - ).onSuccess { - screenState.value = MerchandiseDistributionScreenState.ReadyForTap - }.onError { // TODO: Reduce code duplication - // TODO: If distribution fails, what should we do? - // TODO: Implement loading state while saving distribution - // `this.errorBody` can only be consumed once. If you add a log statement - // including it, then the deserializeErrorBody call will fail - var errorModel: ApiErrorMessage? = null - try { - // Sandwich docs on deserializing errors: https://skydoves.github.io/sandwich/retrofit/#error-body-deserialization - // where A is the return type of the outer API call (getDistributionStatus) - // and B is the type to parse the error body as - // If Android Studio is constantly suggesting to import the deserializeErrorBody - // method, or you get build errors like "None of the following candidates is - // applicable because of receiver type mismatch," it's probably because - // you're specifying the wrong type for A - errorModel = this.deserializeErrorBody() - } catch (e: Exception) { - Timber.e(e, "Could not deserialize error body") - } - Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") - screenState.value = MerchandiseDistributionScreenState.ShowStatusDialog - - error.value = when { - this.statusCode == StatusCode.NotFound && errorModel?.status == null -> { - "No user found for this GTID" - } - - else -> { - errorModel?.message ?: "Failed to fetch distribution status" - } - } - } + val selectedItem = selectedItem.value + val lastAcceptedBuzzCardTap = lastAcceptedBuzzCardTap.value + + if (selectedItem == null) { + error.value = "No merchandise item selected" + Timber.e("No merchandise item selected") + return@launch + } + + if (lastAcceptedBuzzCardTap == null) { + error.value = "BuzzCard data for pickup was not found" + Timber.e("Last BuzzCardTap is null") + return@launch + } + + merchandiseRepository.distributeItem( + itemId = selectedItem.id, + gtid = lastAcceptedBuzzCardTap.gtid, + providedVia = "MyRoboJackets Android - ${lastAcceptedBuzzCardTap.source}" + ).onSuccess { + screenState.value = MerchandiseDistributionScreenState.ReadyForTap + lastStorePickupStatus.value = StorePickupStatus( + error = null, + user = this.data.user + ) + }.onError { + // `this.errorBody` can only be consumed once. If you add a log statement + // including it, then the deserializeErrorBody call will fail + var errorModel: ApiErrorMessage? = null + try { + errorModel = this.deserializeErrorBody() + } catch (e: Exception) { + Timber.e(e, "Could not deserialize error body") } + Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") + error.value = errorModel?.message ?: "Unable to record merchandise distribution" + screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog + }.onException { + Timber.e(this.throwable, "Unable to record merchandise distribution due to an exception") + error.value = "Unable to record merchandise distribution" + screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog } } } @@ -226,11 +233,13 @@ class MerchandiseViewModel @Inject constructor( } data class MerchandiseState( - val merchandiseItems: List = emptyList(), + val merchandiseItems: List? = null, val loadingMerchandiseItems: Boolean = false, val error: String? = null, val selectedItem: MerchandiseItem? = null, val screenState: MerchandiseDistributionScreenState = MerchandiseDistributionScreenState.ReadyForTap, val lastDistributionStatus: DistributionHolder? = null, val lastAcceptedBuzzCardTap: BuzzCardTap? = null, -) \ No newline at end of file + val lastStorePickupStatus: StorePickupStatus? = null, + val merchandiseItemsListError: String? = null, +) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/StorePickupStatus.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/StorePickupStatus.kt new file mode 100644 index 0000000..16a8af2 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/StorePickupStatus.kt @@ -0,0 +1,8 @@ +package org.robojackets.apiary.merchandise.model + +import org.robojackets.apiary.base.model.BasicUser + +data class StorePickupStatus( + val error: String?, + val user: BasicUser, +) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt index f626cd2..d360f17 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt @@ -24,6 +24,6 @@ interface MerchandiseApiService { suspend fun distributeItem( @Path("itemId") itemId: Int, @Path("gtid") gtid: Int, - @Field("provided_via") provided_via: String, + @Field("provided_via") providedVia: String, ): ApiResponse } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt index a437639..d524b9f 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt @@ -8,6 +8,8 @@ class MerchandiseRepository @Inject constructor( val merchandiseApiService: MerchandiseApiService, ) { suspend fun listMerchandiseItems() = merchandiseApiService.getMerchandiseItems() - suspend fun getDistributionStatus(itemId: Int, gtid: Int) = merchandiseApiService.getDistributionStatus(itemId, gtid) - suspend fun distributeItem(itemId: Int, gtid: Int, providedVia: String) = merchandiseApiService.distributeItem(itemId, gtid, providedVia) -} \ No newline at end of file + suspend fun getDistributionStatus(itemId: Int, gtid: Int) = + merchandiseApiService.getDistributionStatus(itemId, gtid) + suspend fun distributeItem(itemId: Int, gtid: Int, providedVia: String) = + merchandiseApiService.distributeItem(itemId, gtid, providedVia) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt index 24a1ccb..46de252 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt @@ -45,7 +45,6 @@ fun CurrentlySelectedItem( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } TextButton(onClick = onChangeItem) { Text("Change") } @@ -63,4 +62,4 @@ fun PreviewCurrentlySelectedItem() { ) ) {} } -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt index 23b8b89..8f0d6e3 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -1,20 +1,28 @@ package org.robojackets.apiary.merchandise.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.nxp.nfclib.NxpNfcLib +import org.robojackets.apiary.base.ui.ActionPrompt +import org.robojackets.apiary.base.ui.icons.PendingIcon import org.robojackets.apiary.base.ui.nfc.BuzzCardPrompt import org.robojackets.apiary.base.ui.nfc.BuzzCardTap import org.robojackets.apiary.merchandise.model.MerchandiseDistributionScreenState import org.robojackets.apiary.merchandise.model.MerchandiseState -import org.robojackets.apiary.merchandise.ui.pickup_dialog.AlreadyPickedUpDialog -import org.robojackets.apiary.merchandise.ui.pickup_dialog.ConfirmPickupDialog -import org.robojackets.apiary.merchandise.ui.pickup_dialog.DistributionErrorDialog -import timber.log.Timber +import org.robojackets.apiary.merchandise.ui.pickupdialog.AlreadyPickedUpDialog +import org.robojackets.apiary.merchandise.ui.pickupdialog.ConfirmPickupDialog +import org.robojackets.apiary.merchandise.ui.pickupdialog.DistributionErrorDialog +// TODO(before merge): Shorten method and implement empty ifs, then remove next line +@Suppress("LongMethod", "CyclomaticComplexMethod", "EmptyIfBlock") @Composable fun MerchandiseDistribution( state: MerchandiseState, @@ -24,37 +32,53 @@ fun MerchandiseDistribution( onDismissPickupDialog: () -> Unit, onNavigateToMerchandiseIndex: () -> Unit, ) { - when(state.screenState) { - MerchandiseDistributionScreenState.ShowStatusDialog -> { + // This when handles showing dialogs on screen + when (state.screenState) { + MerchandiseDistributionScreenState.ShowPickupStatusDialog -> { if (state.lastDistributionStatus != null) { - if (state.lastDistributionStatus.can_distribute) { + if (state.lastDistributionStatus.canDistribute) { ConfirmPickupDialog( userFullName = state.lastDistributionStatus.user.name, - userShirtSize = state.lastDistributionStatus.distribution.size, // FIXME + userShirtSize = state.lastDistributionStatus.distribution.size, onConfirm = { onConfirmPickup() }, - onDismissRequest = { onDismissPickupDialog() } + onDismissRequest = { onDismissPickupDialog() }, ) - } else if (!state.lastDistributionStatus.can_distribute) { + } else if (!state.lastDistributionStatus.canDistribute) { AlreadyPickedUpDialog( distributeTo = state.lastDistributionStatus.user, - providedBy = state.lastDistributionStatus.distribution.provided_by!!, // FIXME: Remove these !!s - providedAt = state.lastDistributionStatus.distribution.provided_at!!.toInstant(), - onDismissRequest = onDismissPickupDialog + // TODO(before merge): Remove these !!s + providedBy = state.lastDistributionStatus.distribution.providedBy!!, + providedAt = + state.lastDistributionStatus.distribution.providedAt!!.toInstant(), + onDismissRequest = onDismissPickupDialog, ) } } else if (state.error != null) { DistributionErrorDialog( error = state.error, - onDismissRequest = onDismissPickupDialog + onDismissRequest = onDismissPickupDialog, ) } } + MerchandiseDistributionScreenState.ShowDistributionErrorDialog -> { + if (state.error != null) { + DistributionErrorDialog( + error = state.error, + title = "Distribution error", + onDismissRequest = onDismissPickupDialog, + ) + } + } else -> {} } - Column() { + if (state.selectedItem == null) { + // TODO(before merge): implement this + } + + Column { Text("Record merchandise distribution", style = MaterialTheme.typography.headlineSmall) - when(state.selectedItem) { + when (state.selectedItem) { null -> Text("No merchandise item selected") else -> CurrentlySelectedItem( item = state.selectedItem, @@ -62,16 +86,33 @@ fun MerchandiseDistribution( ) } HorizontalDivider() + if (state.lastStorePickupStatus != null) { + // TODO: show toast + } - // TODO: the rest of the handling - BuzzCardPrompt( - hidePrompt = false, - nfcLib = nfcLib, - onBuzzCardTap = { - Timber.d("Buzzcard tapped: ${it.gtid}, ${it.source}") - onBuzzcardTap(it) - }, - externalError = null - ) + Column( + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxSize() + ) { + + BuzzCardPrompt( + hidePrompt = state.screenState == MerchandiseDistributionScreenState.LoadingDistributionStatus, + nfcLib = nfcLib, + onBuzzCardTap = { + onBuzzcardTap(it) + }, + externalError = null + ) + + when (state.screenState) { + MerchandiseDistributionScreenState.LoadingDistributionStatus -> { + ActionPrompt(icon = { PendingIcon(Modifier.size(114.dp)) }, title = "Processing...") + } + MerchandiseDistributionScreenState.SavingPickupStatus -> { + ActionPrompt(icon = { PendingIcon(Modifier.size(114.dp)) }, title = "Processing...") + } + else -> {} + } + } } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt index 3d1e47c..7366eb2 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -5,7 +5,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.nxp.nfclib.NxpNfcLib +import org.robojackets.apiary.base.ui.error.ErrorMessageWithRetry import org.robojackets.apiary.base.ui.util.ContentPadding +import org.robojackets.apiary.base.ui.util.LoadingSpinner import org.robojackets.apiary.merchandise.model.MerchandiseViewModel @Composable @@ -21,15 +23,29 @@ fun MerchandiseDistributionScreen( val state by viewModel.state.collectAsState() ContentPadding { - MerchandiseDistribution( - state = state, - nfcLib = nfcLib, - onBuzzcardTap = { - viewModel.onBuzzCardTap(it) - }, - onNavigateToMerchandiseIndex = { viewModel.navigateToMerchandiseIndex() }, - onConfirmPickup = { viewModel.confirmPickup() }, - onDismissPickupDialog = { viewModel.dismissPickupDialog() }, - ) + when { + state.merchandiseItemsListError != null -> { + ErrorMessageWithRetry( + message = "Unable to load details about the selected merchandise item", + onRetry = { + viewModel.loadMerchandiseItems( + forceRefresh = true, + selectedItemId = merchandiseItemId + ) + }, + ) + } + state.loadingMerchandiseItems || state.selectedItem == null -> LoadingSpinner() + else -> MerchandiseDistribution( + state = state, + nfcLib = nfcLib, + onBuzzcardTap = { + viewModel.onBuzzCardTap(it) + }, + onNavigateToMerchandiseIndex = { viewModel.navigateToMerchandiseIndex() }, + onConfirmPickup = { viewModel.confirmPickup() }, + onDismissPickupDialog = { viewModel.dismissPickupDialog() }, + ) + } } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt index a74b878..05f539b 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import org.robojackets.apiary.base.ui.error.ErrorMessageWithRetry import org.robojackets.apiary.base.ui.util.ContentPadding import org.robojackets.apiary.base.ui.util.LoadingSpinner import org.robojackets.apiary.merchandise.model.MerchandiseViewModel @@ -21,15 +22,19 @@ fun MerchandiseIndexScreen( val state by viewModel.state.collectAsState() - // ContentPadding ensures the outer container fills the entire available width + height. // This is important for navigation to avoid weird animations/effects when moving between // screens, even with nav transition animations disabled. ContentPadding { Column { - when (state.loadingMerchandiseItems) { - true -> LoadingSpinner() - false -> MerchandiseItemSelection( + when { + state.merchandiseItemsListError != null -> ErrorMessageWithRetry( + message = state.merchandiseItemsListError + ?: "Unable to load merchandise items available for distribution", + onRetry = { viewModel.loadMerchandiseItems(forceRefresh = true) } + ) + state.merchandiseItems == null || state.loadingMerchandiseItems -> LoadingSpinner() + else -> MerchandiseItemSelection( title = { Text( "Pick a merchandise item to distribute", @@ -39,7 +44,10 @@ fun MerchandiseIndexScreen( items = state.merchandiseItems, onItemSelected = { viewModel.navigateToMerchandiseItemDistribution(it) - } + }, + onRefreshList = { + viewModel.loadMerchandiseItems(forceRefresh = true) + }, ) } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt index ef0c007..2942044 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt @@ -3,31 +3,33 @@ package org.robojackets.apiary.merchandise.ui import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import org.robojackets.apiary.base.ui.error.ErrorMessageWithRetry import org.robojackets.apiary.base.ui.form.ItemList import org.robojackets.apiary.merchandise.model.MerchandiseItem - @Composable fun MerchandiseItemSelection( title: @Composable () -> Unit, - items: List, + items: List?, onItemSelected: (item: MerchandiseItem) -> Unit, + onRefreshList: () -> Unit, ) { - if (items.isEmpty()) { - // todo - return - } - - ItemList( - items = items, - onItemSelected = onItemSelected, - title = title, - postItem = { idx -> - if (idx < (items.size - 1)) { - HorizontalDivider() - } - }, - ) { - Text(it.name) + when { + items.isNullOrEmpty() -> ErrorMessageWithRetry( + message = "No distributable merchandise items found", + onRetry = { onRefreshList() }, + ) + else -> ItemList( + items = items, + onItemSelected = onItemSelected, + title = title, + postItem = { idx -> + if (idx < (items.size - 1)) { + HorizontalDivider() + } + }, + ) { + Text(it.name) + } } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt similarity index 93% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt index 887b93b..0d1e733 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/AlreadyPickedUpDialog.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -21,7 +21,6 @@ import java.time.format.FormatStyle import java.util.Locale import java.util.TimeZone - @Composable fun AlreadyPickedUpDialog( distributeTo: BasicUser, @@ -47,7 +46,8 @@ fun AlreadyPickedUpDialog( ), dismissButton = { Button( - onClick = onDismissRequest) { + onClick = onDismissRequest + ) { Text("Go back") } }, diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ConfirmPickupDialog.kt similarity index 83% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ConfirmPickupDialog.kt index f83b713..f988bd5 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ConfirmPickupDialog.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ConfirmPickupDialog.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -37,9 +37,9 @@ fun ConfirmPickupDialog( { DistributeTo(userFullName) }, { when { - userShirtSize?.display_name != null && userShirtSize.short != null -> { - ShirtSizeInfo(userShirtSize.display_name) - } // FIXME if backend logic changes + userShirtSize != null -> { + ItemSizeInfo(userShirtSize.displayName) + } } } ), @@ -47,9 +47,7 @@ fun ConfirmPickupDialog( Button( onClick = { onConfirm() - onDismissRequest() }, - // FIXME: Button is too bright in dark mode colors = ButtonDefaults.buttonColors(containerColor = success, contentColor = Color.White) ) { Text("Mark picked up") @@ -65,4 +63,3 @@ fun ConfirmPickupDialog( modifier = Modifier.padding(0.dp) ) } - diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributeTo.kt similarity index 89% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributeTo.kt index 78ea4c8..2b41341 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributeTo.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributeTo.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccountCircle @@ -17,4 +17,4 @@ fun DistributeTo(name: String) { Text(name) } ) -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDetails.kt similarity index 89% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDetails.kt index 73f12c5..109e075 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDetails.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDetails.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ErrorOutline @@ -17,4 +17,4 @@ fun DistributionErrorDetails(details: String) { Text(details) } ) -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDialog.kt similarity index 87% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDialog.kt index a89c3b8..3c4d039 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/DistributionErrorDialog.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDialog.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -16,6 +16,7 @@ import org.robojackets.apiary.base.ui.theme.danger @Composable fun DistributionErrorDialog( error: String, + title: String = "Ineligible for item", onDismissRequest: () -> Unit, ) { DetailsDialog( @@ -25,16 +26,16 @@ fun DistributionErrorDialog( }, iconContentColor = danger, title = { - Text("Ineligible for item") + Text(title) }, details = listOf { DistributionErrorDetails(error) }, dismissButton = { Button( onClick = onDismissRequest ) { - Text("Go back") + Text("Close") } }, modifier = Modifier.padding(0.dp) ) -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemPickupInfo.kt similarity index 89% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemPickupInfo.kt index 01970f3..6423b28 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ItemPickupInfo.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemPickupInfo.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ErrorOutline @@ -17,4 +17,4 @@ fun ItemPickupInfo(details: String) { Text(details) } ) -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemSizeInfo.kt similarity index 66% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemSizeInfo.kt index 2bb4e61..d775d6d 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/ShirtSizeInfo.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemSizeInfo.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -6,13 +6,13 @@ import androidx.compose.runtime.Composable import org.robojackets.apiary.base.ui.icons.ApparelIcon @Composable -fun ShirtSizeInfo(sizeName: String) { +fun ItemSizeInfo(sizeName: String) { ListItem( leadingContent = { - ApparelIcon(contentDescription = "Shirt size") + ApparelIcon(contentDescription = "Item size") }, headlineContent = { Text(sizeName) } ) -} \ No newline at end of file +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/PickupDialogPreviews.kt similarity index 96% rename from merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt rename to merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/PickupDialogPreviews.kt index 0408b35..877c7e6 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickup_dialog/PickupDialogPreviews.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/PickupDialogPreviews.kt @@ -1,4 +1,4 @@ -package org.robojackets.apiary.merchandise.ui.pickup_dialog +package org.robojackets.apiary.merchandise.ui.pickupdialog import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview @@ -62,4 +62,4 @@ fun AlreadyPickedUpDialogPreview() { providedAt = Instant.now() ) } -} \ No newline at end of file +} From 368cd01d72ce71edf4d08903008d90923d03439a Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Sun, 4 Aug 2024 23:56:20 -0400 Subject: [PATCH 6/8] Fix build error in CI --- .../apiary/base/ui/nfc/BuzzCardPrompt.kt | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt index 70ef4b7..36ba3f7 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt @@ -29,7 +29,6 @@ import com.nxp.nfclib.NxpNfcLib import com.nxp.nfclib.desfire.DESFireFactory import com.nxp.nfclib.exceptions.NxpNfcLibException import kotlinx.coroutines.android.awaitFrame -import org.robojackets.apiary.base.BuildConfig import org.robojackets.apiary.base.ui.ActionPrompt import org.robojackets.apiary.base.ui.IconWithText import org.robojackets.apiary.base.ui.icons.ContactlessIcon @@ -40,7 +39,6 @@ import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.InvalidBuzzCardDat import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.NotABuzzCard import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.TagLost import org.robojackets.apiary.base.ui.nfc.BuzzCardPromptError.UnknownNfcError -import org.robojackets.apiary.base.ui.nfc.BuzzCardTapSource.Debug import org.robojackets.apiary.base.ui.nfc.BuzzCardTapSource.Keyboard import org.robojackets.apiary.base.ui.theme.danger import timber.log.Timber @@ -171,37 +169,37 @@ fun BuzzCardPrompt( ) { Text("Enter GTID manually") } - - if (BuildConfig.DEBUG) { - when (lastTap) { - null -> { - Button( - onClick = { - onBuzzCardTap( - BuzzCardTap( - BuildConfig.localGTID.toInt(), - source = Debug - ) - ) - }, - Modifier.align(CenterHorizontally) - ) { - Text("Tap ${BuildConfig.localGTID.toInt()} again") - } - } - - else -> { - Button( - onClick = { - onBuzzCardTap(BuzzCardTap(lastTap!!.gtid, source = Debug)) - }, - Modifier.align(CenterHorizontally) - ) { - Text("Tap ${lastTap?.gtid ?: "unknown GTID"} again") - } - } - } - } +// TODO(before merge): Figure out how to make this compile in CI when uncommented +// if (BuildConfig.DEBUG) { +// when (lastTap) { +// null -> { +// Button( +// onClick = { +// onBuzzCardTap( +// BuzzCardTap( +// BuildConfig.localGTID.toInt(), +// source = Debug +// ) +// ) +// }, +// Modifier.align(CenterHorizontally) +// ) { +// Text("Tap ${BuildConfig.localGTID.toInt()} again") +// } +// } +// +// else -> { +// Button( +// onClick = { +// onBuzzCardTap(BuzzCardTap(lastTap!!.gtid, source = Debug)) +// }, +// Modifier.align(CenterHorizontally) +// ) { +// Text("Tap ${lastTap?.gtid ?: "unknown GTID"} again") +// } +// } +// } +// } } } From 5bb4cf72430851bb89317be15ab727f2ab1a9d61 Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Sun, 11 Aug 2024 23:56:35 -0400 Subject: [PATCH 7/8] nom nom snackbars --- .../org/robojackets/apiary/MainActivity.kt | 113 ++++++++------- .../model/AttendableTypeSelectionViewModel.kt | 41 ++---- .../ui/AttendableTypeSelectionScreen.kt | 3 +- .../ui/permissions/InsufficientPermissions.kt | 31 ++-- .../apiary/base/ui/error/ErrorMessage.kt | 101 ++++++++++--- .../base/ui/snackbar/SnackbarController.kt | 133 ++++++++++++++++++ build.gradle.kts | 4 +- .../merchandise/model/MerchandiseViewModel.kt | 10 +- .../merchandise/ui/MerchandiseDistribution.kt | 3 - .../ui/MerchandiseDistributionScreen.kt | 17 ++- .../merchandise/ui/MerchandiseIndexScreen.kt | 13 +- .../ui/MerchandiseItemSelection.kt | 3 +- 12 files changed, 333 insertions(+), 139 deletions(-) create mode 100644 base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt diff --git a/app/src/main/java/org/robojackets/apiary/MainActivity.kt b/app/src/main/java/org/robojackets/apiary/MainActivity.kt index 3d9061b..b66f02f 100644 --- a/app/src/main/java/org/robojackets/apiary/MainActivity.kt +++ b/app/src/main/java/org/robojackets/apiary/MainActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -59,6 +60,7 @@ import org.robojackets.apiary.auth.ui.AuthenticationScreen import org.robojackets.apiary.base.GlobalSettings import org.robojackets.apiary.base.model.AttendableType import org.robojackets.apiary.base.ui.nfc.NfcRequired +import org.robojackets.apiary.base.ui.snackbar.SnackbarControllerProvider import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme import org.robojackets.apiary.merchandise.ui.MerchandiseDistributionScreen import org.robojackets.apiary.merchandise.ui.MerchandiseIndexScreen @@ -182,64 +184,67 @@ class MainActivity : ComponentActivity() { navReady = true } - // A surface container using the 'background' color from the theme - Surface(color = MaterialTheme.colorScheme.background) { - ModalBottomSheetLayout(bottomSheetNavigator) { - UpdateGate( - navReady = navReady, - onShowRequiredUpdatePrompt = { - navigationManager.navigate( - NavigationActions.UpdatePrompts.anyScreenToRequiredUpdatePrompt() - ) - }, - onShowOptionalUpdatePrompt = { - navigationManager.navigate( - NavigationActions.UpdatePrompts.anyScreenToOptionalUpdatePrompt() - ) - }, - onShowUpdateInProgressScreen = { - navigationManager.navigate( - NavigationActions.UpdatePrompts.anyScreenToUpdateInProgress() - ) - } - ) { - Scaffold( - topBar = { AppTopBar(settings.appEnv.production) }, - bottomBar = { - val current = currentRoute(navController) - if (shouldShowBottomNav(nfcEnabled, current)) { - NavigationBar { - navItems.forEach { screen -> - NavigationBarItem( - icon = { - Icon( - screen.icon, - contentDescription = screen.imgContentDescriptor - ) - }, - label = { Text(stringResource(screen.resourceId)) }, - selected = currentDestination - ?.hierarchy - ?.any { - it.route == screen.navigationDestination - } == true, - onClick = { - navigationManager.navigate( - NavigationActions.BottomNavTabs.withinBottomNavTabs( - screen.navigationDestination, - navController.graph.findStartDestination().id + SnackbarControllerProvider { snackbarHost -> + // A surface container using the 'background' color from the theme + Surface(color = MaterialTheme.colorScheme.background) { + ModalBottomSheetLayout(bottomSheetNavigator) { + UpdateGate( + navReady = navReady, + onShowRequiredUpdatePrompt = { + navigationManager.navigate( + NavigationActions.UpdatePrompts.anyScreenToRequiredUpdatePrompt() + ) + }, + onShowOptionalUpdatePrompt = { + navigationManager.navigate( + NavigationActions.UpdatePrompts.anyScreenToOptionalUpdatePrompt() + ) + }, + onShowUpdateInProgressScreen = { + navigationManager.navigate( + NavigationActions.UpdatePrompts.anyScreenToUpdateInProgress() + ) + } + ) { + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHost) }, + topBar = { AppTopBar(settings.appEnv.production) }, + bottomBar = { + val current = currentRoute(navController) + if (shouldShowBottomNav(nfcEnabled, current)) { + NavigationBar { + navItems.forEach { screen -> + NavigationBarItem( + icon = { + Icon( + screen.icon, + contentDescription = screen.imgContentDescriptor + ) + }, + label = { Text(stringResource(screen.resourceId)) }, + selected = currentDestination + ?.hierarchy + ?.any { + it.route == screen.navigationDestination + } == true, + onClick = { + navigationManager.navigate( + NavigationActions.BottomNavTabs.withinBottomNavTabs( + screen.navigationDestination, + navController.graph.findStartDestination().id + ) ) - ) - } - ) + } + ) + } } } } - } - ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding)) { - NfcRequired(nfcEnabled = nfcEnabled) { - AppNavigation(navController) + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + NfcRequired(nfcEnabled = nfcEnabled) { + AppNavigation(navController) + } } } } diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendableTypeSelectionViewModel.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendableTypeSelectionViewModel.kt index de42a4c..4ef9397 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendableTypeSelectionViewModel.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendableTypeSelectionViewModel.kt @@ -3,12 +3,9 @@ package org.robojackets.apiary.attendance.model import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.skydoves.sandwich.StatusCode import com.skydoves.sandwich.message -import com.skydoves.sandwich.onError -import com.skydoves.sandwich.onException +import com.skydoves.sandwich.onFailure import com.skydoves.sandwich.onSuccess -import com.skydoves.sandwich.retrofit.statusCode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -77,34 +74,16 @@ class AttendableTypeSelectionViewModel @Inject constructor( viewModelScope.launch { loadingUserPermissions.value = true - userRepository.getLoggedInUserInfo().onSuccess { - val missingPermissions = getMissingPermissions(this.data.user.allPermissions, requiredPermissions) - userMissingPermissions.value = missingPermissions - user.value = this.data.user - } - .onError { - when { - statusCode.code >= StatusCode.InternalServerError.code -> - Timber.e(this.message()) - else -> Timber.w(this.message()) - } - - permissionsCheckError.value = when { - this.statusCode.code >= StatusCode.InternalServerError.code -> - "A server error occurred while checking if you have permission to " + - "use this feature. Check your internet connection and try " + - "again, or ask in #it-helpdesk for assistance." - else -> - "An error occurred while checking if you have permission to use " + - "this feature. Check your internet connection and try again, or " + - "ask in #it-helpdesk for assistance." - } + userRepository.getLoggedInUserInfo() + .onSuccess { + val missingPermissions = + getMissingPermissions(this.data.user.allPermissions, requiredPermissions) + userMissingPermissions.value = missingPermissions + user.value = this.data.user } - .onException { - Timber.e(this.throwable) - permissionsCheckError.value = "An error occurred while checking if you have " + - "permission to use this feature. Check your internet connection and " + - "try again, or ask in #it-helpdesk for assistance." + .onFailure { + Timber.e(this.message()) + permissionsCheckError.value = "Error while checking permissions" } .also { loadingUserPermissions.value = false diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt index 0a644e6..4fbb6db 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt @@ -46,7 +46,8 @@ fun AttendableTypeSelectionScreen( if (state.permissionsCheckError?.isNotEmpty() == true) { ErrorMessageWithRetry( message = state.permissionsCheckError ?: "An unknown error occurred", - onRetry = { viewModel.checkUserAttendanceAccess(forceRefresh = true) } + onRetry = { viewModel.checkUserAttendanceAccess(forceRefresh = true) }, + prioritizeRetryButton = true, ) return@ContentPadding } diff --git a/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt b/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt index 54faec2..2c588ad 100644 --- a/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt +++ b/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt @@ -30,9 +30,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.robojackets.apiary.auth.model.Permission -import org.robojackets.apiary.auth.model.Permission.* +import org.robojackets.apiary.auth.model.Permission.CREATE_ATTENDANCE +import org.robojackets.apiary.auth.model.Permission.READ_TEAMS_HIDDEN +import org.robojackets.apiary.auth.model.Permission.READ_USERS import org.robojackets.apiary.base.ui.error.GoToItHelpdesk import org.robojackets.apiary.base.ui.icons.ErrorIcon +import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme import org.robojackets.apiary.base.ui.theme.danger import org.robojackets.apiary.base.ui.theme.success import org.robojackets.apiary.base.ui.util.ContentPadding @@ -55,18 +58,20 @@ fun InsufficientPermissions( ) { ErrorIcon(Modifier.size(90.dp), tint = danger) Text( - text = "$featureName unavailable", + text = "$featureName permissions required", + textAlign = TextAlign.Center, style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(top = 12.dp), ) Text( - text = "You don't have permission to use this feature. Please ask in #it-helpdesk for assistance.", + text = "You don't have permission to use this feature", modifier = Modifier.padding(top = 12.dp), textAlign = TextAlign.Center, ) Row(Modifier.padding(top = 18.dp)) { OutlinedButton(onClick = { onRefreshRequest() }) { - Text("Try again") + Text("Retry") } GoToItHelpdesk(modifier = Modifier.padding(start = 8.dp)) } @@ -74,7 +79,7 @@ fun InsufficientPermissions( TextButton(onClick = { showPermissionDetailsDialog = true }, Modifier.padding(top = 0.dp)) { - Text("More info") + Text("View details") } if (showPermissionDetailsDialog) { @@ -157,12 +162,14 @@ fun PermissionsListItem(hasPermission: Boolean, permissionName: String) { @Composable @Preview fun InsufficientPermissionsPreview() { - ContentPadding { - InsufficientPermissions( - featureName = "Attendance", - onRefreshRequest = {}, - missingPermissions = listOf(READ_TEAMS_HIDDEN), - requiredPermissions = listOf(CREATE_ATTENDANCE, READ_USERS, READ_TEAMS_HIDDEN), - ) + Apiary_MobileTheme { + ContentPadding { + InsufficientPermissions( + featureName = "Attendance", + onRefreshRequest = {}, + missingPermissions = listOf(READ_TEAMS_HIDDEN), + requiredPermissions = listOf(CREATE_ATTENDANCE, READ_USERS, READ_TEAMS_HIDDEN), + ) + } } } diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt b/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt index c8e9f2a..f57d794 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,20 +18,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import org.robojackets.apiary.base.ui.icons.ErrorIcon +import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme import org.robojackets.apiary.base.ui.theme.danger @Composable fun ErrorMessageWithRetry( - message: String, + message: String? = null, onRetry: () -> Unit, - showHelpButton: Boolean = true, - retryButton: @Composable () -> Unit = { - OutlinedButton(onClick = onRetry) { - Text("Retry") - } + title: String? = null, + prioritizeRetryButton: Boolean = true, + icon: @Composable () -> Unit = { + ErrorIcon(Modifier.size(90.dp), tint = danger) } ) { Column( @@ -40,32 +42,91 @@ fun ErrorMessageWithRetry( .fillMaxWidth() .fillMaxHeight() ) { - ErrorIcon(Modifier.size(90.dp), tint = danger) - - Text( - text = message, - modifier = Modifier.padding(top = 12.dp), - textAlign = TextAlign.Center, - ) + icon() + if (title?.isNotEmpty() == true) { + Text( + text = title, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + style = MaterialTheme.typography.headlineMedium, + ) + } + if (message?.isNotEmpty() == true) { + Text( + text = message, + modifier = Modifier.padding(top = 12.dp), + textAlign = TextAlign.Center, + ) + } Row(Modifier.padding(top = 12.dp)) { - retryButton() - if (showHelpButton) { - GoToItHelpdesk(modifier = Modifier.padding(start = 8.dp)) + if (prioritizeRetryButton) { + GoToItHelpdesk( + modifier = Modifier.padding(end = 8.dp), + useOutlinedButton = true + ) + Button(onClick = onRetry) { + Text("Retry") + } + } else { + OutlinedButton(onClick = onRetry) { + Text("Retry") + } + GoToItHelpdesk( + modifier = Modifier.padding(start = 8.dp) + ) } } } } @Composable -fun GoToItHelpdesk(modifier: Modifier) { +fun GoToItHelpdesk(modifier: Modifier, useOutlinedButton: Boolean = false) { val context = LocalContext.current - Button(onClick = { + fun onClick() { val slackDeepLink = "slack://channel?team=T033JPZLT&id=C29Q3D8K0" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(slackDeepLink)) ContextCompat.startActivity(context, intent, null) - }, modifier) { - Text("Go to #it-helpdesk") + } + + val text = @Composable { Text("Go to #it-helpdesk") } + + when { + useOutlinedButton -> { + OutlinedButton(onClick = { onClick() }, modifier) { + text() + } + } + else -> { + Button(onClick = { onClick() }, modifier) { + text() + } + } + } +} + +@Preview +@Composable +fun ErrorMessageWithRetryPreview() { + Apiary_MobileTheme { + ErrorMessageWithRetry( + title = "Error while checking permissions", + onRetry = {}, + prioritizeRetryButton = true, + ) + } +} + +@Preview +@Composable +fun ErrorMessageWithRetryPrioritizeHelpPreview() { + Apiary_MobileTheme { + ErrorMessageWithRetry( + title = "Attendance unavailable", + message = "An error occurred while checking your permissions", + onRetry = {}, + prioritizeRetryButton = false, + ) } } diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt b/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt new file mode 100644 index 0000000..226975b --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt @@ -0,0 +1,133 @@ +package org.robojackets.apiary.base.ui.snackbar + +/** + * This file is from https://gist.github.com/krizzu/60b7ea7e7865e6495cbd9359f20c4b91 + * See also https://www.kborowy.com/blog/easy-compose-snackbar/ + */ + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.coroutines.EmptyCoroutineContext + +private val LocalSnackbarController = staticCompositionLocalOf { + SnackbarController( + host = SnackbarHostState(), + scope = CoroutineScope(EmptyCoroutineContext) + ) +} +private val channel = Channel(capacity = Int.MAX_VALUE) + +@Composable +fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) { + val snackHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val snackController = remember(scope) { SnackbarController(snackHostState, scope) } + + DisposableEffect(snackController, scope) { + val job = scope.launch { + for (payload in channel) { + Timber.d("Snackbar message: $payload") + snackController.showMessage( + message = payload.message, + duration = payload.duration, + action = payload.action + ) + } + } + + onDispose { + Timber.d("Snackbar job was disposed") + job.cancel() + } + } + + CompositionLocalProvider(LocalSnackbarController provides snackController) { + content( + snackHostState + ) + } +} + +@Immutable +class SnackbarController( + private val host: SnackbarHostState, + private val scope: CoroutineScope, +) { + companion object { + val current + @Composable + @ReadOnlyComposable + get() = LocalSnackbarController.current + + fun showMessage( + message: String, + action: SnackbarAction? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + ) { + Timber.d("snackbar showMessage") + if (channel.isClosedForSend) { + throw IllegalStateException("snackbar channel is closed") + } + + val result = channel.trySend( + SnackbarChannelMessage( + message = message, + duration = duration, + action = action + ) + ) + Timber.d("Snackbar message sent: success? ${result.isSuccess}") + } + } + + + fun showMessage( + message: String, + action: SnackbarAction? = null, + duration: SnackbarDuration = SnackbarDuration.Short, + ) { + Timber.d("snackbar show message with scope") + scope.launch { + Timber.d("snackbar show message with scope: message is $message") + /** + * note: uncomment this line if you want snackbar to be displayed immediately, + * rather than being enqueued and waiting [duration] * current_queue_size + */ + Timber.d(host.currentSnackbarData.toString()) +// host.currentSnackbarData?.dismiss() + val result = + host.showSnackbar( + message = message, + actionLabel = action?.title, + duration = duration + ) + + Timber.d("snackbar with scope: result is $result") + if (result == SnackbarResult.ActionPerformed) { + action?.onActionPress?.invoke() + } + } + } +} + +data class SnackbarChannelMessage( + val message: String, + val action: SnackbarAction?, + val duration: SnackbarDuration = SnackbarDuration.Short, +) + + +data class SnackbarAction(val title: String, val onActionPress: () -> Unit) \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index d6ece75..00c8162 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ buildscript { } dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0") - classpath("com.android.tools.build:gradle:8.5.1") + classpath("com.android.tools.build:gradle:8.5.2") classpath("com.google.dagger:hilt-android-gradle-plugin:2.51.1") // This version needs to // match the version for other Hilt dependencies defined in Dependencies.kt classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") @@ -34,7 +34,7 @@ plugins { id("io.gitlab.arturbosch.detekt").version("1.23.0") id("com.autonomousapps.dependency-analysis").version("1.21.0") id("com.github.ben-manes.versions").version("0.46.0") - id("com.android.library") version "8.5.1" apply false + id("com.android.library") version "8.5.2" apply false id("org.jetbrains.kotlin.android") version "1.9.0" apply false } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index 6b613ac..0e0be05 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.robojackets.apiary.base.model.ApiErrorMessage import org.robojackets.apiary.base.ui.nfc.BuzzCardTap +import org.robojackets.apiary.base.ui.snackbar.SnackbarController import org.robojackets.apiary.merchandise.network.MerchandiseRepository import org.robojackets.apiary.navigation.NavigationActions import org.robojackets.apiary.navigation.NavigationManager @@ -91,11 +92,11 @@ class MerchandiseViewModel @Inject constructor( loadingMerchandiseItems.value = false }.onError { Timber.e(this.toString(), "Could not fetch merchandise items due to an error") - merchandiseItemsListError.value = "Could not load available merchandise items" + merchandiseItemsListError.value = "Failed to load merchandise items" loadingMerchandiseItems.value = false }.onException { Timber.e(this.throwable, "Could not fetch merchandise items due to an exception") - merchandiseItemsListError.value = "Could not load available merchandise items" + merchandiseItemsListError.value = "Failed to load merchandise items" loadingMerchandiseItems.value = false } } @@ -207,6 +208,7 @@ class MerchandiseViewModel @Inject constructor( error = null, user = this.data.user ) + SnackbarController.showMessage("Saved pickup for ${this.data.user.name}") }.onError { // `this.errorBody` can only be consumed once. If you add a log statement // including it, then the deserializeErrorBody call will fail @@ -217,11 +219,11 @@ class MerchandiseViewModel @Inject constructor( Timber.e(e, "Could not deserialize error body") } Timber.d("status: ${errorModel?.status}, message: ${errorModel?.message}") - error.value = errorModel?.message ?: "Unable to record merchandise distribution" + error.value = errorModel?.message ?: "Error recording merchandise distribution" screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog }.onException { Timber.e(this.throwable, "Unable to record merchandise distribution due to an exception") - error.value = "Unable to record merchandise distribution" + error.value = "Error recording merchandise distribution" screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt index 8f0d6e3..4e945ad 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -86,9 +86,6 @@ fun MerchandiseDistribution( ) } HorizontalDivider() - if (state.lastStorePickupStatus != null) { - // TODO: show toast - } Column( verticalArrangement = Arrangement.SpaceAround, diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt index 7366eb2..ca48165 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -9,6 +9,7 @@ import org.robojackets.apiary.base.ui.error.ErrorMessageWithRetry import org.robojackets.apiary.base.ui.util.ContentPadding import org.robojackets.apiary.base.ui.util.LoadingSpinner import org.robojackets.apiary.merchandise.model.MerchandiseViewModel +import timber.log.Timber @Composable fun MerchandiseDistributionScreen( @@ -17,6 +18,7 @@ fun MerchandiseDistributionScreen( merchandiseItemId: Int, ) { LaunchedEffect(merchandiseItemId) { + Timber.d("Launched effect: $merchandiseItemId") viewModel.loadMerchandiseItems(selectedItemId = merchandiseItemId) } @@ -26,13 +28,14 @@ fun MerchandiseDistributionScreen( when { state.merchandiseItemsListError != null -> { ErrorMessageWithRetry( - message = "Unable to load details about the selected merchandise item", + title = "Failed to load merchandise item", onRetry = { viewModel.loadMerchandiseItems( - forceRefresh = true, - selectedItemId = merchandiseItemId - ) - }, + forceRefresh = true, + selectedItemId = merchandiseItemId + ) + }, + prioritizeRetryButton = true, ) } state.loadingMerchandiseItems || state.selectedItem == null -> LoadingSpinner() @@ -42,9 +45,11 @@ fun MerchandiseDistributionScreen( onBuzzcardTap = { viewModel.onBuzzCardTap(it) }, - onNavigateToMerchandiseIndex = { viewModel.navigateToMerchandiseIndex() }, onConfirmPickup = { viewModel.confirmPickup() }, onDismissPickupDialog = { viewModel.dismissPickupDialog() }, + onNavigateToMerchandiseIndex = { + viewModel.navigateToMerchandiseIndex() + }, ) } } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt index 05f539b..e6f53e7 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt @@ -28,11 +28,14 @@ fun MerchandiseIndexScreen( ContentPadding { Column { when { - state.merchandiseItemsListError != null -> ErrorMessageWithRetry( - message = state.merchandiseItemsListError - ?: "Unable to load merchandise items available for distribution", - onRetry = { viewModel.loadMerchandiseItems(forceRefresh = true) } - ) + state.merchandiseItemsListError != null -> + ErrorMessageWithRetry( + title = state.merchandiseItemsListError + ?: "Unable to load merchandise items available for distribution", + onRetry = { viewModel.loadMerchandiseItems(forceRefresh = true) }, + prioritizeRetryButton = true, + ) + state.merchandiseItems == null || state.loadingMerchandiseItems -> LoadingSpinner() else -> MerchandiseItemSelection( title = { diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt index 2942044..c63c2e1 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt @@ -16,8 +16,9 @@ fun MerchandiseItemSelection( ) { when { items.isNullOrEmpty() -> ErrorMessageWithRetry( - message = "No distributable merchandise items found", + title = "No merchandise to distribute", onRetry = { onRefreshList() }, + prioritizeRetryButton = true, ) else -> ItemList( items = items, From be9f401a28f70775eeffce9744d83acf77d490eb Mon Sep 17 00:00:00 2001 From: Evan Strat Date: Wed, 14 Aug 2024 00:08:57 -0400 Subject: [PATCH 8/8] Final changes pre-review --- .../attendance/model/AttendanceViewModel.kt | 11 +- .../attendance/ui/AttendableSelection.kt | 117 +++++++++--------- .../ui/AttendableTypeSelectionScreen.kt | 10 +- .../apiary/auth/model/Permission.kt | 8 +- .../ui/permissions/InsufficientPermissions.kt | 5 - .../apiary/base/ui/error/ErrorMessage.kt | 12 +- .../apiary/base/ui/form/ItemList.kt | 28 +++-- .../apiary/base/ui/nfc/BuzzCardPrompt.kt | 31 ----- .../base/ui/snackbar/SnackbarController.kt | 4 +- .../merchandise/model/MerchandiseViewModel.kt | 57 +++++++++ .../merchandise/ui/MerchandiseDialog.kt | 55 ++++++++ .../merchandise/ui/MerchandiseDistribution.kt | 90 +++++++------- .../merchandise/ui/MerchandiseIndexScreen.kt | 32 ++++- .../ui/MerchandiseItemSelection.kt | 5 +- .../ui/pickupdialog/AlreadyPickedUpDialog.kt | 16 ++- 15 files changed, 310 insertions(+), 171 deletions(-) create mode 100644 merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDialog.kt diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt index 7e88964..eb038c9 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/model/AttendanceViewModel.kt @@ -130,6 +130,7 @@ class AttendanceViewModel @Inject constructor( fun loadAttendables(attendableType: AttendableType, forceRefresh: Boolean = false) { error.value = null loadingAttendables.value = true + viewModelScope.launch { if (attendableType == AttendableType.Team && (attendableTeams.value.isEmpty() || forceRefresh) @@ -146,9 +147,10 @@ class AttendanceViewModel @Inject constructor( }.onException { Timber.e(this.message, "Could not fetch attendable teams due to an exception") error.value = "Unable to fetch teams" + }.also { + loadingAttendables.value = false } - } - if (attendableType == AttendableType.Event && + } else if (attendableType == AttendableType.Event && (attendableEvents.value.isEmpty() || forceRefresh) ) { meetingsRepository.getEvents().onSuccess { @@ -159,9 +161,12 @@ class AttendanceViewModel @Inject constructor( }.onException { Timber.e(this.message, "Could not fetch attendable events due to an exception") error.value = "Unable to fetch events" + }.also { + loadingAttendables.value = false } + } else { + loadingAttendables.value = false } - loadingAttendables.value = false } } diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt index 20e3279..859ab32 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableSelection.kt @@ -1,6 +1,5 @@ package org.robojackets.apiary.attendance.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -8,12 +7,9 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,37 +24,11 @@ import org.robojackets.apiary.attendance.model.AttendanceViewModel import org.robojackets.apiary.auth.ui.permissions.MissingHiddenTeamsCallout import org.robojackets.apiary.base.model.AttendableType import org.robojackets.apiary.base.ui.IconWithText +import org.robojackets.apiary.base.ui.form.ItemList import org.robojackets.apiary.base.ui.icons.WarningIcon import org.robojackets.apiary.base.ui.theme.danger import org.robojackets.apiary.base.ui.util.ContentPadding -@Composable -private fun AttendableList( - attendables: List, - onAttendableSelected: (attendable: T) -> Unit, - title: @Composable () -> Unit, - callout: @Composable () -> Unit = {}, - attendableContent: @Composable (attendable: T) -> Unit, -) { - Column { - title() - callout() - LazyColumn { - itemsIndexed(attendables) { idx, attendable -> - ListItem( - headlineContent = { attendableContent(attendable) }, - Modifier.clickable { - onAttendableSelected(attendable) - } - ) - if (idx < attendables.size - 1) { - HorizontalDivider() - } - } - } - } -} - @Suppress("LongMethod") @Composable fun AttendableSelectionScreen( @@ -102,36 +72,67 @@ fun AttendableSelectionScreen( Text("Retry") } } - } - when (attendableType) { - AttendableType.Team -> { - AttendableList( - attendables = state.attendableTeams, - onAttendableSelected = { - viewModel.onAttendableSelected(it.toAttendable()) - }, - title = { Text("Select a team", style = MaterialTheme.typography.headlineSmall) }, - callout = { - if (state.missingHiddenTeams == true) { - Spacer(Modifier.height(4.dp)) - MissingHiddenTeamsCallout(onRefreshTeams = { - viewModel.loadAttendables(attendableType, forceRefresh = true) - }) + } else { + when (attendableType) { + AttendableType.Team -> { + ItemList( + items = state.attendableTeams, + onItemSelected = { + viewModel.onAttendableSelected(it.toAttendable()) + }, + title = { + Text( + "Select a team", + style = MaterialTheme.typography.headlineSmall + ) + }, + callout = { + if (state.missingHiddenTeams == true) { + Spacer(Modifier.height(4.dp)) + MissingHiddenTeamsCallout(onRefreshTeams = { + viewModel.loadAttendables(attendableType, forceRefresh = true) + }) + } + }, + // This is missing the empty state, but the empty state is rarely shown and + // was briefly flickering on screen last time I tried it. It wasn't worth + // the flickering for an edge case, so I just omitted it. + postItem = { + when { + it < state.attendableTeams.lastIndex -> HorizontalDivider() + } + }, + itemKey = { + state.attendableTeams[it].id } + ) { + Text(it.name) } - ) { - Text(it.name) } - } - AttendableType.Event -> { - AttendableList( - attendables = state.attendableEvents, - onAttendableSelected = { - viewModel.onAttendableSelected(it.toAttendable()) - }, - title = { Text("Select an event", style = MaterialTheme.typography.headlineSmall) } - ) { - Text(it.name) + + AttendableType.Event -> { + ItemList( + items = state.attendableEvents, + onItemSelected = { + viewModel.onAttendableSelected(it.toAttendable()) + }, + title = { + Text("Select an event", style = MaterialTheme.typography.headlineSmall) + }, + postItem = { + when { + it < state.attendableEvents.lastIndex -> HorizontalDivider() + } + }, + // This is missing the empty state, but the empty state is rarely shown and + // was briefly flickering on screen last time I tried it. It wasn't worth + // the flickering for an edge case, so I just omitted it. + itemKey = { + state.attendableEvents[it].id + } + ) { + Text(it.name) + } } } } diff --git a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt index 4fbb6db..58e1548 100644 --- a/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt +++ b/attendance/src/main/java/org/robojackets/apiary/attendance/ui/AttendableTypeSelectionScreen.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -45,7 +45,7 @@ fun AttendableTypeSelectionScreen( ContentPadding { if (state.permissionsCheckError?.isNotEmpty() == true) { ErrorMessageWithRetry( - message = state.permissionsCheckError ?: "An unknown error occurred", + title = state.permissionsCheckError ?: "Error while checking permissions", onRetry = { viewModel.checkUserAttendanceAccess(forceRefresh = true) }, prioritizeRetryButton = true, ) @@ -71,7 +71,7 @@ fun AttendableTypeSelectionScreen( ) { Text("What do you want to take attendance for?", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.defaultMinSize(minHeight = 16.dp)) - Divider() + HorizontalDivider() ListItem( leadingContent = { GroupsIcon(Modifier.size(36.dp)) @@ -83,7 +83,7 @@ fun AttendableTypeSelectionScreen( .defaultMinSize(minHeight = 80.dp) .clickable { viewModel.navigateToAttendableSelection(AttendableType.Team) } ) - Divider() + HorizontalDivider() ListItem( leadingContent = { EventIcon(Modifier.size(36.dp)) @@ -95,7 +95,7 @@ fun AttendableTypeSelectionScreen( .defaultMinSize(minHeight = 80.dp) .clickable { viewModel.navigateToAttendableSelection(AttendableType.Event) } ) - Divider() + HorizontalDivider() } } } diff --git a/auth/src/main/java/org/robojackets/apiary/auth/model/Permission.kt b/auth/src/main/java/org/robojackets/apiary/auth/model/Permission.kt index 7ee45f8..79c17ce 100644 --- a/auth/src/main/java/org/robojackets/apiary/auth/model/Permission.kt +++ b/auth/src/main/java/org/robojackets/apiary/auth/model/Permission.kt @@ -1,7 +1,7 @@ package org.robojackets.apiary.auth.model import com.squareup.moshi.Json -import java.util.* +import java.util.Locale /** * Nov-2022 (evan10s): This class has a custom serializer; see MainActivityModule in the `app` @@ -19,7 +19,11 @@ enum class Permission { @Json(name = "read-teams-hidden") READ_TEAMS_HIDDEN, @Json(name = "read-users") - READ_USERS; + READ_USERS, + @Json(name = "read-merchandise") + READ_MERCHANDISE, + @Json(name = "distribute-swag") + DISTRIBUTE_SWAG; override fun toString(): String { return name.replace("_", "-").lowercase(Locale.getDefault()) diff --git a/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt b/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt index 2c588ad..6648e0a 100644 --- a/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt +++ b/auth/src/main/java/org/robojackets/apiary/auth/ui/permissions/InsufficientPermissions.kt @@ -63,11 +63,6 @@ fun InsufficientPermissions( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp), ) - Text( - text = "You don't have permission to use this feature", - modifier = Modifier.padding(top = 12.dp), - textAlign = TextAlign.Center, - ) Row(Modifier.padding(top = 18.dp)) { OutlinedButton(onClick = { onRefreshRequest() }) { diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt b/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt index f57d794..42fb158 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/error/ErrorMessage.kt @@ -25,11 +25,21 @@ import org.robojackets.apiary.base.ui.icons.ErrorIcon import org.robojackets.apiary.base.ui.theme.Apiary_MobileTheme import org.robojackets.apiary.base.ui.theme.danger +/** + * Display an error message with retry and help buttons + * + * @param title The title of the error message + * @param message An optional description to include more detail. Try to omit and convey information + * via just the title if possible + * @param onRetry The action to take when the retry button is clicked + * @param prioritizeRetryButton If true, the retry button will be displayed as the primary action + * @param icon The icon to display with the error message, defaults to a large error icon + */ @Composable fun ErrorMessageWithRetry( + title: String? = null, message: String? = null, onRetry: () -> Unit, - title: String? = null, prioritizeRetryButton: Boolean = true, icon: @Composable () -> Unit = { ErrorIcon(Modifier.size(90.dp), tint = danger) diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt index c1d7aa9..f0330cc 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt @@ -3,34 +3,44 @@ package org.robojackets.apiary.base.ui.form import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ListItem import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +@Suppress("LongParameterList") @Composable fun ItemList( items: List, onItemSelected: (item: T) -> Unit, title: @Composable () -> Unit, + itemKey: (idx: Int) -> Any, callout: @Composable () -> Unit = {}, preItem: @Composable (idx: Int) -> Unit = {}, postItem: @Composable (idx: Int) -> Unit = {}, + empty: @Composable () -> Unit = {}, itemContent: @Composable (item: T) -> Unit, ) { Column { title() callout() - LazyColumn { - itemsIndexed(items) { idx, item -> - preItem(idx) - ListItem( - headlineContent = { itemContent(item) }, - Modifier.clickable { - onItemSelected(item) + when { + items.isEmpty() -> empty() + else -> LazyColumn { + items( + count = items.size, + key = itemKey, + itemContent = { idx -> + val item = items[idx] + preItem(idx) + ListItem( + headlineContent = { itemContent(item) }, + Modifier.clickable { + onItemSelected(item) + } + ) + postItem(idx) } ) - postItem(idx) } } } diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt index 36ba3f7..e766bc6 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt @@ -169,37 +169,6 @@ fun BuzzCardPrompt( ) { Text("Enter GTID manually") } -// TODO(before merge): Figure out how to make this compile in CI when uncommented -// if (BuildConfig.DEBUG) { -// when (lastTap) { -// null -> { -// Button( -// onClick = { -// onBuzzCardTap( -// BuzzCardTap( -// BuildConfig.localGTID.toInt(), -// source = Debug -// ) -// ) -// }, -// Modifier.align(CenterHorizontally) -// ) { -// Text("Tap ${BuildConfig.localGTID.toInt()} again") -// } -// } -// -// else -> { -// Button( -// onClick = { -// onBuzzCardTap(BuzzCardTap(lastTap!!.gtid, source = Debug)) -// }, -// Modifier.align(CenterHorizontally) -// ) { -// Text("Tap ${lastTap?.gtid ?: "unknown GTID"} again") -// } -// } -// } -// } } } diff --git a/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt b/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt index 226975b..d980e61 100644 --- a/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt +++ b/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt @@ -93,7 +93,6 @@ class SnackbarController( } } - fun showMessage( message: String, action: SnackbarAction? = null, @@ -129,5 +128,4 @@ data class SnackbarChannelMessage( val duration: SnackbarDuration = SnackbarDuration.Short, ) - -data class SnackbarAction(val title: String, val onActionPress: () -> Unit) \ No newline at end of file +data class SnackbarAction(val title: String, val onActionPress: () -> Unit) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt index 0e0be05..11c55e7 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -2,8 +2,10 @@ package org.robojackets.apiary.merchandise.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.skydoves.sandwich.message import com.skydoves.sandwich.onError import com.skydoves.sandwich.onException +import com.skydoves.sandwich.onFailure import com.skydoves.sandwich.onSuccess import com.skydoves.sandwich.retrofit.serialization.deserializeErrorBody import dagger.hilt.android.lifecycle.HiltViewModel @@ -12,6 +14,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.robojackets.apiary.auth.model.Permission +import org.robojackets.apiary.auth.model.Permission.DISTRIBUTE_SWAG +import org.robojackets.apiary.auth.model.Permission.READ_MERCHANDISE +import org.robojackets.apiary.auth.model.UserInfo +import org.robojackets.apiary.auth.network.UserRepository +import org.robojackets.apiary.auth.util.getMissingPermissions import org.robojackets.apiary.base.model.ApiErrorMessage import org.robojackets.apiary.base.ui.nfc.BuzzCardTap import org.robojackets.apiary.base.ui.snackbar.SnackbarController @@ -25,11 +33,14 @@ import javax.inject.Inject class MerchandiseViewModel @Inject constructor( val merchandiseRepository: MerchandiseRepository, val navManager: NavigationManager, + val userRepository: UserRepository, ) : ViewModel() { private val _state = MutableStateFlow(MerchandiseState()) val state: StateFlow get() = _state + val requiredPermissions = listOf(READ_MERCHANDISE, DISTRIBUTE_SWAG) + private val merchandiseItems = MutableStateFlow?>(null) private val loadingMerchandiseItems = MutableStateFlow(false) private val merchandiseItemsListError = MutableStateFlow(null) @@ -40,6 +51,10 @@ class MerchandiseViewModel @Inject constructor( MutableStateFlow(null) private val lastAcceptedBuzzCardTap: MutableStateFlow = MutableStateFlow(null) private val lastStorePickupStatus: MutableStateFlow = MutableStateFlow(null) + private val loadingUserPermissions = MutableStateFlow(false) + private val permissionsCheckError = MutableStateFlow(null) + private val userMissingPermissions = MutableStateFlow(emptyList()) + private var user = MutableStateFlow(null) init { viewModelScope.launch { @@ -54,6 +69,10 @@ class MerchandiseViewModel @Inject constructor( lastAcceptedBuzzCardTap, lastStorePickupStatus, merchandiseItemsListError, + loadingUserPermissions, + permissionsCheckError, + userMissingPermissions, + user, ) ) { flows -> MerchandiseState( @@ -66,6 +85,10 @@ class MerchandiseViewModel @Inject constructor( lastAcceptedBuzzCardTap = flows[6] as BuzzCardTap?, lastStorePickupStatus = flows[7] as StorePickupStatus?, merchandiseItemsListError = flows[8] as String?, + loadingUserPermissions = flows[9] as Boolean, + permissionsCheckError = flows[10] as String?, + userMissingPermissions = flows[11] as List, + user = flows[12] as UserInfo?, ) } .catch { throwable -> throw throwable } @@ -73,6 +96,36 @@ class MerchandiseViewModel @Inject constructor( } } + fun checkUserAccess( + forceRefresh: Boolean = false, + onSuccess: () -> Unit = {} + ) { + if (user.value != null && !forceRefresh) { + return + } + + permissionsCheckError.value = null + + viewModelScope.launch { + loadingUserPermissions.value = true + userRepository.getLoggedInUserInfo() + .onSuccess { + val missingPermissions = + getMissingPermissions(this.data.user.allPermissions, requiredPermissions) + userMissingPermissions.value = missingPermissions + user.value = this.data.user + onSuccess() + } + .onFailure { + Timber.e(this.message()) + permissionsCheckError.value = "Error while checking permissions" + } + .also { + loadingUserPermissions.value = false + } + } + } + fun loadMerchandiseItems( forceRefresh: Boolean = false, selectedItemId: Int? = null, @@ -244,4 +297,8 @@ data class MerchandiseState( val lastAcceptedBuzzCardTap: BuzzCardTap? = null, val lastStorePickupStatus: StorePickupStatus? = null, val merchandiseItemsListError: String? = null, + val loadingUserPermissions: Boolean = false, + val permissionsCheckError: String? = null, + val userMissingPermissions: List = emptyList(), + val user: UserInfo? = null, ) diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDialog.kt new file mode 100644 index 0000000..fe20981 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDialog.kt @@ -0,0 +1,55 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.runtime.Composable +import org.robojackets.apiary.merchandise.model.MerchandiseDistributionScreenState +import org.robojackets.apiary.merchandise.model.MerchandiseState +import org.robojackets.apiary.merchandise.ui.pickupdialog.AlreadyPickedUpDialog +import org.robojackets.apiary.merchandise.ui.pickupdialog.ConfirmPickupDialog +import org.robojackets.apiary.merchandise.ui.pickupdialog.DistributionErrorDialog + +@Composable +fun MerchandiseDialog( + state: MerchandiseState, + onConfirmPickup: () -> Unit, + onDismissPickupDialog: () -> Unit, +) { + when (state.screenState) { + MerchandiseDistributionScreenState.ShowPickupStatusDialog -> { + if (state.lastDistributionStatus != null) { + if (state.lastDistributionStatus.canDistribute) { + ConfirmPickupDialog( + userFullName = state.lastDistributionStatus.user.name, + userShirtSize = state.lastDistributionStatus.distribution.size, + onConfirm = { onConfirmPickup() }, + onDismissRequest = { onDismissPickupDialog() }, + ) + } else if (!state.lastDistributionStatus.canDistribute) { + AlreadyPickedUpDialog( + distributeTo = state.lastDistributionStatus.user, + providedBy = state.lastDistributionStatus.distribution.providedBy, + providedAt = + state.lastDistributionStatus.distribution.providedAt?.toInstant(), + onDismissRequest = onDismissPickupDialog, + ) + } + } else if (state.error != null) { + DistributionErrorDialog( + error = state.error, + onDismissRequest = onDismissPickupDialog, + ) + } + } + + MerchandiseDistributionScreenState.ShowDistributionErrorDialog -> { + if (state.error != null) { + DistributionErrorDialog( + error = state.error, + title = "Distribution error", + onDismissRequest = onDismissPickupDialog, + ) + } + } + + else -> {} + } +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt index 4e945ad..e438628 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -2,27 +2,32 @@ package org.robojackets.apiary.merchandise.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +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.size +import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nxp.nfclib.NxpNfcLib import org.robojackets.apiary.base.ui.ActionPrompt +import org.robojackets.apiary.base.ui.IconWithText import org.robojackets.apiary.base.ui.icons.PendingIcon +import org.robojackets.apiary.base.ui.icons.WarningIcon import org.robojackets.apiary.base.ui.nfc.BuzzCardPrompt import org.robojackets.apiary.base.ui.nfc.BuzzCardTap +import org.robojackets.apiary.base.ui.theme.danger import org.robojackets.apiary.merchandise.model.MerchandiseDistributionScreenState import org.robojackets.apiary.merchandise.model.MerchandiseState -import org.robojackets.apiary.merchandise.ui.pickupdialog.AlreadyPickedUpDialog -import org.robojackets.apiary.merchandise.ui.pickupdialog.ConfirmPickupDialog -import org.robojackets.apiary.merchandise.ui.pickupdialog.DistributionErrorDialog -// TODO(before merge): Shorten method and implement empty ifs, then remove next line -@Suppress("LongMethod", "CyclomaticComplexMethod", "EmptyIfBlock") +@Suppress("LongMethod") @Composable fun MerchandiseDistribution( state: MerchandiseState, @@ -32,48 +37,32 @@ fun MerchandiseDistribution( onDismissPickupDialog: () -> Unit, onNavigateToMerchandiseIndex: () -> Unit, ) { - // This when handles showing dialogs on screen - when (state.screenState) { - MerchandiseDistributionScreenState.ShowPickupStatusDialog -> { - if (state.lastDistributionStatus != null) { - if (state.lastDistributionStatus.canDistribute) { - ConfirmPickupDialog( - userFullName = state.lastDistributionStatus.user.name, - userShirtSize = state.lastDistributionStatus.distribution.size, - onConfirm = { onConfirmPickup() }, - onDismissRequest = { onDismissPickupDialog() }, - ) - } else if (!state.lastDistributionStatus.canDistribute) { - AlreadyPickedUpDialog( - distributeTo = state.lastDistributionStatus.user, - // TODO(before merge): Remove these !!s - providedBy = state.lastDistributionStatus.distribution.providedBy!!, - providedAt = - state.lastDistributionStatus.distribution.providedAt!!.toInstant(), - onDismissRequest = onDismissPickupDialog, - ) - } - } else if (state.error != null) { - DistributionErrorDialog( - error = state.error, - onDismissRequest = onDismissPickupDialog, - ) - } - } - MerchandiseDistributionScreenState.ShowDistributionErrorDialog -> { - if (state.error != null) { - DistributionErrorDialog( - error = state.error, - title = "Distribution error", - onDismissRequest = onDismissPickupDialog, - ) - } - } - else -> {} - } + MerchandiseDialog( + state = state, + onConfirmPickup = onConfirmPickup, + onDismissPickupDialog = onDismissPickupDialog + ) if (state.selectedItem == null) { - // TODO(before merge): implement this + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + IconWithText( + { WarningIcon(tint = danger) }, + "There's no merchandise item selected. Try going back and selecting an item again", + TextAlign.Center + ) + Button(onClick = { + onNavigateToMerchandiseIndex() + }, modifier = Modifier.padding(top = 8.dp)) { + Text("Go back") + } + } + return } Column { @@ -93,7 +82,8 @@ fun MerchandiseDistribution( ) { BuzzCardPrompt( - hidePrompt = state.screenState == MerchandiseDistributionScreenState.LoadingDistributionStatus, + hidePrompt = + state.screenState == MerchandiseDistributionScreenState.LoadingDistributionStatus, nfcLib = nfcLib, onBuzzCardTap = { onBuzzcardTap(it) @@ -103,10 +93,14 @@ fun MerchandiseDistribution( when (state.screenState) { MerchandiseDistributionScreenState.LoadingDistributionStatus -> { - ActionPrompt(icon = { PendingIcon(Modifier.size(114.dp)) }, title = "Processing...") + ActionPrompt(icon = { + PendingIcon(Modifier.size(114.dp)) + }, title = "Processing...") } MerchandiseDistributionScreenState.SavingPickupStatus -> { - ActionPrompt(icon = { PendingIcon(Modifier.size(114.dp)) }, title = "Processing...") + ActionPrompt(icon = { + PendingIcon(Modifier.size(114.dp)) + }, title = "Processing...") } else -> {} } diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt index e6f53e7..07f454b 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt @@ -7,25 +7,55 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import org.robojackets.apiary.auth.ui.permissions.InsufficientPermissions import org.robojackets.apiary.base.ui.error.ErrorMessageWithRetry import org.robojackets.apiary.base.ui.util.ContentPadding import org.robojackets.apiary.base.ui.util.LoadingSpinner import org.robojackets.apiary.merchandise.model.MerchandiseViewModel +@Suppress("LongMethod") @Composable fun MerchandiseIndexScreen( viewModel: MerchandiseViewModel, ) { LaunchedEffect("merch") { - viewModel.loadMerchandiseItems() + viewModel.checkUserAccess( + onSuccess = { viewModel.loadMerchandiseItems() } + ) } val state by viewModel.state.collectAsState() + if (state.loadingUserPermissions) { + LoadingSpinner() + return + } + // ContentPadding ensures the outer container fills the entire available width + height. // This is important for navigation to avoid weird animations/effects when moving between // screens, even with nav transition animations disabled. ContentPadding { + if (state.permissionsCheckError?.isNotEmpty() == true) { + ErrorMessageWithRetry( + title = state.permissionsCheckError ?: "Error while checking permissions", + onRetry = { viewModel.checkUserAccess(forceRefresh = true) }, + prioritizeRetryButton = true, + ) + return@ContentPadding + } + + if (state.userMissingPermissions.isNotEmpty()) { + InsufficientPermissions( + featureName = "Merchandise", + onRefreshRequest = { + viewModel.checkUserAccess(forceRefresh = true) + }, + missingPermissions = state.userMissingPermissions, + requiredPermissions = viewModel.requiredPermissions, + ) + return@ContentPadding + } + Column { when { state.merchandiseItemsListError != null -> diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt index c63c2e1..ddcdda7 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt @@ -24,8 +24,11 @@ fun MerchandiseItemSelection( items = items, onItemSelected = onItemSelected, title = title, + itemKey = { + items[it].id + }, postItem = { idx -> - if (idx < (items.size - 1)) { + if (idx < items.lastIndex) { HorizontalDivider() } }, diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt index 0d1e733..1d9ba39 100644 --- a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt @@ -24,12 +24,15 @@ import java.util.TimeZone @Composable fun AlreadyPickedUpDialog( distributeTo: BasicUser, - providedBy: UserRef, - providedAt: Instant, + providedBy: UserRef?, + providedAt: Instant?, onDismissRequest: () -> Unit, ) { val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) - val localProvidedAt = LocalDateTime.ofInstant(providedAt, TimeZone.getDefault().toZoneId()) + val localProvidedAt: LocalDateTime? = when (providedAt) { + null -> null + else -> LocalDateTime.ofInstant(providedAt, TimeZone.getDefault().toZoneId()) + } DetailsDialog( onDismissRequest = onDismissRequest, @@ -42,7 +45,12 @@ fun AlreadyPickedUpDialog( }, details = listOf( { DistributeTo(distributeTo.name) }, - { ItemPickupInfo("Distributed by ${providedBy.full_name} on ${localProvidedAt.format(dateFormatter)}") } + { + ItemPickupInfo( + "Distributed by ${providedBy?.full_name ?: "unknown user"} on " + + (localProvidedAt?.format(dateFormatter) ?: "unknown date") + ) + } ), dismissButton = { Button(