diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3dcadf..7bb6a67 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 @@ -47,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/MainActivity.kt b/app/src/main/java/org/robojackets/apiary/MainActivity.kt index 4abb8a3..b66f02f 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 @@ -21,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 @@ -58,7 +60,10 @@ 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 import org.robojackets.apiary.navigation.NavigationActions import org.robojackets.apiary.navigation.NavigationDestinations import org.robojackets.apiary.navigation.NavigationManager @@ -87,6 +92,14 @@ sealed class Screen( "contactless" ) + object Merchandise : + Screen( + NavigationDestinations.merchandiseSubgraph, + R.string.merchandise, + Icons.Outlined.Storefront, + "storefront", + ) + object Settings : Screen( NavigationDestinations.settingsSubgraph, @@ -139,6 +152,7 @@ class MainActivity : ComponentActivity() { val navItems = listOf( Screen.Attendance, + Screen.Merchandise, Screen.Settings, ) @@ -170,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) + } } } } @@ -311,6 +328,31 @@ 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(), + nfcLib, + merchandiseItemId as Int + ) + } + } + navigation( startDestination = NavigationDestinations.settings, route = NavigationDestinations.settingsSubgraph, 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/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/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/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 ad127f5..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,13 +1,15 @@ package org.robojackets.apiary.attendance.ui -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +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.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider -import androidx.compose.material3.ListItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,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) { - Divider() - } - } - } - } -} - @Suppress("LongMethod") @Composable fun AttendableSelectionScreen( @@ -96,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 0a644e6..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,8 +45,9 @@ fun AttendableTypeSelectionScreen( ContentPadding { if (state.permissionsCheckError?.isNotEmpty() == true) { ErrorMessageWithRetry( - message = state.permissionsCheckError ?: "An unknown error occurred", - onRetry = { viewModel.checkUserAttendanceAccess(forceRefresh = true) } + title = state.permissionsCheckError ?: "Error while checking permissions", + onRetry = { viewModel.checkUserAttendanceAccess(forceRefresh = true) }, + prioritizeRetryButton = true, ) return@ContentPadding } @@ -70,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)) @@ -82,7 +83,7 @@ fun AttendableTypeSelectionScreen( .defaultMinSize(minHeight = 80.dp) .clickable { viewModel.navigateToAttendableSelection(AttendableType.Team) } ) - Divider() + HorizontalDivider() ListItem( leadingContent = { EventIcon(Modifier.size(36.dp)) @@ -94,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 54faec2..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 @@ -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,15 @@ fun InsufficientPermissions( ) { ErrorIcon(Modifier.size(90.dp), tint = danger) Text( - text = "$featureName unavailable", + text = "$featureName permissions required", + textAlign = TextAlign.Center, style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = "You don't have permission to use this feature. Please ask in #it-helpdesk for assistance.", 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 +74,7 @@ fun InsufficientPermissions( TextButton(onClick = { showPermissionDetailsDialog = true }, Modifier.padding(top = 0.dp)) { - Text("More info") + Text("View details") } if (showPermissionDetailsDialog) { @@ -157,12 +157,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/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/model/BasicUser.kt b/base/src/main/java/org/robojackets/apiary/base/model/BasicUser.kt index 8fc4960..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 @@ -9,4 +9,13 @@ data class BasicUser( val uid: String, val name: String, val preferred_first_name: String, + val shirt_size: ShirtSize?, + val polo_size: ShirtSize?, +) + +@Suppress("ConstructorParameterNaming") +@JsonClass(generateAdapter = true) +data class UserRef( + val id: Int, + val full_name: String, ) 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..111b91d --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/model/ShirtSize.kt @@ -0,0 +1,38 @@ +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() + } + } + } +} 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..add2b5d --- /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, + ) +} 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..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 @@ -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,31 @@ 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 +/** + * 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( - message: String, + title: String? = null, + message: String? = null, onRetry: () -> Unit, - showHelpButton: Boolean = true, - retryButton: @Composable () -> Unit = { - OutlinedButton(onClick = onRetry) { - Text("Retry") - } + prioritizeRetryButton: Boolean = true, + icon: @Composable () -> Unit = { + ErrorIcon(Modifier.size(90.dp), tint = danger) } ) { Column( @@ -40,32 +52,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/form/ItemList.kt b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt new file mode 100644 index 0000000..f0330cc --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/ui/form/ItemList.kt @@ -0,0 +1,47 @@ +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.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() + 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) + } + ) + } + } + } +} 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/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt b/base/src/main/java/org/robojackets/apiary/base/ui/nfc/BuzzCardPrompt.kt index 1ca0477..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 @@ -11,23 +11,35 @@ 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.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.Keyboard import org.robojackets.apiary.base.ui.theme.danger import timber.log.Timber import java.nio.charset.StandardCharsets @@ -46,6 +58,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( @@ -55,6 +68,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 +118,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 @@ -134,8 +150,8 @@ fun BuzzCardPrompt( } var showGtidPrompt by remember { mutableStateOf(false) } - Column { - if (!hidePrompt) { + if (!hidePrompt) { + Column { if (externalError != null) { ExternalError(externalError) } else { @@ -159,6 +175,7 @@ fun BuzzCardPrompt( if (showGtidPrompt) { ManualGtidEntryPrompt( onGtidEntered = { + lastTap = it onBuzzCardTap(it) error = null }, @@ -174,6 +191,13 @@ fun ManualGtidEntryPrompt( onGtidEntered: (entry: BuzzCardTap) -> Unit, ) { var gtid by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(focusRequester) { + awaitFrame() + focusRequester.requestFocus() + } + AlertDialog( onDismissRequest = { gtid = "" @@ -190,7 +214,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") @@ -209,7 +238,10 @@ 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 +295,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 +315,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/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..d980e61 --- /dev/null +++ b/base/src/main/java/org/robojackets/apiary/base/ui/snackbar/SnackbarController.kt @@ -0,0 +1,131 @@ +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) 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..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,8 +23,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/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/build.gradle.kts b/build.gradle.kts index 4e4923e..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,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.2" 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 6ddc2fd..e9195d2 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -129,13 +129,17 @@ 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 = "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/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/.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..8b5e845 --- /dev/null +++ b/merchandise/build.gradle.kts @@ -0,0 +1,87 @@ +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") + kotlin("plugin.serialization") version "2.0.0" +} + +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.kotlinx_serialization_json) + 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..fd4b721 --- /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) +} 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..05624a7 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Distribution.kt @@ -0,0 +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, + @Json(name = "can_distribute") + val canDistribute: Boolean, +) + +@Suppress("ConstructorParameterNaming") +@JsonClass(generateAdapter = true) +data class Distribution( + val id: Int, + @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/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/Merchandise.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt new file mode 100644 index 0000000..63da67c --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/Merchandise.kt @@ -0,0 +1,14 @@ +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, +) 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..54271e2 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseDistributionScreenState.kt @@ -0,0 +1,9 @@ +package org.robojackets.apiary.merchandise.model + +enum class MerchandiseDistributionScreenState { + ReadyForTap, + LoadingDistributionStatus, + SavingPickupStatus, + 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 new file mode 100644 index 0000000..bd80f1c --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseSize.kt @@ -0,0 +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, + @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 new file mode 100644 index 0000000..11c55e7 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/model/MerchandiseViewModel.kt @@ -0,0 +1,304 @@ +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 +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.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 +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, + 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) + private val error = MutableStateFlow(null) + private val selectedItem = MutableStateFlow(null) + private val screenState = MutableStateFlow(MerchandiseDistributionScreenState.ReadyForTap) + private val lastDistributionStatus: MutableStateFlow = + 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 { + combine( + listOf( + merchandiseItems, + loadingMerchandiseItems, + error, + selectedItem, + screenState, + lastDistributionStatus, + lastAcceptedBuzzCardTap, + lastStorePickupStatus, + merchandiseItemsListError, + loadingUserPermissions, + permissionsCheckError, + userMissingPermissions, + user, + ) + ) { 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?, + loadingUserPermissions = flows[9] as Boolean, + permissionsCheckError = flows[10] as String?, + userMissingPermissions = flows[11] as List, + user = flows[12] as UserInfo?, + ) + } + .catch { throwable -> throw throwable } + .collect { _state.value = it } + } + } + + 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, + ) { + merchandiseItemsListError.value = null + if (merchandiseItems.value?.isNotEmpty() == true && !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") + 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 = "Failed to load 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") + } + } + + @Suppress("TooGenericExceptionCaught") + fun onBuzzCardTap(buzzCardTap: BuzzCardTap) { + if (screenState.value != MerchandiseDistributionScreenState.ReadyForTap) { + Timber.d("onBuzzCardTap: Screen state is not ready for tap, ignoring") + return + } + + screenState.value = MerchandiseDistributionScreenState.LoadingDistributionStatus + + val selectedItemId = selectedItem.value?.id + + if (selectedItemId == null) { + error.value = "No merchandise item selected" + Timber.e("onBuzzCardTap called with no merchandise item selected") + return + } + + error.value = null + lastStorePickupStatus.value = null + lastDistributionStatus.value = null + lastAcceptedBuzzCardTap.value = buzzCardTap + + 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.ShowPickupStatusDialog + } + .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.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() { + screenState.value = MerchandiseDistributionScreenState.SavingPickupStatus + viewModelScope.launch { + 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 + ) + 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 + 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 ?: "Error recording merchandise distribution" + screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog + }.onException { + Timber.e(this.throwable, "Unable to record merchandise distribution due to an exception") + error.value = "Error recording merchandise distribution" + screenState.value = MerchandiseDistributionScreenState.ShowDistributionErrorDialog + } + } + } + + fun dismissPickupDialog() { + screenState.value = MerchandiseDistributionScreenState.ReadyForTap + } +} + +data class MerchandiseState( + 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, + 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/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 new file mode 100644 index 0000000..d360f17 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseApiService.kt @@ -0,0 +1,29 @@ +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 { + @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 + + @FormUrlEncoded + @POST("/api/v1/merchandise/{itemId}/distribute/{gtid}") + suspend fun distributeItem( + @Path("itemId") itemId: Int, + @Path("gtid") gtid: Int, + @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 new file mode 100644 index 0000000..d524b9f --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/network/MerchandiseRepository.kt @@ -0,0 +1,15 @@ +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() + 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 new file mode 100644 index 0000000..46de252 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/CurrentlySelectedItem.kt @@ -0,0 +1,65 @@ +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" + ) + ) {} + } +} 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 new file mode 100644 index 0000000..e438628 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistribution.kt @@ -0,0 +1,109 @@ +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 + +@Suppress("LongMethod") +@Composable +fun MerchandiseDistribution( + state: MerchandiseState, + nfcLib: NxpNfcLib, + onBuzzcardTap: (buzzcardTap: BuzzCardTap) -> Unit, + onConfirmPickup: () -> Unit, + onDismissPickupDialog: () -> Unit, + onNavigateToMerchandiseIndex: () -> Unit, +) { + MerchandiseDialog( + state = state, + onConfirmPickup = onConfirmPickup, + onDismissPickupDialog = onDismissPickupDialog + ) + + if (state.selectedItem == null) { + 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 { + 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() + + 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 new file mode 100644 index 0000000..ca48165 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseDistributionScreen.kt @@ -0,0 +1,56 @@ +package org.robojackets.apiary.merchandise.ui + +import androidx.compose.runtime.Composable +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 +import timber.log.Timber + +@Composable +fun MerchandiseDistributionScreen( + viewModel: MerchandiseViewModel, + nfcLib: NxpNfcLib, + merchandiseItemId: Int, +) { + LaunchedEffect(merchandiseItemId) { + Timber.d("Launched effect: $merchandiseItemId") + viewModel.loadMerchandiseItems(selectedItemId = merchandiseItemId) + } + + val state by viewModel.state.collectAsState() + + ContentPadding { + when { + state.merchandiseItemsListError != null -> { + ErrorMessageWithRetry( + title = "Failed to load merchandise item", + onRetry = { + viewModel.loadMerchandiseItems( + forceRefresh = true, + selectedItemId = merchandiseItemId + ) + }, + prioritizeRetryButton = true, + ) + } + state.loadingMerchandiseItems || state.selectedItem == null -> LoadingSpinner() + else -> MerchandiseDistribution( + state = state, + nfcLib = nfcLib, + onBuzzcardTap = { + viewModel.onBuzzCardTap(it) + }, + 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 new file mode 100644 index 0000000..07f454b --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseIndexScreen.kt @@ -0,0 +1,88 @@ +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.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.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 -> + 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 = { + Text( + "Pick a merchandise item to distribute", + style = MaterialTheme.typography.headlineSmall + ) + }, + 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 new file mode 100644 index 0000000..ddcdda7 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/MerchandiseItemSelection.kt @@ -0,0 +1,39 @@ +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?, + onItemSelected: (item: MerchandiseItem) -> Unit, + onRefreshList: () -> Unit, +) { + when { + items.isNullOrEmpty() -> ErrorMessageWithRetry( + title = "No merchandise to distribute", + onRetry = { onRefreshList() }, + prioritizeRetryButton = true, + ) + else -> ItemList( + items = items, + onItemSelected = onItemSelected, + title = title, + itemKey = { + items[it].id + }, + postItem = { idx -> + if (idx < items.lastIndex) { + HorizontalDivider() + } + }, + ) { + Text(it.name) + } + } +} 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 new file mode 100644 index 0000000..1d9ba39 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/AlreadyPickedUpDialog.kt @@ -0,0 +1,64 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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? = when (providedAt) { + null -> null + else -> 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 ?: "unknown user"} on " + + (localProvidedAt?.format(dateFormatter) ?: "unknown date") + ) + } + ), + dismissButton = { + Button( + onClick = onDismissRequest + ) { + Text("Go back") + } + }, + modifier = Modifier.padding(0.dp) + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ConfirmPickupDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ConfirmPickupDialog.kt new file mode 100644 index 0000000..f988bd5 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ConfirmPickupDialog.kt @@ -0,0 +1,65 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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.ui.dialog.DetailsDialog +import org.robojackets.apiary.base.ui.theme.success +import org.robojackets.apiary.merchandise.model.MerchandiseSize + +@Composable +fun ConfirmPickupDialog( + userFullName: String, + userShirtSize: MerchandiseSize?, + 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) }, + { + when { + userShirtSize != null -> { + ItemSizeInfo(userShirtSize.displayName) + } + } + } + ), + confirmButton = { + Button( + onClick = { + onConfirm() + }, + 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/pickupdialog/DistributeTo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributeTo.kt new file mode 100644 index 0000000..2b41341 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributeTo.kt @@ -0,0 +1,20 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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) + } + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDetails.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDetails.kt new file mode 100644 index 0000000..109e075 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDetails.kt @@ -0,0 +1,20 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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) + } + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDialog.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDialog.kt new file mode 100644 index 0000000..3c4d039 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/DistributionErrorDialog.kt @@ -0,0 +1,41 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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, + title: String = "Ineligible for item", + onDismissRequest: () -> Unit, +) { + DetailsDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, Modifier.size(40.dp)) + }, + iconContentColor = danger, + title = { + Text(title) + }, + details = listOf { DistributionErrorDetails(error) }, + dismissButton = { + Button( + onClick = onDismissRequest + ) { + Text("Close") + } + }, + modifier = Modifier.padding(0.dp) + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemPickupInfo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemPickupInfo.kt new file mode 100644 index 0000000..6423b28 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemPickupInfo.kt @@ -0,0 +1,20 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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) + } + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemSizeInfo.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemSizeInfo.kt new file mode 100644 index 0000000..d775d6d --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/ItemSizeInfo.kt @@ -0,0 +1,18 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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 ItemSizeInfo(sizeName: String) { + ListItem( + leadingContent = { + ApparelIcon(contentDescription = "Item size") + }, + headlineContent = { + Text(sizeName) + } + ) +} diff --git a/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/PickupDialogPreviews.kt b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/PickupDialogPreviews.kt new file mode 100644 index 0000000..877c7e6 --- /dev/null +++ b/merchandise/src/main/java/org/robojackets/apiary/merchandise/ui/pickupdialog/PickupDialogPreviews.kt @@ -0,0 +1,65 @@ +package org.robojackets.apiary.merchandise.ui.pickupdialog + +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 org.robojackets.apiary.merchandise.model.MerchandiseSize +import java.time.Instant + +@Preview +@Composable +fun ConfirmDistributionDialogPreview() { + Apiary_MobileTheme { + ConfirmPickupDialog( + userFullName = "George Burdell", + userShirtSize = MerchandiseSize("s", "Small"), + onConfirm = {}, + onDismissRequest = {}, + ) + } +} + +@Preview +@Composable +fun NoPaidTransactionErrorDialogPreview() { + Apiary_MobileTheme { + DistributionErrorDialog( + error = "This person doesn't have a paid transaction for this item.", + onDismissRequest = {} + ) + } +} + +@Preview +@Composable +fun NotDistributableDialogPreview() { + Apiary_MobileTheme { + DistributionErrorDialog( + error = "This item cannot be distributed.", + onDismissRequest = {} + ) + } +} + +@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() + ) + } +} 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")