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