Skip to content

Commit

Permalink
Merge pull request #8148 from thunderbird/qr_code_scanner
Browse files Browse the repository at this point in the history
Add QR code scanner UI (part 1)
  • Loading branch information
cketti authored Sep 26, 2024
2 parents dec799c + 0c3885a commit 7812165
Show file tree
Hide file tree
Showing 20 changed files with 705 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import androidx.compose.runtime.State
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest

/**
* Interface for a unidirectional view model with side-effects ([EFFECT]). It has a [STATE] and can handle [EVENT]'s.
Expand Down Expand Up @@ -92,7 +91,7 @@ inline fun <reified STATE, EVENT, EFFECT> UnidirectionalViewModel<STATE, EVENT,
val dispatch: (EVENT) -> Unit = { event(it) }

LaunchedEffect(key1 = effect) {
effect.collectLatest {
effect.collect {
handleEffect(it)
}
}
Expand Down
10 changes: 9 additions & 1 deletion feature/migration/qrcode/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id(ThunderbirdPlugins.Library.android)
// TODO: Change to ThunderbirdPlugins.Library.androidCompose when integrating the feature into the app.
id(ThunderbirdPlugins.App.androidCompose)
}

android {
Expand All @@ -9,6 +10,13 @@ android {

dependencies {
implementation(projects.core.common)

implementation(projects.core.ui.compose.designsystem)
debugImplementation(projects.core.ui.compose.theme2.k9mail)

implementation(libs.moshi)
implementation(libs.timber)

testImplementation(projects.core.ui.compose.testing)
testImplementation(projects.core.ui.compose.theme2.k9mail)
}
31 changes: 31 additions & 0 deletions feature/migration/qrcode/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TODO: This file only exists for manual testing during development. Remove when integrating the feature into the app.
-->
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>

<application
android:name=".ui.QrCodeApplication"
tools:ignore="MissingApplicationIcon"
>

<activity
android:name=".ui.QrCodeScannerActivity"
android:exported="true"
android:theme="@style/Theme.Material3.Light.NoActionBar"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

</activity>

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package app.k9mail.feature.migration.qrcode.ui

import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State

class NoOpQrCodeScannerViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), QrCodeScannerContract.ViewModel {
override fun event(event: Event) = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package app.k9mail.feature.migration.qrcode.ui

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.core.ui.compose.designsystem.atom.Surface

@PreviewScreenSizes
@Composable
fun PermissionDeniedContentPreview() {
PreviewWithTheme {
Surface {
PermissionDeniedContent(
onGoToSettingsClick = {},
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package app.k9mail.feature.migration.qrcode.ui

import android.app.Application
import android.content.Context
import app.k9mail.feature.migration.qrcode.qrCodeModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin

// TODO: This only exists for manual testing during development. Remove when integrating the feature into the app.
class QrCodeApplication : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)

startKoin {
androidContext(this@QrCodeApplication)
modules(qrCodeModule)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package app.k9mail.feature.migration.qrcode.ui

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import app.k9mail.core.ui.compose.common.activity.setActivityContent
import app.k9mail.core.ui.compose.theme2.k9mail.K9MailTheme2

// TODO: This only exists for manual testing during development. Remove when integrating the feature into the app.
class QrCodeScannerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

enableEdgeToEdge()

setActivityContent {
K9MailTheme2 {
QrCodeScannerScreen()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package app.k9mail.feature.migration.qrcode.ui

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState

@Preview
@Composable
fun QrCodeScannerScreenPreview_permission_unknown() {
PreviewWithTheme {
QrCodeScannerScreen(
viewModel = NoOpQrCodeScannerViewModel(
initialState = State(cameraPermissionState = UiPermissionState.Unknown),
),
)
}
}

@Preview
@Composable
fun QrCodeScannerScreenPreview_permission_granted() {
PreviewWithTheme {
QrCodeScannerScreen(
viewModel = NoOpQrCodeScannerViewModel(
initialState = State(cameraPermissionState = UiPermissionState.Granted),
),
)
}
}

@Preview
@Composable
fun QrCodeScannerScreenPreview_permission_denied() {
PreviewWithTheme {
QrCodeScannerScreen(
viewModel = NoOpQrCodeScannerViewModel(
initialState = State(cameraPermissionState = UiPermissionState.Denied),
),
)
}
}
7 changes: 7 additions & 0 deletions feature/migration/qrcode/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.CAMERA"/>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package app.k9mail.feature.migration.qrcode

import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module

val qrCodeModule = module {
viewModel { QrCodeScannerViewModel() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package app.k9mail.feature.migration.qrcode.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveContent
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.feature.migration.qrcode.R

@Composable
internal fun PermissionDeniedContent(
onGoToSettingsClick: () -> Unit,
) {
ResponsiveContent(
modifier = Modifier.testTag("PermissionDeniedContent"),
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxHeight()
.padding(MainTheme.spacings.double),
) {
TextTitleLarge(text = stringResource(R.string.migration_qrcode_permission_denied_title))
Spacer(modifier = Modifier.height(MainTheme.spacings.double))

TextBodyLarge(text = stringResource(R.string.migration_qrcode_permission_denied_message))
Spacer(modifier = Modifier.height(MainTheme.spacings.triple))

ButtonFilled(
text = stringResource(R.string.migration_qrcode_go_to_settings_button_text),
onClick = onGoToSettingsClick,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.testTag("GoToSettingsButton"),
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package app.k9mail.feature.migration.qrcode.ui

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState

@Composable
internal fun QrCodeScannerContent(
state: State,
onEvent: (Event) -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
) {
when (state.cameraPermissionState) {
UiPermissionState.Unknown -> {
// Display empty surface while we're waiting for the camera permission request to return a result
}
UiPermissionState.Granted -> {
QrCodeScannerView()
}
UiPermissionState.Denied -> {
PermissionDeniedContent(
onGoToSettingsClick = { onEvent(Event.GoToSettingsClicked) },
)
}
UiPermissionState.Waiting -> {
// We've launched Android's app info screen and are now waiting for the user to return to our app.

LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
// Once the user has returned to the app, notify the view model about it.
onEvent(Event.ReturnedFromAppInfoScreen)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package app.k9mail.feature.migration.qrcode.ui

import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel

interface QrCodeScannerContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>

data class State(
val cameraPermissionState: UiPermissionState = UiPermissionState.Unknown,
)

sealed interface Event {
data object StartScreen : Event
data class CameraPermissionResult(val success: Boolean) : Event
data object GoToSettingsClicked : Event
data object ReturnedFromAppInfoScreen : Event
}

sealed interface Effect {
data object RequestCameraPermission : Effect
data object GoToAppInfoScreen : Effect
}

enum class UiPermissionState {
Unknown,
Granted,
Denied,
Waiting,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package app.k9mail.feature.migration.qrcode.ui

import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import org.koin.androidx.compose.koinViewModel
import timber.log.Timber

@Composable
fun QrCodeScannerScreen(
viewModel: QrCodeScannerContract.ViewModel = koinViewModel<QrCodeScannerViewModel>(),
) {
val cameraPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { success ->
viewModel.event(Event.CameraPermissionResult(success))
}

val context = LocalContext.current

val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
Effect.RequestCameraPermission -> cameraPermissionLauncher.requestCameraPermission()
Effect.GoToAppInfoScreen -> context.goToAppInfoScreen()
}
}

LaunchedEffect(key1 = Unit) {
dispatch(Event.StartScreen)
}

QrCodeScannerContent(
state = state.value,
onEvent = dispatch,
)
}

private fun Context.goToAppInfoScreen() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}

try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e(e, "Error opening Android's app settings")
}
}

private fun ManagedActivityResultLauncher<String, Boolean>.requestCameraPermission() {
launch(Manifest.permission.CAMERA)
}
Loading

0 comments on commit 7812165

Please sign in to comment.