feat: Add guided tour feature

This commit introduces a guided tour feature to the app.

- A new  has been added to provide an introduction to the app's functionalities.
- A "Show Guided Tour" button has been added to the settings screen, allowing users to revisit the tour.
- Logic has been implemented to display the guided tour automatically on the first launch of the app.
- String resources for the guided tour have been added in both English and German.
- Accompanist Pager dependencies have been added to  for the tour's UI.
- Minor fixes to XML escaping in string resources have been applied.
This commit is contained in:
2025-10-30 17:30:44 +01:00
parent 2cd2e616d4
commit 684fdb290a
9 changed files with 232 additions and 8 deletions

View File

@@ -66,6 +66,7 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.ui.text) // Added for KeyboardOptions
implementation(libs.androidx.compose.material3)
implementation(libs.reorderable)
implementation(libs.androidx.material.icons.extended)
@@ -73,6 +74,9 @@ dependencies {
implementation(libs.google.material)
implementation(libs.compose.markdown)
// Accompanist Pager
implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
// Room Database
implementation(libs.androidx.room.runtime)

View File

@@ -121,6 +121,7 @@ import de.lxtools.noteshop.security.FileEncryptor
import android.util.Log
import androidx.fragment.app.FragmentActivity
import de.lxtools.noteshop.ui.EncryptionPasswordDialog
import de.lxtools.noteshop.ui.GuidedTourScreen
import java.io.FileOutputStream
import javax.crypto.SecretKey
@@ -298,6 +299,7 @@ sealed class Screen(val route: String, val titleRes: Int) : Parcelable {
data object About : Screen("about", R.string.menu_about)
data object Settings : Screen("settings", R.string.menu_settings)
data object WebAppIntegration : Screen("webapp_integration", R.string.webapp_integration_title)
data object GuidedTour : Screen("guided_tour", R.string.show_guided_tour)
}
// Function to get DocumentFile for a given URI and filename
@@ -768,6 +770,14 @@ fun AppShell(
}
}
}
LaunchedEffect(Unit) {
val firstLaunchCompleted = sharedPrefs.getBoolean("first_launch_completed", false)
if (!firstLaunchCompleted) {
currentScreen = Screen.GuidedTour
sharedPrefs.edit { putBoolean("first_launch_completed", true) }
}
}
var selectedListId: Int? by rememberSaveable { mutableStateOf(null) }
var selectedNoteId: Int? by rememberSaveable { mutableStateOf(null) }
var selectedRecipeId: Int? by rememberSaveable { mutableStateOf(null) }
@@ -1893,10 +1903,39 @@ fun AppShell(
)
}
is Screen.WebAppIntegration -> {
val webAppIntegrationViewModel: de.lxtools.noteshop.ui.webapp.WebAppIntegrationViewModel = viewModel(factory = AppViewModelProvider.Factory)
val onAuthenticateAndTogglePasswordVisibility: (Boolean) -> Unit = { isPassword ->
val activity = context.findActivity() as FragmentActivity
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.authenticate_to_perform_action),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
onSuccess = {
if (isPassword) {
webAppIntegrationViewModel.togglePasswordVisibility()
} else {
webAppIntegrationViewModel.toggleDeletePasswordVisibility()
}
},
onFailed = {
android.widget.Toast.makeText(context, R.string.unlock_failed, android.widget.Toast.LENGTH_SHORT).show()
},
onError = { _, _ ->
android.widget.Toast.makeText(context, R.string.unlock_failed, android.widget.Toast.LENGTH_SHORT).show()
}
)
}
de.lxtools.noteshop.ui.webapp.WebAppIntegrationScreen(
viewModel = viewModel(factory = AppViewModelProvider.Factory),
viewModel = webAppIntegrationViewModel,
onNavigateUp = { currentScreen = Screen.Settings },
padding = innerPadding
padding = innerPadding,
onAuthenticateAndTogglePasswordVisibility = onAuthenticateAndTogglePasswordVisibility
)
}
is Screen.GuidedTour -> {
de.lxtools.noteshop.ui.GuidedTourScreen(
onTourFinished = { currentScreen = Screen.Settings }
)
}
}

View File

@@ -0,0 +1,105 @@
package de.lxtools.noteshop.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.accompanist.pager.rememberPagerState
import de.lxtools.noteshop.R
@Composable
fun GuidedTourScreen(onTourFinished: () -> Unit) {
val pagerState = rememberPagerState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
HorizontalPager(
count = 3,
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
when (page) {
0 -> TourPage(
imageRes = R.drawable.noteshop_logo,
title = stringResource(id = R.string.tour_page_1_title),
text = stringResource(id = R.string.tour_page_1_text)
)
1 -> TourPage(
imageRes = R.drawable.noteshop_logo,
title = stringResource(id = R.string.tour_page_2_title),
text = stringResource(id = R.string.tour_page_2_text)
)
2 -> TourPage(
imageRes = R.drawable.noteshop_logo,
title = stringResource(id = R.string.tour_page_3_title),
text = stringResource(id = R.string.tour_page_3_text)
)
}
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
Button(
onClick = onTourFinished,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = if (pagerState.currentPage == 2) "Fertig" else "Überspringen")
}
}
}
@Composable
fun TourPage(imageRes: Int, title: String, text: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = null,
modifier = Modifier.size(200.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
}

View File

@@ -200,6 +200,14 @@ fun SettingsScreen(
}
}
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
Button(onClick = { onNavigate(Screen.GuidedTour) }, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.show_guided_tour))
}
}
item { Spacer(modifier = Modifier.height(24.dp)) }
item { HorizontalDivider() }
item { Spacer(modifier = Modifier.height(24.dp)) }

