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 f8732d3..37696d2 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 @@ -201,7 +201,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 -> { @@ -255,6 +257,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/ticket/TicketScreen.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt new file mode 100644 index 0000000..51a08c2 --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt @@ -0,0 +1,198 @@ +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.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +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.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.shimmer +import app.opass.ccip.android.ui.navigation.Screen +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import kotlinx.coroutines.android.awaitFrame + +@Composable +fun Screen.Ticket.TicketScreen( + navHostController: NavHostController, + viewModel: TicketViewModel = hiltViewModel() +) { + + val context = LocalContext.current + var shouldShowManualEntryDialog by rememberSaveable { mutableStateOf(false) } + val eventConfig by viewModel.eventConfig.collectAsStateWithLifecycle() + val startActivityForResult = rememberLauncherForActivityResult( + contract = PickVisualMedia(), + onResult = { uri -> + // TODO: Process the image + } + ) + + LaunchedEffect(key1 = Unit) { viewModel.getEventConfig(this@TicketScreen.eventId) } + + if (shouldShowManualEntryDialog) { + ManualEntryDialog(onDismiss = { shouldShowManualEntryDialog = false }) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = stringResource(this.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) + .build(), + contentDescription = "", + 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 ManualEntryDialog(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 = { + // TODO: Process the image + onDismiss() + } + ) { + 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..1d48340 --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketViewModel.kt @@ -0,0 +1,40 @@ +package app.opass.ccip.android.ui.screens.ticket + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.opass.ccip.helpers.PortalHelper +import app.opass.ccip.network.models.eventconfig.EventConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TicketViewModel @Inject constructor( + private val portalHelper: PortalHelper +): ViewModel() { + + private val TAG = TicketViewModel::class.java.simpleName + + private val _eventConfig: MutableStateFlow = MutableStateFlow(null) + val eventConfig = _eventConfig.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() + + fun getEventConfig(eventId: String, forceReload: Boolean = false) { + viewModelScope.launch { + try { + _isRefreshing.value = true + _eventConfig.value = portalHelper.getEventConfig(eventId, forceReload) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch event config", exception) + _eventConfig.value = null + } finally { + _isRefreshing.value = false + } + } + } +} 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/values/strings.xml b/androidApp/src/main/res/values/strings.xml index f2f42e2..8288025 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,11 @@ Session + + + Ticket + Scan QR + Upload from Gallery + Token + Enter the token manually that you may have received via email directly.