diff --git a/androidApp/src/main/java/app/opass/ccip/android/MainActivity.kt b/androidApp/src/main/java/app/opass/ccip/android/MainActivity.kt index f55ce3b..ad30a4b 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/MainActivity.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/MainActivity.kt @@ -10,11 +10,11 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.navigation.compose.rememberNavController +import app.opass.ccip.android.ui.extensions.currentEventId import app.opass.ccip.android.ui.extensions.sharedPreferences import app.opass.ccip.android.ui.navigation.Screen import app.opass.ccip.android.ui.navigation.SetupNavGraph import app.opass.ccip.android.ui.theme.OPassTheme -import app.opass.ccip.android.utils.Preferences.CURRENT_EVENT_ID import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -24,7 +24,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) - val currentEventId = sharedPreferences.getString(CURRENT_EVENT_ID, null) + val currentEventId = sharedPreferences.currentEventId setContent { OPassTheme { diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/components/TopAppBar.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/components/TopAppBar.kt index fe2cfb6..6dd57aa 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/components/TopAppBar.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/components/TopAppBar.kt @@ -5,6 +5,8 @@ package app.opass.ccip.android.ui.components +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -15,25 +17,41 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @Composable @OptIn(ExperimentalMaterial3Api::class) fun TopAppBar( title: String = String(), + subtitle: String = String(), navHostController: NavHostController? = null, navigationIcon: @Composable () -> Unit = { DefaultNavigationIcon(navHostController) }, - actions: @Composable() (RowScope.() -> Unit) = {} + actions: @Composable (RowScope.() -> Unit) = {} ) { CenterAlignedTopAppBar( title = { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLarge - ) + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall + ) + } + } }, navigationIcon = navigationIcon, actions = actions 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/extensions/SharedPreferences.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/extensions/SharedPreferences.kt new file mode 100644 index 0000000..28998ae --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/extensions/SharedPreferences.kt @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2024 OPass + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.opass.ccip.android.ui.extensions + +import android.content.SharedPreferences +import androidx.core.content.edit + +private const val CURRENT_EVENT_ID = "CURRENT_EVENT_ID" +private const val TOKEN = "TOKEN" + +val SharedPreferences.currentEventId: String? + get() = this.getString(CURRENT_EVENT_ID, null) + +fun SharedPreferences.saveCurrentEventId(eventId: String) { + return this.edit { putString(CURRENT_EVENT_ID, eventId) } +} + +fun SharedPreferences.getToken(eventId: String): String? { + return this.getString("${eventId}_$TOKEN", null) +} + +fun SharedPreferences.saveToken(eventId: String, token: String) { + return this.edit { putString("${eventId}_$TOKEN", token) } +} + +fun SharedPreferences.removeToken(eventId: String) { + return this.edit { remove("${eventId}_$TOKEN") } +} 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..2937e8c 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 @@ -77,6 +77,7 @@ fun Screen.Event.EventScreen( val windowWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass val context = LocalContext.current val eventConfig by viewModel.eventConfig.collectAsStateWithLifecycle() + val attendee by viewModel.attendee.collectAsStateWithLifecycle() var shouldShowBottomSheet by rememberSaveable { mutableStateOf(false) } @@ -87,6 +88,7 @@ fun Screen.Event.EventScreen( topBar = { TopAppBar( title = eventConfig?.name ?: String(), + subtitle = attendee?.userId ?: String(), navigationIcon = { IconButton(onClick = { shouldShowBottomSheet = true }) { Icon( @@ -127,8 +129,14 @@ fun Screen.Event.EventScreen( maxItemsInEachRow = if (windowWidth == WindowWidthSizeClass.COMPACT) 4 else 6, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - // TODO: Show and hide features based on roles eventConfig!!.features.fastForEach { feature -> + + // Return early if feature is limited to certain attendee roles + // Roles requires attendee to be logged in by verifying their ticket + if (!feature.roles.isNullOrEmpty() && !feature.roles!!.contains(attendee?.role)) { + return@fastForEach + } + when (feature.type) { FeatureType.ANNOUNCEMENT -> { FeatureItem( @@ -202,7 +210,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 +270,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/event/EventViewModel.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventViewModel.kt index 1ca9f88..c2d60b4 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventViewModel.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/event/EventViewModel.kt @@ -5,13 +5,18 @@ package app.opass.ccip.android.ui.screens.event +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.opass.ccip.android.ui.extensions.getToken +import app.opass.ccip.android.ui.extensions.sharedPreferences import app.opass.ccip.helpers.PortalHelper import app.opass.ccip.network.models.eventconfig.EventConfig +import app.opass.ccip.network.models.fastpass.Attendee import app.opass.ccip.network.models.schedule.Schedule import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,7 +26,8 @@ import java.text.SimpleDateFormat @HiltViewModel class EventViewModel @Inject constructor( val sdf: SimpleDateFormat, - private val portalHelper: PortalHelper + private val portalHelper: PortalHelper, + @ApplicationContext private val context: Context ): ViewModel() { private val TAG = EventViewModel::class.java.simpleName @@ -32,6 +38,9 @@ class EventViewModel @Inject constructor( private val _schedule: MutableStateFlow = MutableStateFlow(null) val schedule = _schedule.asStateFlow() + private val _attendee: MutableStateFlow = MutableStateFlow(null) + val attendee = _attendee.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) val isRefreshing = _isRefreshing.asStateFlow() @@ -40,6 +49,9 @@ class EventViewModel @Inject constructor( try { _isRefreshing.value = true _eventConfig.value = portalHelper.getEventConfig(eventId, forceReload) + + // Fetch attendee as well + getAttendee(eventId, forceReload) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch event config", exception) _eventConfig.value = null @@ -59,4 +71,20 @@ class EventViewModel @Inject constructor( } } } + + private fun getAttendee(eventId: String, forceReload: Boolean = false) { + viewModelScope.launch { + try { + val token = context.sharedPreferences.getToken(eventId) + if (token != null) { + _attendee.value = portalHelper.getAttendee(eventId, token, forceReload) + } else { + _attendee.value = null + } + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch attendee", exception) + _attendee.value = null + } + } + } } 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..5a8a867 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 @@ -48,16 +48,15 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.saveCurrentEventId import app.opass.ccip.android.ui.extensions.sharedPreferences import app.opass.ccip.android.ui.extensions.shimmer import app.opass.ccip.android.ui.navigation.Screen -import app.opass.ccip.android.utils.Preferences.CURRENT_EVENT_ID import app.opass.ccip.network.models.event.Event import coil.compose.SubcomposeAsyncImage import coil.request.CachePolicy @@ -94,13 +93,8 @@ fun Screen.EventPreview.EventPreviewScreen( items(items = list!!, key = { e -> e.id }) { event: Event -> 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 - } + sharedPreferences.saveCurrentEventId(event.id) + navHostController.popBackToEventScreen(event.id) } } } @@ -160,7 +154,7 @@ fun Screen.EventPreview.EventPreviewScreen( key = { e -> e.id } ) { event: Event -> EventPreviewItem(name = event.name, logoUrl = event.logoUrl) { - sharedPreferences.edit { putString(CURRENT_EVENT_ID, event.id) } + sharedPreferences.saveCurrentEventId(event.id) navHostController.navigate(Screen.Event(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..12d89da --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt @@ -0,0 +1,328 @@ +/* + * 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.BackHandler +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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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() +) { + + // Always pop back to reflect latest status of attendee + BackHandler { + navHostController.popBackToEventScreen(this.eventId) + } + + 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, + 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) + + // Tip + HelpSection() + + // 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 HelpSection() { + Card( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_qr_code), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + text = stringResource(R.string.ticket_verification_help_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.ticket_verification_help_desc), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} + +@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..67c7c9d --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketViewModel.kt @@ -0,0 +1,87 @@ +/* + * 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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.opass.ccip.android.ui.extensions.currentEventId +import app.opass.ccip.android.ui.extensions.getToken +import app.opass.ccip.android.ui.extensions.removeToken +import app.opass.ccip.android.ui.extensions.saveToken +import app.opass.ccip.android.ui.extensions.sharedPreferences +import app.opass.ccip.helpers.PortalHelper +import app.opass.ccip.network.models.eventconfig.EventConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +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.currentEventId + private val currentTokenId = context.sharedPreferences.getToken(currentEventId ?: "") + + private val _eventConfig: MutableStateFlow = MutableStateFlow(null) + val eventConfig = _eventConfig.asStateFlow() + + private val _token: MutableStateFlow = MutableStateFlow(currentTokenId) + val token = _token.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 + if (portalHelper.getAttendee(eventId, token, forceReload) != null) { + context.sharedPreferences.saveToken(eventId, token) + _token.value = token + } + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch attendee", exception) + _token.value = null + } finally { + _isVerifying.value = false + } + } + } + + fun logout(eventId: String, token: String) { + viewModelScope.launch { + portalHelper.deleteAttendee(eventId, token) + context.sharedPreferences.removeToken(eventId) + _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 deleted file mode 100644 index 15fa701..0000000 --- a/androidApp/src/main/java/app/opass/ccip/android/utils/Preferences.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 OPass - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.opass.ccip.android.utils - -object Preferences { - - const val CURRENT_EVENT_ID = "CURRENT_EVENT_ID" -} 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..56586e7 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,14 @@ Session + + + Ticket + Scan QR + Upload from Gallery + Token + Enter the token manually that you may have received via email or organizers. + Verifying ticket + Please verify your ticket + The ticket is usually in your email\'s inbox or spam folder. In case you haven\'t received it, please contact the event organisers.