From e0227fd608eda5abecd1cfbe7221492db84bf937 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Fri, 11 Oct 2024 00:32:35 +0530 Subject: [PATCH] DO NOT MERGE: androidApp: Initial support for tickets Signed-off-by: Aayush Gupta --- .../ui/extensions/NavHostController.kt | 17 + .../ccip/android/ui/navigation/NavGraph.kt | 5 + .../ccip/android/ui/navigation/Screen.kt | 6 + .../android/ui/screens/event/EventScreen.kt | 5 +- .../eventpreview/EventPreviewScreen.kt | 9 +- .../android/ui/screens/ticket/TicketScreen.kt | 293 ++++++++++++++++++ .../ui/screens/ticket/TicketViewModel.kt | 96 ++++++ .../opass/ccip/android/utils/Preferences.kt | 1 + .../src/main/res/drawable/ic_gallery.xml | 14 + .../src/main/res/drawable/ic_keyboard.xml | 14 + .../src/main/res/drawable/ic_logout.xml | 15 + androidApp/src/main/res/values/strings.xml | 9 +- 12 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 androidApp/src/main/java/app/opass/ccip/android/ui/extensions/NavHostController.kt create mode 100644 androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt create mode 100644 androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketViewModel.kt create mode 100644 androidApp/src/main/res/drawable/ic_gallery.xml create mode 100644 androidApp/src/main/res/drawable/ic_keyboard.xml create mode 100644 androidApp/src/main/res/drawable/ic_logout.xml diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/extensions/NavHostController.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/extensions/NavHostController.kt new file mode 100644 index 0000000..2fd221f --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/extensions/NavHostController.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 OPass + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.opass.ccip.android.ui.extensions + +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import app.opass.ccip.android.ui.navigation.Screen + +fun NavHostController.popBackToEventScreen(eventId: String) { + navigate(Screen.Event(eventId)) { + popUpTo(graph.findStartDestination().id) { inclusive = true } + launchSingleTop = true + } +} diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/NavGraph.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/NavGraph.kt index 6602f63..1a9532f 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/NavGraph.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/NavGraph.kt @@ -21,6 +21,7 @@ import app.opass.ccip.android.ui.screens.event.EventScreen import app.opass.ccip.android.ui.screens.eventpreview.EventPreviewScreen import app.opass.ccip.android.ui.screens.schedule.ScheduleScreen import app.opass.ccip.android.ui.screens.session.SessionScreen +import app.opass.ccip.android.ui.screens.ticket.TicketScreen @Composable fun SetupNavGraph(navHostController: NavHostController, startDestination: Screen) { @@ -51,6 +52,10 @@ fun SetupNavGraph(navHostController: NavHostController, startDestination: Screen composable { backStackEntry -> backStackEntry.toRoute().SessionScreen(navHostController) } + + composable { backStackEntry -> + backStackEntry.toRoute().TicketScreen(navHostController) + } } } diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/Screen.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/Screen.kt index 04787c1..11d40cb 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/Screen.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/navigation/Screen.kt @@ -36,4 +36,10 @@ sealed class Screen(@StringRes val title: Int, @DrawableRes val icon: Int) { title = R.string.session, icon = R.drawable.ic_podium ) + + @Serializable + data class Ticket(val eventId: String) : Screen( + title = R.string.ticket, + icon = R.drawable.ic_ticket + ) } diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventScreen.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventScreen.kt index dbc3e76..9ec1f72 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventScreen.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventScreen.kt @@ -202,7 +202,9 @@ fun Screen.Event.EventScreen( FeatureItem( label = stringResource(id = R.string.ticket), iconRes = R.drawable.ic_ticket - ) + ) { + navHostController.navigate(Screen.Ticket(this@EventScreen.id)) + } } FeatureType.VENUE -> { @@ -260,6 +262,7 @@ private fun HeaderImage(logoUrl: String?) { .padding(horizontal = 32.dp) .aspectRatio(2.0f) .heightIn(max = 180.dp) + .clip(RoundedCornerShape(10.dp)) .shimmer(logoUrl == null), colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) ) diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/eventpreview/EventPreviewScreen.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/eventpreview/EventPreviewScreen.kt index 65a329a..931ff60 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/eventpreview/EventPreviewScreen.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/eventpreview/EventPreviewScreen.kt @@ -51,9 +51,9 @@ import androidx.compose.ui.unit.sp import androidx.core.content.edit import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import app.opass.ccip.android.R +import app.opass.ccip.android.ui.extensions.popBackToEventScreen import app.opass.ccip.android.ui.extensions.sharedPreferences import app.opass.ccip.android.ui.extensions.shimmer import app.opass.ccip.android.ui.navigation.Screen @@ -95,12 +95,7 @@ fun Screen.EventPreview.EventPreviewScreen( EventPreviewItem(name = event.name, logoUrl = event.logoUrl) { onEventSelected() sharedPreferences.edit { putString(CURRENT_EVENT_ID, event.id) } - navHostController.navigate(Screen.Event(event.id)) { - popUpTo(navHostController.graph.findStartDestination().id) { - inclusive = true - } - launchSingleTop = true - } + navHostController.popBackToEventScreen(event.id) } } } diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt new file mode 100644 index 0000000..a4b6834 --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2024 OPass + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.opass.ccip.android.ui.screens.ticket + +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import app.opass.ccip.android.R +import app.opass.ccip.android.ui.components.TopAppBar +import app.opass.ccip.android.ui.extensions.popBackToEventScreen +import app.opass.ccip.android.ui.extensions.shimmer +import app.opass.ccip.android.ui.navigation.Screen +import coil.compose.SubcomposeAsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import kotlinx.coroutines.android.awaitFrame + +@Composable +fun Screen.Ticket.TicketScreen( + navHostController: NavHostController, + viewModel: TicketViewModel = hiltViewModel() +) { + val token by viewModel.token.collectAsStateWithLifecycle() + if (!token.isNullOrBlank()) { + ShowTicket(this, navHostController, viewModel) + } else { + RequestTicket(this, navHostController, viewModel) + } +} + +@Composable +private fun ShowTicket( + screen: Screen.Ticket, + navHostController: NavHostController, + viewModel: TicketViewModel +) { + val token by viewModel.token.collectAsStateWithLifecycle() + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = stringResource(screen.title), + navHostController = navHostController, + navigationIcon = { + IconButton( + onClick = { navHostController.popBackToEventScreen(screen.eventId) } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + IconButton(onClick = { viewModel.logout(screen.eventId, token!!) }) { + Icon( + painter = painterResource(R.drawable.ic_logout), + contentDescription = stringResource(R.string.enter_token_manually_title) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // TODO: Show current ticket as QR + } + } +} + +@Composable +private fun RequestTicket( + screen: Screen.Ticket, + navHostController: NavHostController, + viewModel: TicketViewModel +) { + val context = LocalContext.current + + val eventConfig by viewModel.eventConfig.collectAsStateWithLifecycle() + val isVerifying by viewModel.isVerifying.collectAsStateWithLifecycle() + + var shouldShowVerificationDialog by rememberSaveable { mutableStateOf(false) } + var shouldShowManualEntryDialog by rememberSaveable { mutableStateOf(false) } + + val startActivityForResult = rememberLauncherForActivityResult( + contract = PickVisualMedia(), + onResult = { uri -> + // TODO: Process the image + } + ) + + LaunchedEffect(key1 = Unit) { viewModel.getEventConfig(screen.eventId) } + LaunchedEffect(key1 = isVerifying) { shouldShowVerificationDialog = isVerifying } + + if (isVerifying) { + VerificationDialog(onDismiss = { shouldShowVerificationDialog = false }) + } + + if (shouldShowManualEntryDialog) { + ManualEntryDialog( + onConfirm = { + shouldShowManualEntryDialog = false + viewModel.getAttendee(eventConfig!!.id, it) + }, + onDismiss = { shouldShowManualEntryDialog = false } + ) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = stringResource(screen.title), + navHostController = navHostController, + actions = { + IconButton(onClick = { shouldShowManualEntryDialog = true }) { + Icon( + painter = painterResource(R.drawable.ic_keyboard), + contentDescription = stringResource(R.string.enter_token_manually_title) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HeaderImage(logoUrl = eventConfig?.logoUrl) + + // Actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally) + ) { + if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { + Button(onClick = {}) { + Text(text = stringResource(R.string.scan)) + } + } + FilledTonalButton( + onClick = { + startActivityForResult.launch( + PickVisualMediaRequest(PickVisualMedia.ImageOnly) + ) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_gallery), + contentDescription = null, + modifier = Modifier.padding(end = 5.dp) + ) + Text(text = stringResource(R.string.upload_from_gallery)) + } + } + } + } +} + +@Composable +private fun HeaderImage(logoUrl: String?) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(logoUrl) + .placeholder(R.drawable.ic_landscape) + .error(R.drawable.ic_broken_image) + .crossfade(true) + .memoryCacheKey(logoUrl) + .diskCacheKey(logoUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(horizontal = 32.dp) + .aspectRatio(2.0f) + .heightIn(max = 180.dp) + .clip(RoundedCornerShape(10.dp)) + .shimmer(logoUrl == null), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) +} + +@Composable +private fun VerificationDialog(onDismiss: () -> Unit = {}) { + Dialog(onDismissRequest = { onDismiss() }) { + CircularProgressIndicator(modifier = Modifier.requiredWidth(48.dp)) + } +} + +@Composable +private fun ManualEntryDialog(onConfirm: (token: String) -> Unit = {}, onDismiss: () -> Unit = {}) { + var token by rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(focusRequester) { + awaitFrame() + focusRequester.requestFocus() + } + + AlertDialog( + title = { Text(text = stringResource(R.string.enter_token_manually_title)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = stringResource(R.string.enter_token_manually_desc)) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + value = token, + onValueChange = { token = it }, + shape = RoundedCornerShape(10.dp) + ) + } + }, + onDismissRequest = { onDismiss() }, + confirmButton = { + TextButton ( + onClick = { onConfirm(token) }, + enabled = token.isNotBlank() + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text(text = stringResource(android.R.string.cancel)) + } + } + ) +} diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketViewModel.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketViewModel.kt new file mode 100644 index 0000000..2873449 --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketViewModel.kt @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2024 OPass + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.opass.ccip.android.ui.screens.ticket + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.opass.ccip.android.ui.extensions.sharedPreferences +import app.opass.ccip.android.utils.Preferences.CURRENT_EVENT_ID +import app.opass.ccip.android.utils.Preferences.CURRENT_EVENT_TOKEN +import app.opass.ccip.helpers.PortalHelper +import app.opass.ccip.network.models.eventconfig.EventConfig +import app.opass.ccip.network.models.fastpass.Attendee +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TicketViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val portalHelper: PortalHelper +): ViewModel() { + + private val TAG = TicketViewModel::class.java.simpleName + + private val currentEventId = context.sharedPreferences.getString(CURRENT_EVENT_ID, null) + private val currentTokenId = context.sharedPreferences.getString(CURRENT_EVENT_TOKEN, null) + + private val _eventConfig: MutableStateFlow = MutableStateFlow(null) + val eventConfig = _eventConfig.asStateFlow() + + private val _token: MutableStateFlow = MutableStateFlow(currentTokenId) + val token = _token.asStateFlow() + + private val _attendee: MutableStateFlow = MutableStateFlow(null) + val attendee = _attendee.asStateFlow() + + private val _isVerifying = MutableStateFlow(false) + val isVerifying = _isVerifying.asStateFlow() + + init { + if (!currentEventId.isNullOrBlank() && !currentTokenId.isNullOrBlank()) { + getAttendee(currentEventId, currentTokenId) + } + } + + fun getEventConfig(eventId: String, forceReload: Boolean = false) { + viewModelScope.launch { + try { + _eventConfig.value = portalHelper.getEventConfig(eventId, forceReload) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch event config", exception) + _eventConfig.value = null + } + } + } + + fun getAttendee(eventId: String, token: String, forceReload: Boolean = false) { + viewModelScope.launch { + try { + _isVerifying.value = true + _attendee.value = portalHelper.getAttendee(eventId, token, forceReload) + + // Save the token if it was valid + if (_attendee.value != null) { + context.sharedPreferences.edit { putString(CURRENT_EVENT_TOKEN, token) } + _token.value = token + } + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch event config", exception) + _attendee.value = null + } finally { + _isVerifying.value = false + } + } + } + + fun logout(eventId: String, token: String) { + viewModelScope.launch { + portalHelper.deleteAttendee(eventId, token) + context.sharedPreferences.edit { remove(CURRENT_EVENT_TOKEN) } + _attendee.value = null + _token.value = null + } + } +} diff --git a/androidApp/src/main/java/app/opass/ccip/android/utils/Preferences.kt b/androidApp/src/main/java/app/opass/ccip/android/utils/Preferences.kt index 15fa701..6cf2af7 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/utils/Preferences.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/utils/Preferences.kt @@ -8,4 +8,5 @@ package app.opass.ccip.android.utils object Preferences { const val CURRENT_EVENT_ID = "CURRENT_EVENT_ID" + const val CURRENT_EVENT_TOKEN = "CURRENT_EVENT_TOKEN" } diff --git a/androidApp/src/main/res/drawable/ic_gallery.xml b/androidApp/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 0000000..f136f22 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/androidApp/src/main/res/drawable/ic_keyboard.xml b/androidApp/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000..d189f22 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/androidApp/src/main/res/drawable/ic_logout.xml b/androidApp/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..5da3772 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index f2f42e2..c7a0d65 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -19,7 +19,6 @@ Venue Sponsors Staff - Ticket WiFi IRC @@ -29,4 +28,12 @@ Session + + + Ticket + Scan QR + Upload from Gallery + Token + Verifying ticket + Enter the token manually that you may have received via email or organizers.