diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 7d8d1fb..fd8d912 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -89,4 +89,9 @@ dependencies { ksp(libs.google.hilt.android.compiler) implementation(libs.androidx.hilt.navigation) implementation(libs.google.hilt.android.core) + + // ZXing/Camera (QR) + implementation(libs.androidx.camera) + implementation(libs.zxing.core) + implementation(libs.zxing.cpp) } diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 802da28..2468fe8 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -6,7 +6,18 @@ + + + + + + + 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..8c478af --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt @@ -0,0 +1,391 @@ +/* + * 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.Image +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.BottomAppBar +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.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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.graphics.asImageBitmap +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 app.opass.ccip.android.utils.CommonUtil.setBrightness +import app.opass.ccip.android.utils.ZXingUtil +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 context = LocalContext.current + val token by viewModel.token.collectAsStateWithLifecycle() + if (!token.isNullOrBlank()) { + DisposableEffect(Unit) { + setBrightness(context, isFull = true) + onDispose { + setBrightness(context, isFull = false) + } + } + 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) + ) + } + } + ) + }, + bottomBar = { BrightnessControl() } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // QR + Card( + modifier = Modifier.padding(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Image( + bitmap = ZXingUtil.generateQR(token!!).asImageBitmap(), + contentDescription = null + ) + } + } + } +} + +@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 BrightnessControl() { + val context = LocalContext.current + var isOverridingBrightness by rememberSaveable { mutableStateOf(true) } + + BottomAppBar { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_brightness_high), + contentDescription = null + ) + Text(text = "Auto-Brighten") + } + Switch( + checked = isOverridingBrightness, + onCheckedChange = { + isOverridingBrightness = it + setBrightness(context, it) + } + ) + } + } +} + +@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.bodyMedium + ) + } + } + } +} + +@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), + singleLine = true + ) + } + }, + 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/CommonUtil.kt b/androidApp/src/main/java/app/opass/ccip/android/utils/CommonUtil.kt new file mode 100644 index 0000000..27c57ad --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/utils/CommonUtil.kt @@ -0,0 +1,16 @@ +package app.opass.ccip.android.utils + +import android.app.Activity +import android.content.Context +import android.view.WindowManager + +object CommonUtil { + + fun setBrightness(context: Context, isFull: Boolean) { + val activity = context as? Activity ?: return + val layoutParams: WindowManager.LayoutParams = activity.window.attributes + layoutParams.screenBrightness = + if (isFull) 1.0F else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + activity.window.attributes = layoutParams + } +} 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/java/app/opass/ccip/android/utils/ZXingUtil.kt b/androidApp/src/main/java/app/opass/ccip/android/utils/ZXingUtil.kt new file mode 100644 index 0000000..bba236b --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/utils/ZXingUtil.kt @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2024 OPass + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.opass.ccip.android.utils + +import android.graphics.Bitmap +import android.graphics.Color +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter + +object ZXingUtil { + + private const val BITMAP_QR_HEIGHT = 512 + private const val BITMAP_QR_WIDTH = 512 + + fun generateQR(token: String): Bitmap { + val bitMatrix = MultiFormatWriter() + .encode(token, BarcodeFormat.QR_CODE, BITMAP_QR_WIDTH, BITMAP_QR_HEIGHT) + + val width = bitMatrix.width + val height = bitMatrix.height + val pixels = IntArray(width * height) + + for (y in 0 until height) { + val offset = y * width + for (x in 0 until width) { + pixels[offset + x] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE + } + } + + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, width, 0, 0, width, height) + } + } +} diff --git a/androidApp/src/main/res/drawable/ic_brightness_high.xml b/androidApp/src/main/res/drawable/ic_brightness_high.xml new file mode 100644 index 0000000..1c6fc0c --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_brightness_high.xml @@ -0,0 +1,14 @@ + + + + + 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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be7c551..9339350 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ coroutines = "1.9.0" androidx-activityCompose = "1.9.2" androidx-adaptiveAndroid = "1.1.0-alpha04" androidx-browser = "1.8.0" +androidx-camera = "1.3.4" androidx-composeNavigation = "2.8.2" androidx-hilt = "1.2.0" androidx-lifecycle = "2.8.6" @@ -22,12 +23,15 @@ hilt = "2.52" markdown = "0.26.0" material = "1.12.0" serialization = "1.7.2" +zxing-core = "3.5.3" +zxing-cpp = "2.2.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-adaptive-android = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "androidx-adaptiveAndroid" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } +androidx-camera = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" } androidx-hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-composeNavigation" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } @@ -53,6 +57,8 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } mikepenz-markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" } +zxing-cpp = { module = "io.github.zxing-cpp:android", version.ref = "zxing-cpp" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }