From ff38b24ae1faf13da469f2b13887e95647f5edb1 Mon Sep 17 00:00:00 2001 From: Zakir Sheikh Date: Sun, 15 Sep 2024 22:51:49 +0530 Subject: [PATCH] [FEAT] Added logic to show What's new. [FEAT] Replaced layouts in NavigationSuiteScaffold with measurePolicies. - This will ensure the state of expendables, etc., will be preserved during orientation changes. [FEAT] Enhanced Toast with new features. - Toast now handles the back-button. - Supports expanded state; click to expand. - Supports swipe to dismiss. - Fixed toast now using mutex. - Renders at the top of everything during shared transition. [FEAT] Introduced showPlatformToast in context. - Now everything depends on the Context.showPlatformToast for showing Android toasts. - It also supports `Duration`. --- app/build.gradle.kts | 4 +- app/src/main/java/com/zs/gallery/Gallery.kt | 70 +++--- .../main/java/com/zs/gallery/MainActivity.kt | 30 ++- .../com/zs/gallery/common/SystemFacade.kt | 16 +- .../java/com/zs/gallery/impl/KoinViewModel.kt | 12 +- app/src/main/res/values/what_s_new.xml | 35 +++ .../src/main/java/com/zs/foundation/Utils.kt | 42 +++- .../adaptive/NavigationSuiteScaffold.kt | 182 ++++++++------- .../java/com/zs/foundation/toast/Toast.kt | 208 +++++++++++++----- .../com/zs/foundation/toast/ToastHostState.kt | 15 +- 10 files changed, 389 insertions(+), 225 deletions(-) create mode 100644 app/src/main/res/values/what_s_new.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d33f952..06cb680 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.googol.android.apps.photos" minSdk = 24 targetSdk = 35 - versionCode = 20 - versionName = "0.1.0-dev2" + versionCode = 21 + versionName = "0.1.0-dev21" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/zs/gallery/Gallery.kt b/app/src/main/java/com/zs/gallery/Gallery.kt index fb2857a..d28ded0 100644 --- a/app/src/main/java/com/zs/gallery/Gallery.kt +++ b/app/src/main/java/com/zs/gallery/Gallery.kt @@ -337,44 +337,42 @@ private fun NavigationBar( navController: NavController, modifier: Modifier = Modifier, ) { - val routes = remember { - movableContentOf { - // Get the current navigation destination from NavController - val current by navController.currentBackStackEntryAsState() - val colors = NavigationItemDefaults.navigationItemColors() - val domain = current?.destination?.domain - val facade = LocalSystemFacade.current - - // Timeline - NavItem( - label = { Label(text = textResource(R.string.photos)) }, - icon = { Icon(imageVector = Icons.Filled.PhotoLibrary, contentDescription = null) }, - checked = domain == RouteTimeline.domain, - onClick = { facade.launchReviewFlow(); navController.toRoute(RouteTimeline) }, - typeRail = typeRail, - colors = colors - ) + val routes = @Composable { + // Get the current navigation destination from NavController + val current by navController.currentBackStackEntryAsState() + val colors = NavigationItemDefaults.navigationItemColors() + val domain = current?.destination?.domain + val facade = LocalSystemFacade.current + + // Timeline + NavItem( + label = { Label(text = textResource(R.string.photos)) }, + icon = { Icon(imageVector = Icons.Filled.PhotoLibrary, contentDescription = null) }, + checked = domain == RouteTimeline.domain, + onClick = { facade.launchReviewFlow(); navController.toRoute(RouteTimeline) }, + typeRail = typeRail, + colors = colors + ) - // Folders - NavItem( - label = { Label(text = textResource(R.string.folders)) }, - icon = { Icon(imageVector = Icons.Filled.FolderCopy, contentDescription = null) }, - checked = domain == RouteFolders.domain, - onClick = { facade.launchReviewFlow(); navController.toRoute(RouteFolders) }, - typeRail = typeRail, - colors = colors - ) + // Folders + NavItem( + label = { Label(text = textResource(R.string.folders)) }, + icon = { Icon(imageVector = Icons.Filled.FolderCopy, contentDescription = null) }, + checked = domain == RouteFolders.domain, + onClick = { facade.launchReviewFlow(); navController.toRoute(RouteFolders) }, + typeRail = typeRail, + colors = colors + ) - // Settings - NavItem( - label = { Label(text = textResource(R.string.settings)) }, - icon = { Icon(imageVector = Icons.Filled.Settings, contentDescription = null) }, - checked = domain == RouteSettings.domain, - onClick = { facade.launchReviewFlow(); navController.toRoute(RouteSettings) }, - typeRail = typeRail, - colors = colors - ) - } + // Settings + NavItem( + label = { Label(text = textResource(R.string.settings)) }, + icon = { Icon(imageVector = Icons.Filled.Settings, contentDescription = null) }, + checked = domain == RouteSettings.domain, + onClick = { facade.launchReviewFlow(); navController.toRoute(RouteSettings) }, + typeRail = typeRail, + colors = colors + ) } // Get the current theme colors diff --git a/app/src/main/java/com/zs/gallery/MainActivity.kt b/app/src/main/java/com/zs/gallery/MainActivity.kt index 4f08269..4872b57 100644 --- a/app/src/main/java/com/zs/gallery/MainActivity.kt +++ b/app/src/main/java/com/zs/gallery/MainActivity.kt @@ -24,7 +24,6 @@ import android.content.res.Configuration import android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG import android.hardware.biometrics.BiometricManager.Authenticators.DEVICE_CREDENTIAL import android.hardware.biometrics.BiometricPrompt -import android.hardware.biometrics.BiometricPrompt.AuthenticationCallback import android.os.Build import android.os.Bundle import android.os.CancellationSignal @@ -35,6 +34,7 @@ import androidx.activity.compose.setContent import androidx.annotation.RequiresApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Downloading +import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.NonRestartableComposable @@ -69,6 +69,7 @@ import com.primex.core.getText2 import com.primex.core.runCatching import com.primex.preferences.Key import com.primex.preferences.Preferences +import com.primex.preferences.intPreferenceKey import com.primex.preferences.longPreferenceKey import com.primex.preferences.observeAsState import com.primex.preferences.value @@ -81,7 +82,6 @@ import com.zs.gallery.common.getPackageInfoCompat import com.zs.gallery.files.RouteTimeline import com.zs.gallery.lockscreen.RouteLockScreen import com.zs.gallery.settings.Settings -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn @@ -90,7 +90,7 @@ import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes -import android.widget.Toast as PlatformToast +import com.zs.foundation.showPlatformToast as showAndroidToast private const val TAG = "MainActivity" @@ -114,6 +114,10 @@ private val STANDARD_REVIEW_DELAY = 5.days private val KEY_LAST_REVIEW_TIME = longPreferenceKey(TAG + "_last_review_time", 0) +private val KEY_APP_VERSION_CODE = + intPreferenceKey(TAG + "_app_version_code", -1) + + /** * @property inAppUpdateProgress A simple property that represents the progress of the in-app update. * The progress value is a float between 0.0 and 1.0, indicating the percentage of the @@ -200,12 +204,6 @@ class MainActivity : ComponentActivity(), SystemFacade, NavController.OnDestinat } } - override fun showPlatformToast(string: Int) = - PlatformToast.makeText(this, getString(string), PlatformToast.LENGTH_SHORT).show() - - override fun showPlatformToast(string: String) = - PlatformToast.makeText(this, string, PlatformToast.LENGTH_SHORT).show() - @RequiresApi(Build.VERSION_CODES.P) override fun authenticate(subtitle: String?, desc: String?, onAuthenticated: () -> Unit) { Log.d(TAG, "preparing to show authentication dialog.") @@ -257,6 +255,12 @@ class MainActivity : ComponentActivity(), SystemFacade, NavController.OnDestinat ) } + override fun showPlatformToast(message: String, duration: Int) = + showAndroidToast(message, duration) + + override fun showPlatformToast(message: Int, duration: Int) = + showAndroidToast(message, duration) + override fun getDeviceService(name: String): T = getSystemService(name) as T @@ -391,6 +395,14 @@ class MainActivity : ComponentActivity(), SystemFacade, NavController.OnDestinat flow1.combine(flow2) { _, _ -> enableEdgeToEdge() } .launchIn(scope = lifecycleScope) lifecycleScope.launch { launchUpdateFlow() } + + // show what's new message on click. + val versionCode = BuildConfig.VERSION_CODE + val savedVersionCode = preferences.value(KEY_APP_VERSION_CODE) + if (savedVersionCode != versionCode){ + preferences[KEY_APP_VERSION_CODE] = versionCode + showToast(R.string.what_s_new_latest, duration = Toast.DURATION_INDEFINITE) + } } override fun launchUpdateFlow(report: Boolean) { diff --git a/app/src/main/java/com/zs/gallery/common/SystemFacade.kt b/app/src/main/java/com/zs/gallery/common/SystemFacade.kt index 1d38c07..a1d137e 100644 --- a/app/src/main/java/com/zs/gallery/common/SystemFacade.kt +++ b/app/src/main/java/com/zs/gallery/common/SystemFacade.kt @@ -205,22 +205,14 @@ interface SystemFacade { } /** - * Displays a toast message using the platform's default toast mechanism. - * - * This function shows a short toast message with the provided text. - * - * @param string The text message to display in the toast. + * @see com.zs.foundation.showPlatformToast */ - fun showPlatformToast(string: String) + fun showPlatformToast(message: String, @Duration duration: Int = Toast.DURATION_SHORT) /** - * Displays a toast message using the platform's default toast mechanism. - * - * This function shows a short toast message with the text from the specified string resource. - * - * @param string The string resource ID of the text message to display in the toast. + * @see com.zs.foundation.showPlatformToast */ - fun showPlatformToast(@StringRes string: Int) + fun showPlatformToast(@StringRes message: Int, @Duration duration: Int = Toast.DURATION_SHORT) /** * Returns the handle to a system-level service by name. diff --git a/app/src/main/java/com/zs/gallery/impl/KoinViewModel.kt b/app/src/main/java/com/zs/gallery/impl/KoinViewModel.kt index a05636f..a54c275 100644 --- a/app/src/main/java/com/zs/gallery/impl/KoinViewModel.kt +++ b/app/src/main/java/com/zs/gallery/impl/KoinViewModel.kt @@ -35,7 +35,7 @@ import com.zs.foundation.toast.ToastHostState import org.koin.androidx.scope.ScopeViewModel import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.component.inject -import android.widget.Toast as AndroidToast +import com.zs.foundation.showPlatformToast as showAndroidToast private const val TAG = "KoinViewModel" @@ -47,12 +47,14 @@ abstract class KoinViewModel: ScopeViewModel() { private val context: Application by inject() fun showPlatformToast( - @StringRes message: Int - ) = AndroidToast.makeText(context, message, AndroidToast.LENGTH_LONG).show() + @StringRes message: Int, + @Duration duration: Int = Toast.DURATION_SHORT + ) = context.showAndroidToast(message, duration) fun showPlatformToast( - message: String - ) = AndroidToast.makeText(context, message, AndroidToast.LENGTH_LONG).show() + message: String, + @Duration duration: Int = Toast.DURATION_SHORT + ) = context.showAndroidToast(message, duration) suspend fun showToast( message: CharSequence, diff --git a/app/src/main/res/values/what_s_new.xml b/app/src/main/res/values/what_s_new.xml new file mode 100644 index 0000000..ef7cb28 --- /dev/null +++ b/app/src/main/res/values/what_s_new.xml @@ -0,0 +1,35 @@ + + + + + What\'s new + + \nšŸž Bug fixes and performance enhancements + \nāœØ Enhanced Toast/Snackbar: Now supports swipe to dismiss and expandability + \nIn Focus + \n1. šŸŽ„ In-App Media Player. + \n2. Support for various file types: GIF, SVG, etc. + \n3. General performance improvements + + + + + + 0.1.0-dev20 + + \nšŸŒŸ Thanks for using our app! ā¤ļøšŸ˜ + \nšŸŒŸ Biometric Authentication: Supports Android 9+. + \nšŸŒŸ UI Updates: We\'ve made improvements to the design and navigation. + \nšŸŒŸ Introduced Favourites and Recycle Bin in Folders + \nšŸŒŸ Bug Fixes: We\'ve resolved issues with loading and app lock. + \nšŸŒŸ Some minor bug fixes + + + + + + + @string/what_s_new_latest + @string/what_s_new_20_dev20 + + \ No newline at end of file diff --git a/foundation/src/main/java/com/zs/foundation/Utils.kt b/foundation/src/main/java/com/zs/foundation/Utils.kt index 3c7023b..e77de29 100644 --- a/foundation/src/main/java/com/zs/foundation/Utils.kt +++ b/foundation/src/main/java/com/zs/foundation/Utils.kt @@ -2,11 +2,12 @@ package com.zs.foundation import android.content.Context import android.content.pm.PackageManager +import androidx.annotation.StringRes import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract +import com.zs.foundation.toast.Duration +import com.zs.foundation.toast.Toast +import android.widget.Toast as AndroidWidgetToast /** * Checks if a given permission is granted for the application in the current context. @@ -49,4 +50,37 @@ inline fun Modifier.thenIf(condition: Boolean, crossinline value: Modifier.() -> returns() implies (condition) } return if (condition) this then Modifier.value() else this -}*/ \ No newline at end of file +}*/ + +/** + * Shows a platform Toast message with the given text. + * + * This function uses the standard Android Toast class to display a short message to the user. + * + * @param message The text message to display in the Toast. + * @param duration The duration of the Toast. Must be either [Toast.DURATION_SHORT] or [Toast.DURATION_LONG]. + */ +fun Context.showPlatformToast(message: String, @Duration duration: Int = Toast.DURATION_SHORT) { + // Ensure the duration is valid + require(duration == Toast.DURATION_SHORT || duration == Toast.DURATION_LONG) { + "Duration must be either Toast.DURATION_SHORT or Toast.DURATION_LONG" + } + // Create and show the Toast + val toastDuration = if (duration == Toast.DURATION_SHORT) AndroidWidgetToast.LENGTH_SHORT else AndroidWidgetToast.LENGTH_LONG + AndroidWidgetToast.makeText(this, message, toastDuration).show() +} + +/** + * @see showPlatformToast + */ +fun Context.showPlatformToast( + @StringRes message: Int, + @Duration duration: Int = Toast.DURATION_SHORT +) { + require(duration == Toast.DURATION_SHORT || duration == Toast.DURATION_LONG) { + "Duration must be either Toast.DURATION_SHORT or Toast.DURATION_LONG" + } + // Create and show the Toast + val toastDuration = if (duration == Toast.DURATION_SHORT) AndroidWidgetToast.LENGTH_SHORT else AndroidWidgetToast.LENGTH_LONG + AndroidWidgetToast.makeText(this, message, toastDuration).show() +} diff --git a/foundation/src/main/java/com/zs/foundation/adaptive/NavigationSuiteScaffold.kt b/foundation/src/main/java/com/zs/foundation/adaptive/NavigationSuiteScaffold.kt index 2716b53..58683b0 100644 --- a/foundation/src/main/java/com/zs/foundation/adaptive/NavigationSuiteScaffold.kt +++ b/foundation/src/main/java/com/zs/foundation/adaptive/NavigationSuiteScaffold.kt @@ -39,6 +39,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.UiComposable import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import com.zs.foundation.toast.ToastHost import com.zs.foundation.toast.ToastHostState @@ -70,19 +75,82 @@ val WindowInsets.Companion.contentInsets */ private val STANDARD_SPACING = 8.dp +/** + * A flexible Scaffold composable for navigation-based layouts. + * + * This composable provides a basic structure for screens within a navigation flow, + * including a content area, optional navigation bar, toast host, and progress indicator. + * + * @param vertical Whether the layout should be arranged vertically (true) or horizontally (false). + * @param content The main content of the screen. + * @param modifier Modifier to be applied to the Scaffold layout. + * @param hideNavigationBar Whether to hide the navigation bar. + * @param background Background color for the Scaffold. + * @param contentColor Content color for the main content area. + * @param toastHostState State for managing toasts (similar to Snackbars). + * @param progress Progress value for the progress indicator (NaN for no indicator, -1 for indeterminate). + * @param navBar Composable function that provides the navigation bar content. + */ @Composable -private inline fun Vertical( - content: @UiComposable @Composable () -> Unit, - crossinline onNewInsets: (PaddingValues) -> Unit, +fun NavigationSuiteScaffold( + vertical: Boolean, + content: @Composable () -> Unit, modifier: Modifier = Modifier, + hideNavigationBar: Boolean = false, + background: Color = MaterialTheme.colors.background, + contentColor: Color = contentColorFor(backgroundColor = background), + toastHostState: ToastHostState = remember(::ToastHostState), + @FloatRange(0.0, 1.0) progress: Float = Float.NaN, + navBar: @Composable () -> Unit, ) { - // Insets for the system navigation bar. This will be used to adjust - // the position of elements when the navigation bar is hidden. - val systemNavBarInsets = WindowInsets.navigationBars + // Compose all the individual elements of the Scaffold into a single composable + val (insets, onNewInsets) = + remember { mutableStateOf(EmptyPadding) } + val navBarInsets = WindowInsets.navigationBars Layout( - content = content, - modifier = modifier, - ) { measurables, c -> + modifier = modifier + .background(background) + .fillMaxSize(), + measurePolicy = remember(vertical, navBarInsets) { + when{ + vertical -> NavVerticalMeasurePolicy(navBarInsets, onNewInsets) + else -> NavHorizontalMeasurePolicy(navBarInsets, onNewInsets) + } + }, + content = { + // Provide the content color for the main content + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalContentInsets provides insets, + content = content + ) + // Conditionally display the navigation bar based on + // 'hideNavigationBar' + // Display the navigation bar (either bottom bar or navigation rail) + when { + // Don't show anything. + hideNavigationBar -> Spacer(modifier = Modifier) + else -> navBar() + } + // Display the Snackbar using the provided channel + ToastHost(toastHostState) + // Conditionally display the progress bar based on the 'progress' value + // Show an indeterminate progress bar when progress is -1 + // Show a determinate progress bar when progress is between 0 and 1 + when { + progress == -1f -> LinearProgressIndicator() + !progress.isNaN() -> LinearProgressIndicator(progress = progress) + else -> Spacer(modifier = Modifier) + } + } + ) +} + +private class NavVerticalMeasurePolicy( + private val insets: WindowInsets, + private val onNewInsets: (PaddingValues) -> Unit +): MeasurePolicy { + override fun MeasureScope.measure(measurables: List, c: Constraints): MeasureResult { val width = c.maxWidth; val height = c.maxHeight // Measure the size requirements of each child element, allowing @@ -98,7 +166,7 @@ private inline fun Vertical( // and report through onNewIntent onNewInsets(PaddingValues(bottom = navBarPlaceable.height.toDp())) // Place the content - layout(width, height) { + return layout(width, height){ var x = 0; var y = 0 // Place the main content at the top, filling the space up to the navigation bar @@ -113,7 +181,7 @@ private inline fun Vertical( progressBarPlaceable.placeRelative(x, y) // we only need bottom insets since we are placing above the navBar val insetBottom = - if (navBarPlaceable.height == 0) systemNavBarInsets.getBottom(density = this@Layout) else 0 + if (navBarPlaceable.height == 0) insets.getBottom(density = this@measure) else 0 // Place Toast at the centre bottom of the screen // remove nav bar offset from it. x = width / 2 - toastPlaceable.width / 2 // centre @@ -125,18 +193,11 @@ private inline fun Vertical( } } -@Composable -private inline fun Horizontal( - content: @UiComposable @Composable () -> Unit, - modifier: Modifier = Modifier, -) { - // Insets for the system navigation bar. This will be used to adjust - // the position of elements when the navigation bar is hidden. - val systemNavBarInsets = WindowInsets.navigationBars - Layout( - content = content, - modifier = modifier, - ) { measurables, c -> +private class NavHorizontalMeasurePolicy( + private val insets: WindowInsets, + private val onNewInsets: (PaddingValues) -> Unit +): MeasurePolicy { + override fun MeasureScope.measure(measurables: List, c: Constraints): MeasureResult { val width = c.maxWidth; val height = c.maxHeight // Measure the size requirements of each child element @@ -150,7 +211,11 @@ private inline fun Horizontal( val contentWidth = width - navBarPlaceable.width constraints = c.copy(minWidth = contentWidth, maxWidth = contentWidth) val contentPlaceable = measurables[INDEX_CONTENT].measure(constraints) - layout(width, height) { + // Calculate the insets for the content. + // and report through onNewIntent + // onNewInsets(PaddingValues(start = navBarPlaceable.width.toDp())) + // Place the content + return layout(width, height) { var x = 0; var y = 0 // place nav_bar from top at the start of the screen @@ -164,78 +229,11 @@ private inline fun Horizontal( y = (height - progressBarPlaceable.height) progressBarPlaceable.placeRelative(x, y) // Place toast above the system navigationBar at the centre of the screen. - val insetBottom = systemNavBarInsets.getBottom(density = this@Layout) + val insetBottom = insets.getBottom(density = this@measure) x = width / 2 - toastPlaceable.width / 2 // centre // full height - toaster height - navbar - 16dp padding + navbar offset. y = (height - toastPlaceable.height - STANDARD_SPACING.roundToPx() - insetBottom) toastPlaceable.placeRelative(x, y) } } -} - -/** - * A flexible Scaffold composable for navigation-based layouts. - * - * This composable provides a basic structure for screens within a navigation flow, - * including a content area, optional navigation bar, toast host, and progress indicator. - * - * @param vertical Whether the layout should be arranged vertically (true) or horizontally (false). - * @param content The main content of the screen. - * @param modifier Modifier to be applied to the Scaffold layout. - * @param hideNavigationBar Whether to hide the navigation bar. - * @param background Background color for the Scaffold. - * @param contentColor Content color for the main content area. - * @param toastHostState State for managing toasts (similar to Snackbars). - * @param progress Progress value for the progress indicator (NaN for no indicator, -1 for indeterminate). - * @param navBar Composable function that provides the navigation bar content. - */ -@Composable -fun NavigationSuiteScaffold( - vertical: Boolean, - content: @Composable () -> Unit, - modifier: Modifier = Modifier, - hideNavigationBar: Boolean = false, - background: Color = MaterialTheme.colors.background, - contentColor: Color = contentColorFor(backgroundColor = background), - toastHostState: ToastHostState = remember(::ToastHostState), - @FloatRange(0.0, 1.0) progress: Float = Float.NaN, - navBar: @Composable () -> Unit, -) { - // Compose all the individual elements of the Scaffold into a single composable - val (insets, onNewInsets) = remember { mutableStateOf(EmptyPadding) } - val composed = @Composable { - // Provide the content color for the main content - CompositionLocalProvider( - LocalContentColor provides contentColor, - LocalContentInsets provides insets, - content = content - ) - // Conditionally display the navigation bar based on - // 'hideNavigationBar' - // Display the navigation bar (either bottom bar or navigation rail) - when { - // Don't show anything. - hideNavigationBar -> Spacer(modifier = Modifier) - else -> navBar() - } - // Display the Snackbar using the provided channel - ToastHost(toastHostState) - // Conditionally display the progress bar based on the 'progress' value - // Show an indeterminate progress bar when progress is -1 - // Show a determinate progress bar when progress is between 0 and 1 - when { - progress == -1f -> LinearProgressIndicator() - !progress.isNaN() -> LinearProgressIndicator(progress = progress) - else -> Spacer(modifier = Modifier) - } - } - // Apply background color and fill the available space with the Scaffold - val finalModifier = modifier - .background(background) - .fillMaxSize() - // Choose the layout based on 'vertical' flag - when (vertical) { - true -> Vertical(content = composed, onNewInsets, modifier = finalModifier) - else -> Horizontal(content = composed, modifier = finalModifier) - } } \ No newline at end of file diff --git a/foundation/src/main/java/com/zs/foundation/toast/Toast.kt b/foundation/src/main/java/com/zs/foundation/toast/Toast.kt index 3a2362c..6f85a5b 100644 --- a/foundation/src/main/java/com/zs/foundation/toast/Toast.kt +++ b/foundation/src/main/java/com/zs/foundation/toast/Toast.kt @@ -18,34 +18,54 @@ package com.zs.foundation.toast +import androidx.activity.compose.BackHandler import androidx.annotation.IntDef +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Surface -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.SwipeToDismiss +import androidx.compose.material.rememberDismissState import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.AccessibilityManager -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.primex.core.ImageBrush import com.primex.core.SignalWhite import com.primex.core.composableOrNull +import com.primex.core.thenIf +import com.primex.core.verticalFadingEdge +import com.primex.core.visualEffect +import com.primex.material2.Button import com.primex.material2.Label import com.primex.material2.ListTile import com.primex.material2.TextButton import com.zs.foundation.AppTheme +import com.zs.foundation.Colors +import com.zs.foundation.renderInSharedTransitionScopeOverlay import kotlinx.coroutines.CancellableContinuation import kotlin.coroutines.resume @@ -177,73 +197,143 @@ internal fun Toast.toMillis( } -private fun Modifier.indicatior(color: Color) = this then Modifier.drawBehind { - drawRect(color = color, size = size.copy(width = 4.dp.toPx())) -} +private val EXPANDED_TOAST_SHAPE = RoundedCornerShape(10) +private val TOAST_SHAPE = RoundedCornerShape(16) + +private inline val Colors.toastBackgroundColor + @Composable + get() = if (isLight) Color(0xFF0E0E0F) else AppTheme.colors.background(1.dp) +/** + * A custom Toast composable that provides a richer experience compared to the standard Android Toast. + * + * This Toast supports features like expandable content, swipe to dismiss, back_press handle and action buttons. + * It is designed to be customizable in terms of colors, shapes, and content. + * + * @param value The [Toast] data class containing the message, action, and other relevant information. + * @param modifier The [Modifier] to be applied to the Toast composable. + * @param backgroundColor The background color of the Toast. + * @param contentColor The content color (text, icons) of the Toast. + * @param actionColor The color for action buttons. + */ @OptIn(ExperimentalMaterialApi::class) @Composable -fun Toast( - state: Toast, +internal fun Toast( + value: Toast, modifier: Modifier = Modifier, - shape: Shape = AppTheme.shapes.small, - backgroundColor: Color = if (AppTheme.colors.isLight) - Color(0xFF0E0E0F) - else - AppTheme.colors.background(1.dp), + backgroundColor: Color = AppTheme.colors.toastBackgroundColor, contentColor: Color = Color.SignalWhite, - actionColor: Color = state.accent.takeOrElse { AppTheme.colors.accent }, - elevation: Dp = 6.dp, + actionColor: Color = value.accent.takeOrElse { AppTheme.colors.accent }, ) { - Surface( - // fill whole width and add some padding. - modifier = modifier - .padding(horizontal = 16.dp) - .sizeIn(minHeight = 56.dp, maxWidth = 400.dp, minWidth = 360.dp), - shape = shape, - elevation = elevation, - color = backgroundColor, - contentColor = contentColor, - content = { + // State to track if Toast is expanded + var isExpanded: Boolean by remember { mutableStateOf(false) } + // Handle back press to dismiss expanded Toast or the entire Toast + BackHandler { if (isExpanded) isExpanded = false else value.dismiss() } + // State for swipe-to-dismiss gesture + val dismissState = rememberDismissState( + confirmStateChange = { + val confirm = !isExpanded // Dismiss only if not expanded + if (confirm) value.action() // Execute action if confirmed + confirm + } + ) + + // SwipeToDismiss composable for handling swipe gesture + SwipeToDismiss( + dismissState, + background = {}, + modifier = modifier.renderInSharedTransitionScopeOverlay(1.0f), + dismissContent = { + // Shape of the Toast based on expanded state + val shape = if (isExpanded) EXPANDED_TOAST_SHAPE else TOAST_SHAPE ListTile( - // draw the indicator. - modifier = Modifier.indicatior(actionColor), - centerAlign = false, - color = Color.Transparent, - headline = { - Label( - text = state.message, - color = LocalContentColor.current, - style = AppTheme.typography.bodyMedium, - maxLines = 6, - ) - }, - leading = composableOrNull(state.icon != null) { - // TODO: It might case the problems. - val icon = state.icon!! + shape = shape, + color = backgroundColor, + onColor = contentColor, + modifier = Modifier + .padding(horizontal = 16.dp) + // Size constraints for the Toast + .sizeIn(360.dp, 56.dp, 400.dp, 340.dp) + // Toggle expanded state on click + .clickable(indication = null, interactionSource = null) { + isExpanded = !isExpanded + } + .animateContentSize() + // Apply border and visual effect if dark theme + .thenIf(!AppTheme.colors.isLight) { + border(0.7.dp, Color.SignalWhite.copy(0.08f), shape) + .visualEffect(ImageBrush.NoiseBrush, 0.2f, overlay = true) + }, + leading = composableOrNull(value.icon != null) { + // FixMe: It might cause problems. + val icon = value.icon!! Icon( painter = rememberVectorPainter(image = icon), contentDescription = null, tint = actionColor ) }, - trailing = composableOrNull(state.action != null) { - if (state.action != null) - TextButton( - label = state.action!!, - onClick = state::action, - colors = ButtonDefaults.textButtonColors( - contentColor = actionColor - ) - ) - else - com.primex.material2.IconButton( - onClick = { state.dismiss() }, - imageVector = Icons.Outlined.Close, - contentDescription = null + // Trailing action button if available and not expanded + trailing = composableOrNull(value.action != null && !isExpanded) { + TextButton( + label = value.action!!, + onClick = value::action, + colors = ButtonDefaults.textButtonColors( + contentColor = actionColor ) + ) + }, + // Toast message + headline = { + Label( + text = value.message, + color = contentColor, + style = AppTheme.typography.bodyMedium, + // Limit lines when not expanded + maxLines = if (!isExpanded) 2 else Int.MAX_VALUE, + modifier = Modifier + // Max height constraint + .heightIn(max = 185.dp) + .thenIf(isExpanded) { + val state = rememberScrollState() + verticalFadingEdge(backgroundColor, state, 16.dp) + .verticalScroll(state) + } + ) + }, + // Footer with action buttons when expanded + footer = composableOrNull(isExpanded) { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + content = { + // Cancel button + TextButton( + stringResource(android.R.string.cancel), + value::dismiss, + modifier = Modifier.scale(0.9f), + colors = ButtonDefaults.textButtonColors(contentColor = contentColor), + shape = AppTheme.shapes.compact + ) + // Action button if available + val action = value.action + if (action != null) + Button( + label = action, + onClick = value::action, + colors = ButtonDefaults.buttonColors( + contentColor = actionColor, + backgroundColor = actionColor.copy(0.12f) + ), + modifier = Modifier.scale(0.9f), + shape = AppTheme.shapes.compact, + elevation = null + ) + } + ) } ) - }, + } ) } \ No newline at end of file diff --git a/foundation/src/main/java/com/zs/foundation/toast/ToastHostState.kt b/foundation/src/main/java/com/zs/foundation/toast/ToastHostState.kt index 1fe51db..2b6a74c 100644 --- a/foundation/src/main/java/com/zs/foundation/toast/ToastHostState.kt +++ b/foundation/src/main/java/com/zs/foundation/toast/ToastHostState.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalAccessibilityManager import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * A class that manages the display of [Toast] messages, ensuring only one Toast is shown at a time. @@ -62,12 +63,14 @@ class ToastHostState { accent: Color = Color.Unspecified, @Duration duration: Int = if (action == null) Toast.DURATION_SHORT else Toast.DURATION_INDEFINITE ): @Result Int { - try { - return suspendCancellableCoroutine { continuation -> - current = Data(icon, message, duration, action, accent, continuation) + mutex.withLock { + try { + return suspendCancellableCoroutine { continuation -> + current = Data(icon, message, duration, action, accent, continuation) + } + } finally { + current = null } - } finally { - current = null } } } @@ -90,7 +93,7 @@ fun ToastHost( } FadeInFadeOutWithScale( current = state.current, modifier = modifier, content = { - Toast(state = it) + Toast(it) } ) } \ No newline at end of file