View File

@@ -4,6 +4,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -15,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
@@ -23,8 +26,11 @@ import de.lxtools.noteshop.R
fun WebAppIntegrationScreen(
viewModel: WebAppIntegrationViewModel,
onNavigateUp: () -> Unit,
padding: PaddingValues
padding: PaddingValues,
onAuthenticateAndTogglePasswordVisibility: (Boolean) -> Unit // New parameter
) {
var showResetConfirmationDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.padding(padding)
@@ -50,14 +56,34 @@ fun WebAppIntegrationScreen(
value = viewModel.password,
onValueChange = viewModel::onPasswordChange,
label = { Text(stringResource(R.string.password_label)) },
visualTransformation = PasswordVisualTransformation(),
visualTransformation = if (viewModel.showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image = if (viewModel.showPassword)
Icons.Filled.Visibility
else Icons.Filled.VisibilityOff
val description = if (viewModel.showPassword) "Hide password" else "Show password"
IconButton(onClick = { onAuthenticateAndTogglePasswordVisibility(true) }) {
Icon(imageVector = image, contentDescription = description)
}
},
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = viewModel.deletePassword,
onValueChange = viewModel::onDeletePasswordChange,
label = { Text(stringResource(R.string.delete_password_label)) },
visualTransformation = PasswordVisualTransformation(),
visualTransformation = if (viewModel.showDeletePassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image = if (viewModel.showDeletePassword)
Icons.Filled.Visibility
else Icons.Filled.VisibilityOff
val description = if (viewModel.showDeletePassword) "Hide password" else "Show password"
IconButton(onClick = { onAuthenticateAndTogglePasswordVisibility(false) }) {
Icon(imageVector = image, contentDescription = description)
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
@@ -70,8 +96,6 @@ fun WebAppIntegrationScreen(
Spacer(modifier = Modifier.height(8.dp)) // Add some space between buttons
var showResetConfirmationDialog by remember { mutableStateOf(false) }
Button(
onClick = { showResetConfirmationDialog = true },
modifier = Modifier.fillMaxWidth(),
@@ -103,4 +127,4 @@ fun WebAppIntegrationScreen(
)
}
}
}
}

View File

@@ -13,6 +13,7 @@ import de.lxtools.noteshop.data.NoteshopRepository
import de.lxtools.noteshop.security.FileEncryptor
import de.lxtools.noteshop.security.KeyManager
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
class WebAppIntegrationViewModel(private val repository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
@@ -30,10 +31,36 @@ class WebAppIntegrationViewModel(private val repository: NoteshopRepository, app
var deletePassword by mutableStateOf("")
private set
private val _showPassword = mutableStateOf(false)
val showPassword: Boolean by _showPassword
private val _showDeletePassword = mutableStateOf(false)
val showDeletePassword: Boolean by _showDeletePassword
init {
loadCredentials()
}
fun togglePasswordVisibility() {
_showPassword.value = !_showPassword.value
if (_showPassword.value) {
viewModelScope.launch {
kotlinx.coroutines.delay(10000) // Hide after 10 seconds
_showPassword.value = false
}
}
}
fun toggleDeletePasswordVisibility() {
_showDeletePassword.value = !_showDeletePassword.value
if (_showDeletePassword.value) {
viewModelScope.launch {
kotlinx.coroutines.delay(10000) // Hide after 10 seconds
_showDeletePassword.value = false
}
}
}
fun onWebAppUrlChange(newUrl: String) {
webAppUrl = newUrl
}

View File

@@ -302,4 +302,12 @@
<string name="web_app_integration_reset_successful">Web-App-Integration erfolgreich zurückgesetzt</string>
<string name="failed_to_decrypt_credentials">Fehler beim Entschlüsseln der Zugangsdaten</string>
<string name="deletion_password_cannot_be_empty">Löschpasswort darf nicht leer sein</string>
<string name="tour_page_1_title">Willkommen bei Noteshop!</string>
<string name="tour_page_1_text">Verwalte deine Notizen, Einkaufslisten und Rezepte an einem Ort.</string>
<string name="tour_page_2_title">Alles organisieren</string>
<string name="tour_page_2_text">Erstelle, bearbeite und kategorisiere deine Notizen und Listen mit Leichtigkeit.</string>
<string name="tour_page_3_title">Sicher &amp; Privat</string>
<string name="tour_page_3_text">Deine Daten gehören dir. Verschlüssele deine Daten für maximale Privatsphäre.</string>
<string name="show_guided_tour">Geführte Tour anzeigen</string>
</resources>

View File

@@ -302,4 +302,12 @@
<string name="web_app_integration_reset_successful">Web App Integration reset successfully</string>
<string name="failed_to_decrypt_credentials">Failed to decrypt credentials</string>
<string name="deletion_password_cannot_be_empty">Deletion password cannot be empty</string>
<string name="tour_page_1_title">Welcome to Noteshop!</string>
<string name="tour_page_1_text">Manage your notes, shopping lists, and recipes all in one place.</string>
<string name="tour_page_2_title">Organize Everything</string>
<string name="tour_page_2_text">Create, edit, and categorize your notes and lists with ease.</string>
<string name="tour_page_3_title">Secure &amp; Private</string>
<string name="tour_page_3_text">Your data is yours. Encrypt your data for maximum privacy.</string>
<string name="show_guided_tour">Show Guided Tour</string>
</resources>

View File

@@ -36,6 +36,7 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } # Added for KeyboardOptions
junit = { module = "junit:junit", version.ref = "junit" }
# Room database libraries