Skip to content

Commit f6f6711

Browse files
committed
Merge branch 'feat/desktop' into 'master'
Restyle invitation scanner + Cleanup dependencies See merge request fmasa/wfrp-master!222
2 parents 326494c + 930ff08 commit f6f6711

File tree

7 files changed

+160
-102
lines changed

7 files changed

+160
-102
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ android {
9191
"-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi" +
9292
"-Xopt-in=androidx.compose.animation.ExperimentalFoundationApi" +
9393
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi" +
94+
"-Xopt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi" +
9495
"-P" +
9596
"plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"
9697
}
@@ -111,7 +112,7 @@ dependencies {
111112
implementation("com.google.android.gms:play-services-auth:19.0.0")
112113

113114
// Permission management
114-
implementation("com.sagar:coroutinespermission:2.0.3")
115+
implementation("com.google.accompanist:accompanist-permissions:0.20.0")
115116

116117
// QR code scanning
117118
implementation("com.google.zxing:core:3.3.3")

app/core/build.gradle.kts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ android {
1111
//
1212
// Firestore emulator setup
1313
//
14-
val propertiesFile = File("local.properties")
15-
16-
val properties = if (propertiesFile.exists())
14+
val properties = if (File("local.properties").exists())
1715
loadProperties("local.properties")
1816
else Properties()
1917

@@ -51,27 +49,26 @@ dependencies {
5149
api("androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07")
5250

5351
// Koin
54-
api("org.koin:koin-android:2.2.0")
55-
api("org.koin:koin-androidx-viewmodel:2.2.0")
52+
api("io.insert-koin:koin-android:3.1.2")
5653

5754
// Coil - image library
5855
implementation("io.coil-kt:coil-compose:1.3.2")
5956

6057
// Firebase-related dependencies
61-
api("com.google.firebase:firebase-analytics:19.0.0")
62-
api("com.firebaseui:firebase-ui-auth:6.2.0")
63-
api("com.google.firebase:firebase-firestore-ktx:23.0.1")
64-
api("com.google.firebase:firebase-analytics-ktx:19.0.0")
65-
api("com.google.firebase:firebase-crashlytics:18.1.0")
66-
api("com.google.firebase:firebase-dynamic-links-ktx:20.1.0")
67-
api("com.google.firebase:firebase-functions-ktx:20.0.1")
58+
api(platform("com.google.firebase:firebase-bom:28.4.2"))
59+
api("com.google.firebase:firebase-analytics-ktx")
60+
api("com.google.firebase:firebase-auth-ktx")
61+
api("com.google.firebase:firebase-firestore-ktx")
62+
api("com.google.firebase:firebase-crashlytics")
63+
api("com.google.firebase:firebase-dynamic-links-ktx")
64+
api("com.google.firebase:firebase-functions-ktx")
6865

6966
// Logging
7067
api("com.jakewharton.timber:timber:4.7.1")
7168

7269
// Coroutines
73-
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
74-
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")
70+
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
71+
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
7572
api("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.3.5")
7673
api("androidx.lifecycle:lifecycle-livedata-ktx:2.3.1")
7774

@@ -88,17 +85,15 @@ dependencies {
8885
api("com.revenuecat.purchases:purchases:4.0.2")
8986

9087
// Ads
91-
api("com.google.android.gms:play-services-ads:20.2.0")
88+
api("com.google.android.gms:play-services-ads:20.4.0")
9289

9390
// Shared Preferences DataStore
94-
implementation("androidx.datastore:datastore-preferences:1.0.0-alpha05")
91+
implementation("androidx.datastore:datastore-preferences:1.0.0")
9592
implementation("com.google.firebase:firebase-auth-ktx:21.0.1")
9693

9794
// HTTP Client
9895
val ktorVersion = "1.6.0"
9996
implementation("io.ktor:ktor-client-core:$ktorVersion")
10097
implementation("io.ktor:ktor-client-cio:$ktorVersion")
10198
implementation("io.ktor:ktor-client-serialization:$ktorVersion")
102-
103-
api("com.google.accompanist:accompanist-flowlayout:0.12.0")
10499
}

app/core/src/main/java/cz/frantisekmasa/wfrp_master/core/viewModel/SettingsViewModel.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package cz.frantisekmasa.wfrp_master.core.viewModel
33
import android.content.Context
44
import androidx.compose.runtime.Composable
55
import androidx.datastore.preferences.core.Preferences
6+
import androidx.datastore.preferences.core.booleanPreferencesKey
67
import androidx.datastore.preferences.core.edit
7-
import androidx.datastore.preferences.core.preferencesKey
8-
import androidx.datastore.preferences.createDataStore
8+
import androidx.datastore.preferences.preferencesDataStore
99
import androidx.lifecycle.LiveData
1010
import androidx.lifecycle.ViewModel
1111
import androidx.lifecycle.asLiveData
@@ -24,6 +24,8 @@ import kotlinx.coroutines.withContext
2424
import org.koin.androidx.viewmodel.ext.android.getViewModel
2525
import timber.log.Timber
2626

27+
private val Context.settingsDataStore by preferencesDataStore("settings")
28+
2729
class SettingsViewModel(
2830
context: Context,
2931
private val parties: PartyRepository,
@@ -35,7 +37,7 @@ class SettingsViewModel(
3537
val soundEnabled: LiveData<Boolean> by lazy { getPreference(AppSettings.SOUND_ENABLED, true) }
3638
val personalizedAds: LiveData<Boolean> by lazy { getPreference(AppSettings.PERSONALIZED_ADS, false) }
3739

38-
private val dataStore = context.createDataStore("settings")
40+
private val dataStore = context.settingsDataStore
3941

4042
suspend fun initializeAds() {
4143
val personalizedAds = refreshPersonalizedAdConsent()
@@ -104,10 +106,10 @@ class SettingsViewModel(
104106
}
105107

106108
private object AppSettings {
107-
val DARK_MODE = preferencesKey<Boolean>("dark_mode")
108-
val SOUND_ENABLED = preferencesKey<Boolean>("sound_enabled")
109-
val GOOGLE_SIGN_IN_DISMISSED = preferencesKey<Boolean>("dismissed_google_sign_in")
110-
val PERSONALIZED_ADS = preferencesKey<Boolean>("personalized_ads")
109+
val DARK_MODE = booleanPreferencesKey("dark_mode")
110+
val SOUND_ENABLED = booleanPreferencesKey("sound_enabled")
111+
val GOOGLE_SIGN_IN_DISMISSED = booleanPreferencesKey("dismissed_google_sign_in")
112+
val PERSONALIZED_ADS = booleanPreferencesKey("personalized_ads")
111113
}
112114

113115
@Composable
Lines changed: 126 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,56 @@
11
package cz.muni.fi.rpg.ui.joinParty
22

33
import android.Manifest
4-
import android.os.Parcelable
5-
import android.widget.Toast
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.net.Uri
7+
import android.provider.Settings
68
import androidx.compose.foundation.layout.Arrangement
79
import androidx.compose.foundation.layout.Column
10+
import androidx.compose.foundation.layout.ColumnScope
811
import androidx.compose.foundation.layout.fillMaxHeight
912
import androidx.compose.foundation.layout.fillMaxSize
13+
import androidx.compose.foundation.layout.fillMaxWidth
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.rememberScrollState
16+
import androidx.compose.foundation.verticalScroll
17+
import androidx.compose.material.MaterialTheme
1018
import androidx.compose.material.Scaffold
1119
import androidx.compose.material.Text
20+
import androidx.compose.material.TextButton
1221
import androidx.compose.material.TopAppBar
1322
import androidx.compose.runtime.Composable
14-
import androidx.compose.runtime.LaunchedEffect
23+
import androidx.compose.runtime.SideEffect
1524
import androidx.compose.runtime.getValue
1625
import androidx.compose.runtime.mutableStateOf
1726
import androidx.compose.runtime.rememberCoroutineScope
1827
import androidx.compose.runtime.saveable.rememberSaveable
1928
import androidx.compose.runtime.setValue
2029
import androidx.compose.ui.Alignment
2130
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.platform.LocalContext
2232
import androidx.compose.ui.res.stringResource
23-
import com.eazypermissions.common.model.PermissionResult
24-
import com.eazypermissions.coroutinespermission.PermissionManager
33+
import androidx.compose.ui.text.style.TextAlign
34+
import com.google.accompanist.permissions.PermissionState
35+
import com.google.accompanist.permissions.rememberPermissionState
2536
import cz.frantisekmasa.wfrp_master.core.domain.party.Invitation
2637
import cz.frantisekmasa.wfrp_master.core.ui.buttons.BackButton
38+
import cz.frantisekmasa.wfrp_master.core.ui.primitives.HorizontalLine
39+
import cz.frantisekmasa.wfrp_master.core.ui.primitives.Spacing
2740
import cz.frantisekmasa.wfrp_master.core.ui.scaffolding.SubheadBar
28-
import cz.frantisekmasa.wfrp_master.core.ui.viewinterop.LocalActivity
2941
import cz.frantisekmasa.wfrp_master.navigation.Route
3042
import cz.frantisekmasa.wfrp_master.navigation.Routing
3143
import cz.muni.fi.rpg.R
32-
import cz.muni.fi.rpg.ui.common.toast
44+
import cz.muni.fi.rpg.viewModels.JoinPartyViewModel
3345
import cz.muni.fi.rpg.viewModels.provideJoinPartyViewModel
3446
import kotlinx.coroutines.launch
35-
import kotlinx.parcelize.Parcelize
3647

3748
@Composable
3849
fun InvitationScannerScreen(routing: Routing<Route>) {
3950
Scaffold(
4051
topBar = {
4152
TopAppBar(
42-
title = { Text(stringResource(R.string.qr_scan_prompt)) },
53+
title = { Text(stringResource(R.string.title_joinParty)) },
4354
navigationIcon = {
4455
BackButton(onClick = { routing.pop() })
4556
},
@@ -54,81 +65,124 @@ fun InvitationScannerScreen(routing: Routing<Route>) {
5465
modifier = Modifier.fillMaxHeight()
5566
) {
5667
val viewModel = provideJoinPartyViewModel()
57-
val coroutineScope = rememberCoroutineScope()
5868

59-
var screenState: InvitationScannerScreenState by rememberSaveable {
60-
mutableStateOf(InvitationScannerScreenState.WaitingForPermissions)
61-
}
69+
var invitation: Invitation? by rememberSaveable { mutableStateOf(null) }
6270

63-
when (val state = screenState) {
64-
InvitationScannerScreenState.WaitingForPermissions -> {
65-
val activity = LocalActivity.current
66-
67-
LaunchedEffect(null) {
68-
val permissionResult = PermissionManager.requestPermissions(
69-
activity,
70-
PermissionRequestCode,
71-
Manifest.permission.CAMERA,
72-
)
73-
74-
when (permissionResult) {
75-
is PermissionResult.PermissionGranted -> {
76-
screenState = InvitationScannerScreenState.Scanning
77-
}
78-
else -> {
79-
// TODO: Add more specific wording for types of denial
80-
// see https://github.com/sagar-viradiya/eazypermissions#coroutines-support
81-
82-
activity.toast(
83-
activity.getString(R.string.error_camera_permission_required),
84-
Toast.LENGTH_LONG
85-
)
86-
87-
routing.pop()
88-
}
89-
}
90-
}
91-
}
92-
InvitationScannerScreenState.Scanning -> {
93-
SubheadBar(stringResource(R.string.qr_scan_prompt))
94-
QrCodeScanner(
95-
modifier = Modifier.fillMaxSize(),
96-
onSuccessfulScan = { qrCodeData ->
97-
coroutineScope.launch {
98-
viewModel.deserializeInvitationJson(qrCodeData)?.let {
99-
screenState =
100-
InvitationScannerScreenState.WaitingForUserConfirmation(it)
101-
}
102-
}
103-
},
104-
)
105-
}
106-
is InvitationScannerScreenState.WaitingForUserConfirmation -> {
71+
when {
72+
invitation != null -> {
10773
InvitationConfirmation(
108-
state.invitation,
74+
invitation!!,
10975
viewModel,
110-
onSuccess = {
111-
routing.pop()
112-
},
113-
onError = {
114-
screenState = InvitationScannerScreenState.Scanning
115-
},
76+
onSuccess = { routing.pop() },
77+
onError = { invitation = null },
11678
)
11779
}
80+
else -> {
81+
Scanner(viewModel, onSuccessfulScan = { invitation = it })
82+
}
11883
}
11984
}
12085
}
12186
}
12287

123-
private sealed class InvitationScannerScreenState : Parcelable {
124-
@Parcelize
125-
object WaitingForPermissions : InvitationScannerScreenState()
88+
@Composable
89+
private fun Scanner(viewModel: JoinPartyViewModel, onSuccessfulScan: (Invitation) -> Unit) {
90+
val coroutineScope = rememberCoroutineScope()
91+
val camera = rememberPermissionState(Manifest.permission.CAMERA)
12692

127-
@Parcelize
128-
object Scanning : InvitationScannerScreenState()
93+
when {
94+
camera.hasPermission -> {
95+
SubheadBar(stringResource(R.string.qr_scan_prompt))
96+
QrCodeScanner(
97+
modifier = Modifier.fillMaxSize(),
98+
onSuccessfulScan = { qrCodeData ->
99+
coroutineScope.launch {
100+
viewModel.deserializeInvitationJson(qrCodeData)
101+
?.let(onSuccessfulScan)
102+
}
103+
},
104+
)
105+
}
106+
!camera.permissionRequested || camera.shouldShowRationale -> PermissionRequestScreen(camera)
107+
else -> PermissionDeniedScreen()
108+
}
109+
}
110+
111+
@Composable
112+
private fun PermissionRequestScreen(camera: PermissionState) {
113+
ScreenBody {
114+
if (!camera.permissionRequested) {
115+
SideEffect { camera.launchPermissionRequest() }
116+
}
129117

130-
@Parcelize
131-
data class WaitingForUserConfirmation(val invitation: Invitation) : InvitationScannerScreenState()
118+
Text(
119+
stringResource(R.string.camera_permission_required),
120+
style = MaterialTheme.typography.h6,
121+
)
122+
123+
Rationale()
124+
125+
TextButton(onClick = { camera.launchPermissionRequest() }) {
126+
Text(stringResource(R.string.button_request_permission).uppercase())
127+
}
128+
129+
Alternative()
130+
}
132131
}
133132

134-
private const val PermissionRequestCode = 10
133+
@Composable
134+
private fun PermissionDeniedScreen() {
135+
ScreenBody {
136+
Text(stringResource(R.string.camera_permission_denied), style = MaterialTheme.typography.h6)
137+
Rationale()
138+
Text(stringResource(R.string.camera_permission_instructions), textAlign = TextAlign.Center)
139+
140+
val context = LocalContext.current
141+
TextButton(onClick = { context.openApplicationSettings() }) {
142+
Text(stringResource(R.string.button_open_settings).uppercase())
143+
}
144+
145+
Alternative()
146+
}
147+
}
148+
149+
@Composable
150+
private inline fun ScreenBody(content: @Composable ColumnScope.() -> Unit) {
151+
Column(
152+
Modifier
153+
.fillMaxWidth()
154+
.padding(Spacing.bodyPadding)
155+
.verticalScroll(rememberScrollState()),
156+
horizontalAlignment = Alignment.CenterHorizontally,
157+
content = content,
158+
)
159+
}
160+
161+
@Composable
162+
private fun Rationale() {
163+
Text(
164+
stringResource(R.string.camera_permission_rationale),
165+
textAlign = TextAlign.Center,
166+
)
167+
}
168+
169+
@Composable
170+
private fun Alternative() {
171+
172+
HorizontalLine()
173+
174+
Text(
175+
stringResource(R.string.camera_permission_alternative),
176+
modifier = Modifier.padding(top = Spacing.mediumLarge),
177+
textAlign = TextAlign.Center,
178+
)
179+
}
180+
181+
private fun Context.openApplicationSettings() {
182+
startActivity(
183+
Intent().apply {
184+
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
185+
data = Uri.fromParts("package", packageName, null)
186+
}
187+
)
188+
}

app/src/main/res/values/strings.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,5 +321,14 @@
321321
<string name="error_file_opening_crashed">Could not open file</string>
322322
<string name="message_avatar_changed">Avatar was changed</string>
323323
<string name="message_avatar_removed">Avatar was removed</string>
324+
<string name="button_open_settings">Open Settings</string>
325+
<string name="camera_permission_denied">Camera permission denied</string>
326+
<string name="camera_permission_required">Camera permission required</string>
327+
<string name="camera_permission_rationale">Access to camera is needed to let you scan QR codes with party invitations.</string>
328+
<string name="camera_permission_alternative">Alternatively you can ask your GM for invitation link.</string>
329+
<string name="camera_permission_instructions">
330+
Please, grant us access on the Settings screen.
331+
</string>
332+
<string name="button_request_permission">Request permission</string>
324333

325334
</resources>

0 commit comments

Comments
 (0)