taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 2dfcf9f43a9e10711e93e6db630c374fb37ed2c8
parent 9d3174d8469d3d57838c2faa5f2bc3bbc6462b05
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu,  7 Nov 2024 15:09:05 +0100

[wallet] Improve user interaction of Taler action button

Diffstat:
Mwallet/src/main/java/net/taler/wallet/MainFragment.kt | 125+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 22+++++++++++++++++++---
Mwallet/src/main/java/net/taler/wallet/compose/DemandAttention.kt | 30++++++++++++++++++++++--------
Mwallet/src/main/proto/user_prefs.proto | 1+
4 files changed, 123 insertions(+), 55 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -23,7 +23,7 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -60,7 +60,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -79,6 +78,7 @@ import androidx.fragment.compose.FragmentState import androidx.fragment.compose.rememberFragmentState import androidx.navigation.fragment.findNavController import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import net.taler.wallet.balances.BalanceState import net.taler.wallet.balances.BalancesComposable import net.taler.wallet.balances.ScopeInfo @@ -96,7 +96,7 @@ class MainFragment: Fragment() { private val model: MainViewModel by activityViewModels() - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) + @OptIn(ExperimentalMaterial3Api::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -115,6 +115,7 @@ class MainFragment: Fragment() { val selectedScope by model.transactionManager.selectedScope.collectAsStateLifecycleAware() val txResult by remember(selectedScope) { model.transactionManager.transactionsFlow(selectedScope) }.collectAsStateLifecycleAware() val selectedSpec = remember(selectedScope) { selectedScope?.let { model.balanceManager.getSpecForScopeInfo(it) } } + val actionButtonUsed by remember { model.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) Scaffold( bottomBar = { @@ -126,47 +127,17 @@ class MainFragment: Fragment() { onClick = { tab = Tab.BALANCES }, ) - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { PlainTooltip { Text(stringResource(R.string.actions)) } }, - state = rememberTooltipState(), - ) { - var offsetY by remember { mutableFloatStateOf(0f) } - - DemandAttention { - LargeFloatingActionButton( - modifier = Modifier - .requiredSize(86.dp) - .padding(8.dp) - .offset { IntOffset(0, offsetY.roundToInt() / 6) } - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - if (delta < 0) { offsetY += delta } - }, - onDragStopped = { - offsetY = 0.0f - onScanQr() - }, - ), - shape = CircleShape, - onClick = { showSheet = true }, - ) { - if (offsetY == 0.0f) { - Icon( - painterResource(R.drawable.ic_actions), - modifier = Modifier.size(38.dp), - contentDescription = stringResource(R.string.actions), - ) - } else { - Icon( - painterResource(R.drawable.ic_scan_qr), - contentDescription = stringResource(R.string.actions), - ) - } - } - } - } + TalerActionButton( + demandAttention = !actionButtonUsed, + onShowSheet = { + showSheet = true + model.saveActionButtonUsed(context) + }, + onScanQr = { + onScanQr() + model.saveActionButtonUsed(context) + }, + ) NavigationBarItem( icon = { Icon(Icons.Default.Settings, contentDescription = null) }, @@ -251,6 +222,8 @@ class MainFragment: Fragment() { } } + + override fun onStart() { super.onStart() model.balanceManager.loadBalances() @@ -317,6 +290,70 @@ fun SettingsView( ) } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun TalerActionButton( + demandAttention: Boolean, + onShowSheet: () -> Unit, + onScanQr: () -> Unit, +) { + val tooltipState = rememberTooltipState() + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(stringResource(R.string.actions)) } }, + state = tooltipState, + ) { + val offsetY = remember { Animatable(0f) } + var cancelled by remember { mutableStateOf(false) } + + DemandAttention(demandAttention = demandAttention) { + LargeFloatingActionButton( + modifier = Modifier + .requiredSize(86.dp) + .padding(8.dp) + .offset { IntOffset(0, offsetY.value.roundToInt() / 6) } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + runBlocking { offsetY.snapTo(offsetY.value + delta) } + if (delta > 0) { + cancelled = true + } + }, + onDragStopped = { + offsetY.animateTo(0.0f) + if (!cancelled) { + onScanQr() + } + cancelled = false + }, + ), + shape = CircleShape, + onClick = { onShowSheet() }, + ) { + if (offsetY.value == 0.0f) { + Icon( + painterResource(R.drawable.ic_actions), + modifier = Modifier.size(38.dp), + contentDescription = stringResource(R.string.actions), + ) + } else { + Icon( + painterResource(R.drawable.ic_scan_qr), + contentDescription = stringResource(R.string.actions), + ) + } + } + } + } + + LaunchedEffect(demandAttention) { + if (demandAttention) { + tooltipState.show() + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TalerActionsModal( diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -281,9 +281,9 @@ class MainViewModel( } } - fun getSelectedScope(c: Context) = c.userPreferencesDataStore.data.map { scope -> - if (scope.hasSelectedScope()) { - ScopeInfo.fromPrefs(scope.selectedScope) + fun getSelectedScope(c: Context) = c.userPreferencesDataStore.data.map { prefs -> + if (prefs.hasSelectedScope()) { + ScopeInfo.fromPrefs(prefs.selectedScope) } else { null } @@ -302,6 +302,22 @@ class MainViewModel( } } } + + fun getActionButtonUsed(c: Context) = c.userPreferencesDataStore.data.map { prefs -> + if (prefs.hasActionButtonUsed()) { + prefs.actionButtonUsed + } else { + false + } + } + + fun saveActionButtonUsed(c: Context) = viewModelScope.launch { + c.userPreferencesDataStore.updateData { current -> + current.toBuilder() + .setActionButtonUsed(true) + .build() + } + } } enum class ScanQrContext { diff --git a/wallet/src/main/java/net/taler/wallet/compose/DemandAttention.kt b/wallet/src/main/java/net/taler/wallet/compose/DemandAttention.kt @@ -31,31 +31,45 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay @Composable -fun DemandAttention(content: @Composable () -> Unit) { +fun DemandAttention( + initialDelayMillis: Long = 400, + wiggleWidth: Float = 5f, + wiggleCount: Int = 2, + demandAttention: Boolean = true, + content: @Composable () -> Unit, +) { val offsetX = remember { Animatable(0f) } - LaunchedEffect(Unit) { - delay(400) + suspend fun wiggle() { offsetX.animateTo( - targetValue = -5f, - animationSpec = tween(90, easing = LinearEasing), + targetValue = -wiggleWidth, + animationSpec = tween(80, easing = LinearEasing), ) offsetX.animateTo( - targetValue = 5f, + targetValue = wiggleWidth, animationSpec = repeatable( iterations = 5, - animation = tween(80, easing = LinearEasing), + animation = tween(90, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ) ) offsetX.animateTo( targetValue = 0f, - animationSpec = tween(90, easing = LinearEasing), + animationSpec = tween(80, easing = LinearEasing), ) } + LaunchedEffect(demandAttention) { + if (demandAttention) { + delay(initialDelayMillis) + repeat(wiggleCount) { + wiggle() + } + } + } + Box(Modifier.offset(offsetX.value.dp, 0.dp)) { content() } diff --git a/wallet/src/main/proto/user_prefs.proto b/wallet/src/main/proto/user_prefs.proto @@ -17,4 +17,5 @@ message PrefsScopeInfo { message UserPreferences { optional PrefsScopeInfo selectedScope = 1; + optional bool actionButtonUsed = 2; } \ No newline at end of file