diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7cd101..8a04a7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.flux" minSdk = 29 targetSdk = 37 - versionCode = 19 - versionName = "3.2.2" + versionCode = 20 + versionName = "3.2.3" } dependenciesInfo { @@ -89,6 +89,7 @@ dependencies { implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.core.ktx) implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.hilt.common) implementation(libs.androidx.material3) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 427c84b..99b24fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,7 +23,7 @@ diff --git a/app/src/main/java/com/flux/MainActivity.kt b/app/src/main/java/com/flux/MainActivity.kt index 4e7b864..6c3fbc7 100644 --- a/app/src/main/java/com/flux/MainActivity.kt +++ b/app/src/main/java/com/flux/MainActivity.kt @@ -1,5 +1,8 @@ package com.flux +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent @@ -24,7 +27,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import com.flux.navigation.AppNavHost -import com.flux.navigation.Loader import com.flux.other.Constants.Other import com.flux.other.createNotificationChannel import com.flux.ui.effects.ScreenEffect @@ -54,13 +56,99 @@ class MainActivity : AppCompatActivity() { @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) createNotificationChannel(this) - // Splash screen condition - val splashScreen = installSplashScreen() - splashScreen.setKeepOnScreenCondition { keepSplashScreen.value } + + splashScreen.setKeepOnScreenCondition { + keepSplashScreen.value + } + + // Animated exit transition for the system splash screen. + splashScreen.setOnExitAnimationListener { splashScreenView -> + val iconView = splashScreenView.iconView + + // Initial state + iconView.alpha = 1f + iconView.scaleX = 1f + iconView.scaleY = 1f + iconView.translationY = 0f + + // Subtle "settle" scale + val scaleX = ObjectAnimator.ofFloat( + iconView, + "scaleX", + 1f, + 1.08f, + 0.82f + ) + + val scaleY = ObjectAnimator.ofFloat( + iconView, + "scaleY", + 1f, + 1.08f, + 0.82f + ) + + // Move upward while leaving + val translationY = ObjectAnimator.ofFloat( + iconView, + "translationY", + 0f, + -20f, + -90f + ) + + // Fade slightly later than the movement begins + val fadeOut = ObjectAnimator.ofFloat( + iconView, + "alpha", + 1f, + 1f, + 0f + ) + + AnimatorSet().apply { + + playTogether( + scaleX, + scaleY, + translationY, + fadeOut + ) + + duration = 550L + + // Smooth deceleration rather than AnticipateInterpolator. + interpolator = android.view.animation.PathInterpolator( + 0.2f, + 0f, + 0f, + 1f + ) + + addListener(object : Animator.AnimatorListener { + + override fun onAnimationStart(animation: Animator) = Unit + + override fun onAnimationEnd(animation: Animator) { + splashScreenView.remove() + } + + override fun onAnimationCancel(animation: Animator) { + splashScreenView.remove() + } + + override fun onAnimationRepeat(animation: Animator) = Unit + }) + + start() + } + } enableEdgeToEdge() + setContent { val snackBarHostState = remember { SnackbarHostState() } @@ -89,7 +177,7 @@ class MainActivity : AppCompatActivity() { val progressBoardState by progressBoardViewModel.state.collectAsStateWithLifecycle() val rootChangeState by settingsViewModel.rootChangeState.collectAsStateWithLifecycle() - // Stop splash screen when settings are loaded + // Keep system splash on screen until settings are actually loaded LaunchedEffect(settings.isLoading) { keepSplashScreen.value = settings.isLoading } HandleSideEffects( @@ -98,8 +186,8 @@ class MainActivity : AppCompatActivity() { settingsViewModel.effect ) - if (!settings.isLoading) { - FluxTheme(settings, settingsViewModel::onEvent) { + FluxTheme(settings, settingsViewModel::onEvent) { + if (!settings.isLoading) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLow @@ -135,8 +223,6 @@ class MainActivity : AppCompatActivity() { ) } } - } else { - Loader() } } } diff --git a/app/src/main/java/com/flux/navigation/NavRoutes.kt b/app/src/main/java/com/flux/navigation/NavRoutes.kt index 5494d33..67ec1f1 100644 --- a/app/src/main/java/com/flux/navigation/NavRoutes.kt +++ b/app/src/main/java/com/flux/navigation/NavRoutes.kt @@ -116,7 +116,7 @@ val NotesScreens = states.settings.data.isLintValid, states.settings.data.isLineNumbersVisible, states.settings.data.startWithReadView, - states.notesState.allNotes.find { it.notesId == notesId } ?: NotesModel(workspaceId = workspaceId), + states.notesState.allNotes.find { it.notesId == notesId } ?: NotesModel(workspaceId = workspaceId, notesId = notesId), states.settings.data.storageRootUri, states.labelState.allLabels.filter { it.workspaceId==workspaceId }, viewModel.settingsViewModel, @@ -146,7 +146,7 @@ val HabitScreens = NavRoutes.NewHabit.route + "/{workspaceId}" + "/{habitId}" to { navController, habitId, workspaceId, states, viewModel -> NewHabit( navController, - states.habitState.allHabits.find { it.id == habitId } ?: HabitModel(workspaceId=workspaceId), + states.habitState.allHabits.find { it.id == habitId } ?: HabitModel(workspaceId=workspaceId, id = habitId), states.settings, viewModel.habitViewModel::onEvent ) @@ -173,7 +173,7 @@ val TodoScreens = NewTodoList( navController, states.settings.data.is24HourFormat, - states.todoState.allLists.find { it.id == listId } ?: TodoModel(workspaceId = workspaceId), + states.todoState.allLists.find { it.id == listId } ?: TodoModel(workspaceId = workspaceId, id = listId), workspaceId, viewModel.todoViewModel::onEvent ) @@ -187,7 +187,7 @@ val JournalScreens = navController, states.workspaceState.allWorkspaces, workspaceId, - states.journalState.data.find { it.journalId == journalId } ?: JournalModel(workspaceId = workspaceId, dateTime = journalDateTime), + states.journalState.data.find { it.journalId == journalId } ?: JournalModel(journalId = journalId, workspaceId = workspaceId, dateTime = journalDateTime), states.journalState.outline, states.journalState.textState, states.settings.data.fontNumber, @@ -266,7 +266,7 @@ val EventScreens = NavRoutes.NewEvent.route + "/{workspaceId}" + "/{eventId}" + "/{eventDate}" to { navController, states, viewModels, eventId, workspaceId, _, eventDate -> NewEvent( navController, - states.eventState.allEvent.find { it.id == eventId } ?: EventModel(workspaceId = workspaceId, startDateTime = eventDate), + states.eventState.allEvent.find { it.id == eventId } ?: EventModel(workspaceId = workspaceId, startDateTime = eventDate, id = eventId), states.settings, viewModels.eventViewModel::onEvent ) diff --git a/app/src/main/java/com/flux/other/BackupManger.kt b/app/src/main/java/com/flux/other/BackupManger.kt index 25e6505..98d7a89 100644 --- a/app/src/main/java/com/flux/other/BackupManger.kt +++ b/app/src/main/java/com/flux/other/BackupManger.kt @@ -6,7 +6,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit -class BackupManager( +class BackupManager ( private val workManager: WorkManager ) { fun scheduleBackup(frequency: BackupFrequency) { diff --git a/app/src/main/java/com/flux/other/BackupWorker.kt b/app/src/main/java/com/flux/other/BackupWorker.kt index a5b1e82..32f8a02 100644 --- a/app/src/main/java/com/flux/other/BackupWorker.kt +++ b/app/src/main/java/com/flux/other/BackupWorker.kt @@ -2,16 +2,20 @@ package com.flux.other import android.content.Context import androidx.core.net.toUri +import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.flux.R import com.flux.data.database.FluxBackup import com.flux.data.model.SettingsModel import com.flux.di.DataModule +import com.flux.other.crypto.BackupCredentialsStore +import com.flux.other.crypto.BackupEncryptor +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import java.io.OutputStreamWriter enum class BackupFrequency(val days: Int, val textRes: Int) { NEVER(0, R.string.never), @@ -20,11 +24,21 @@ enum class BackupFrequency(val days: Int, val textRes: Int) { MONTHLY(30, R.string.monthly); } -class BackupWorker( - appContext: Context, - workerParams: WorkerParameters +@HiltWorker +class BackupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val credentialsStore: BackupCredentialsStore, + private val backupEncryptor: BackupEncryptor ) : CoroutineWorker(appContext, workerParams) { + private val backupJson = Json { + ignoreUnknownKeys = true + coerceInputValues = true + encodeDefaults = true + classDiscriminator = "type" + } + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { try { val context = applicationContext @@ -41,8 +55,9 @@ class BackupWorker( val eventDao = DataModule.provideEventDao(fluxDatabase) val eventInstanceDao = DataModule.provideEventInstanceDao(fluxDatabase) val progressBoardDao = DataModule.provideProgressBoardDao(fluxDatabase) + val settings = settingsDao.loadSetting() - val rootUri = settings?.storageRootUri?.toUri()?: "".toUri() + val rootUri = settings?.storageRootUri?.toUri() ?: "".toUri() val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) val backupDir = baseDir?.let { dir -> @@ -60,10 +75,18 @@ class BackupWorker( labels = labelDao.getAll(), events = eventDao.loadAllEvents(), eventInstances = eventInstanceDao.getAll(), - settings = settingsDao.loadSetting()?: SettingsModel(), + settings = settingsDao.loadSetting() ?: SettingsModel(), progressBoardItems = progressBoardDao.getAllBoardItems() ) - val json = Json.encodeToString(FluxBackup.serializer(), backup) + val json = backupJson.encodeToString(FluxBackup.serializer(), backup) + + val password = credentialsStore.getPassword() + val bytes = if (password != null) { + backupEncryptor.encrypt(json.toByteArray(Charsets.UTF_8), password) + .also { password.fill('\u0000') } + } else { + json.toByteArray(Charsets.UTF_8) // no password set → plaintext, unchanged legacy behavior + } val fileName = "${System.currentTimeMillis()}.json" val file = dir.createFile("application/json", fileName) @@ -71,9 +94,8 @@ class BackupWorker( file?.let { docFile -> context.contentResolver.openOutputStream(docFile.uri) ?.use { outputStream -> - OutputStreamWriter(outputStream).use { writer -> - writer.write(json) - } + outputStream.write(bytes) + outputStream.flush() } } } diff --git a/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt b/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt index 570ce91..1b60e35 100644 --- a/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt @@ -1,172 +1,710 @@ package com.flux.ui.screens.auth import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Fingerprint -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +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.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import androidx.navigation.NavController import com.flux.R import com.flux.navigation.NavRoutes import com.flux.other.BiometricAuthenticator -import com.flux.ui.screens.settings.CircleWrapper @Composable fun AuthScreen( navController: NavController, isBiometricEnabled: Boolean ) { - val context = LocalContext.current + val context = androidx.compose.ui.platform.LocalContext.current val activity = context as FragmentActivity val showAuth = remember { mutableStateOf(true) } val authFailed = stringResource(R.string.Auth_Error) - // Navigate immediately if biometric is not enabled (inside LaunchedEffect) + fun navigateToWorkspace() { + showAuth.value = false + + navController.navigate(NavRoutes.Workspace.route) { + popUpTo(NavRoutes.AuthScreen.route) { + inclusive = true + } + launchSingleTop = true + } + } + + fun startAuthentication() { + val biometricAuthenticator = BiometricAuthenticator( + activity = activity, + onSuccess = { + navigateToWorkspace() + }, + onError = {}, + onFailed = { + Toast.makeText( + context, + authFailed, + Toast.LENGTH_SHORT + ).show() + } + ) + + if (biometricAuthenticator.isAvailable()) { + biometricAuthenticator.authenticate() + } else { + navigateToWorkspace() + } + } + LaunchedEffect(isBiometricEnabled) { if (!isBiometricEnabled) { navController.navigate(NavRoutes.StorageSelection.route) { - popUpTo(NavRoutes.AuthScreen.route) { inclusive = true } + popUpTo(NavRoutes.AuthScreen.route) { + inclusive = true + } launchSingleTop = true } } } - // If biometric is enabled, show authentication UI if (isBiometricEnabled && showAuth.value) { - fun startAuthentication() { - val biometricAuthenticator = BiometricAuthenticator( - activity = activity, - onSuccess = { - showAuth.value = false - navController.navigate(NavRoutes.Workspace.route) { - popUpTo(NavRoutes.AuthScreen.route) { inclusive = true } - launchSingleTop = true - } - }, - onError = {}, - onFailed = { - Toast.makeText( - context, - authFailed, - Toast.LENGTH_SHORT - ).show() - } - ) + LaunchedEffect(Unit) { + startAuthentication() + } - if (biometricAuthenticator.isAvailable()) { - biometricAuthenticator.authenticate() - } else { - navController.navigate(NavRoutes.Workspace.route) { - popUpTo(NavRoutes.AuthScreen.route) { inclusive = true } - launchSingleTop = true - } + AuthContent( + onAuthenticate = { + startAuthentication() } - } + ) + } +} +@Composable +private fun AuthContent( + onAuthenticate: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + var started by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + started = true + } + + /* + * ------------------------------------------------------------ + * Ambient background + * ------------------------------------------------------------ + */ + val ambientTransition = rememberInfiniteTransition( + label = "auth_ambient" + ) + + val gradientProgress by ambientTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 8000, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "auth_gradient_progress" + ) + + /* + * ------------------------------------------------------------ + * Flux gradient animation + * ------------------------------------------------------------ + * + * This is the same animated gradient used for the FLUX + * wordmark. It continuously sweeps across the text. + */ + val brandingTransition = rememberInfiniteTransition( + label = "auth_branding" + ) - LaunchedEffect(Unit) { startAuthentication() } + val textGradientProgress by brandingTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 3200, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "auth_text_gradient" + ) + + val gradientWidth = 900f + val gradientStart = textGradientProgress * gradientWidth + + val fluxGradient = Brush.linearGradient( + colors = listOf( + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary, + colorScheme.error, + colorScheme.secondary, + colorScheme.primary + ), + start = Offset( + x = gradientStart - gradientWidth, + y = 0f + ), + end = Offset( + x = gradientStart, + y = 0f + ) + ) + + /* + * ------------------------------------------------------------ + * Fingerprint animation + * ------------------------------------------------------------ + */ + val biometricTransition = rememberInfiniteTransition( + label = "auth_biometric" + ) + + val fingerprintPulse by biometricTransition.animateFloat( + initialValue = 0.96f, + targetValue = 1.04f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "fingerprint_pulse" + ) + + val fingerprintGlow by biometricTransition.animateFloat( + initialValue = 0.08f, + targetValue = 0.22f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "fingerprint_glow" + ) + + /* + * ------------------------------------------------------------ + * Screen + * ------------------------------------------------------------ + */ + Scaffold( + containerColor = Color.Transparent + ) { innerPadding -> + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf( + colorScheme.primary.copy(alpha = 0.14f), + colorScheme.tertiary.copy(alpha = 0.08f), + colorScheme.surfaceContainerLow + ), + center = Offset( + x = 350f + (500f * gradientProgress), + y = 100f + ), + radius = 1000f + ) + ) + .padding(innerPadding) + ) { - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { innerPadding -> + /* + * ---------------------------------------------------- + * Ambient glow at the top + * ---------------------------------------------------- + */ Box( + modifier = Modifier + .size(300.dp) + .align(Alignment.TopCenter) + .offset(y = (-120).dp) + .alpha(0.18f) + .background( + brush = Brush.radialGradient( + colors = listOf( + colorScheme.primary, + Color.Transparent + ) + ), + shape = CircleShape + ) + ) + + Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding( + horizontal = 24.dp, + vertical = 32.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally + + /* + * ==================================================== + * WELCOME HEADLINE + * ==================================================== + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 800, + delayMillis = 300 + ) + ) + slideInVertically( + animationSpec = tween( + durationMillis = 800, + delayMillis = 300, + easing = FastOutSlowInEasing + ), + initialOffsetY = { it / 2 } + ) + ) { + + Text( + text = buildAnnotatedString { + append(stringResource(R.string.welcome_back)+"\n") + + pushStyle( + SpanStyle( + brush = fluxGradient, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-2).sp + ) + ) + + append("FLUX") + + pop() + }, + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.ExtraBold + ), + textAlign = TextAlign.Center, + lineHeight = MaterialTheme.typography.displaySmall.lineHeight + ) + } + + /* + * ---------------------------------------------------- + * Description + * ---------------------------------------------------- + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 700, + delayMillis = 450 + ) + ) ) { - Card( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { }, - shape = RoundedCornerShape(50), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + + Text( + text = stringResource( + R.string.Auth_with_Biometric + ), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding( + horizontal = 20.dp + ) + ) + } + + Spacer( + modifier = Modifier.height(26.dp) + ) + + /* + * ==================================================== + * BIOMETRIC VISUAL + * ==================================================== + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 700, + delayMillis = 550 ) + ) + scaleIn( + initialScale = 0.75f, + animationSpec = tween( + durationMillis = 800, + delayMillis = 550, + easing = FastOutSlowInEasing + ) + ) + ) { + + Box( + modifier = Modifier.size(150.dp), + contentAlignment = Alignment.Center ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(8.dp) + + /* + * Outer glow + */ + Box( + modifier = Modifier + .size(140.dp) + .scale(fingerprintPulse) + .alpha(fingerprintGlow) + .background( + color = colorScheme.primary, + shape = CircleShape + ) + ) + + /* + * Secondary ring + */ + Box( + modifier = Modifier + .size(118.dp) + .border( + width = 1.dp, + color = colorScheme.primary.copy( + alpha = 0.30f + ), + shape = CircleShape + ) + ) + + /* + * Fingerprint surface + */ + Surface( + modifier = Modifier.size(92.dp), + shape = CircleShape, + color = colorScheme.primaryContainer, + tonalElevation = 6.dp, + shadowElevation = 4.dp ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start + + Box( + contentAlignment = Alignment.Center ) { - CircleWrapper( - size = 0.dp, - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Icon( - painter = painterResource(R.mipmap.ic_launcher_foreground), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Text( - stringResource(R.string.app_name), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + tint = colorScheme.onPrimaryContainer, + modifier = Modifier.size(54.dp) ) - Spacer(modifier = Modifier.width(12.dp)) } } } - Spacer(Modifier.height(64.dp)) - Text( - stringResource(R.string.Auth_with_Biometric), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold) + } + + Spacer( + modifier = Modifier.height(24.dp) + ) + + /* + * ==================================================== + * SECURITY CARD + * ==================================================== + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 700, + delayMillis = 700 + ) + ) + slideInVertically( + animationSpec = tween( + durationMillis = 700, + delayMillis = 700, + easing = FastOutSlowInEasing + ), + initialOffsetY = { it / 3 } ) - Spacer(Modifier.height(24.dp)) - IconButton( - onClick = { startAuthentication() }, - modifier = Modifier.size(64.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ) { + + SecurityInfoCard() + } + + Spacer( + modifier = Modifier.height(22.dp) + ) + + /* + * ==================================================== + * AUTHENTICATE BUTTON + * ==================================================== + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 700, + delayMillis = 850 ) - ) { - Icon(Icons.Default.Fingerprint, null, modifier = Modifier.size(42.dp)) - } + ) + slideInVertically( + animationSpec = tween( + durationMillis = 700, + delayMillis = 850, + easing = FastOutSlowInEasing + ), + initialOffsetY = { it / 3 } + ) + ) { + + AuthButton( + onAuthenticate = onAuthenticate + ) + } + + Spacer( + modifier = Modifier.height(12.dp) + ) + + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 500, + delayMillis = 1000 + ) + ) + ) { + + Text( + text = stringResource(R.string.biometric_data_never_leaves), + style = MaterialTheme.typography.labelMedium, + color = colorScheme.onSurfaceVariant.copy( + alpha = 0.7f + ), + textAlign = TextAlign.Center + ) } } } } } + +@Composable +private fun AuthButton( + onAuthenticate: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + val transition = rememberInfiniteTransition( + label = "auth_button" + ) + + val glowAlpha by transition.animateFloat( + initialValue = 0.08f, + targetValue = 0.24f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "auth_button_glow" + ) + + val glowScale by transition.animateFloat( + initialValue = 0.98f, + targetValue = 1.015f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "auth_button_scale" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .scale(glowScale) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient( + colors = listOf( + colorScheme.primary.copy( + alpha = glowAlpha + ), + colorScheme.tertiary.copy( + alpha = glowAlpha + ) + ) + ) + ) + .padding(3.dp) + ) { + + Button( + onClick = onAuthenticate, + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + shape = RoundedCornerShape(17.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary + ) + ) { + + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + modifier = Modifier.size(23.dp) + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text( + text = stringResource(R.string.authenticate), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun SecurityInfoCard() { + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = colorScheme.surfaceContainer, + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + Box( + modifier = Modifier + .size(44.dp) + .background( + color = colorScheme.secondaryContainer, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = null, + tint = colorScheme.onSecondaryContainer, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.private_secure), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(3.dp)) + + Text( + text = stringResource(R.string.biometric_unlock_description), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt b/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt index 7ad1bef..eb484b2 100644 --- a/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt @@ -60,6 +60,7 @@ import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime +import java.util.UUID @RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S) @OptIn(ExperimentalMaterial3Api::class) @@ -147,8 +148,8 @@ fun EventScreen( val currentTime = LocalTime.now() val zonedDateTime = ZonedDateTime.of(localDate, currentTime, ZoneId.systemDefault()) val selectedDateMillis = zonedDateTime.toInstant().toEpochMilli() - - navController.navigate(NavRoutes.NewEvent.withArgs(workspaceId, "", selectedDateMillis)) + val newId = UUID.randomUUID().toString() + navController.navigate(NavRoutes.NewEvent.withArgs(workspaceId, newId, selectedDateMillis)) } }) { Icon(Icons.Default.Add, null) } } diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt index 575fcfa..40a22d4 100644 --- a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt @@ -1750,24 +1750,13 @@ private fun getExpectedDates( .atZone(ZoneId.systemDefault()) .toLocalDate() - val weekly = - recurrence as? RecurrenceRule.Weekly - ?: return emptyList() - val expectedDates = mutableListOf() - var current = startDate while (!current.isAfter(today)) { - - // Monday=0 ... Sunday=6 - val dayOfWeek = - current.dayOfWeek.value - 1 - - if (dayOfWeek in weekly.daysOfWeek) { + if (recurrence.isActiveOn(date = current, startDate = startDate)) { expectedDates.add(current.toEpochDay()) } - current = current.plusDays(1) } @@ -1781,25 +1770,22 @@ private fun calculateCurrentStreak( if (expectedDates.isEmpty()) return 0 + val todayEpoch = LocalDate.now().toEpochDay() var streak = 0 + var skippedToday = false for (expectedDate in expectedDates.asReversed()) { if (expectedDate in completedDates) { streak++ + } else if (expectedDate == todayEpoch && !skippedToday) { + skippedToday = true + continue } else { break } } - // If latest expected date is incomplete, - // streak is dead. - val latestExpected = expectedDates.last() - - if (latestExpected !in completedDates) { - return 0 - } - return streak } diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt index 9495e72..97dde4e 100644 --- a/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt @@ -64,6 +64,7 @@ import com.flux.ui.state.Settings import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState import java.time.LocalDate +import java.util.UUID @RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalMaterial3Api::class) @@ -146,7 +147,8 @@ fun HabitScreen( openAppNotificationSettings(context) } if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) { - navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, "")) + val newId = UUID.randomUUID().toString() + navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, newId)) } }) { Icon(Icons.Default.Add, null) } } diff --git a/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt b/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt index 14f8832..3627265 100644 --- a/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt @@ -72,6 +72,7 @@ import java.time.Instant import java.time.ZoneId import java.time.format.TextStyle import java.util.Locale +import java.util.UUID data class FilterState( val sort: String? = null, @@ -235,7 +236,10 @@ fun JournalScreen( ExtendedFloatingActionButton( modifier = buttonModifier, - onClick = { navController.navigate(NavRoutes.EditJournal.withArgs(workspaceId, "", System.currentTimeMillis())) }, + onClick = { + val newId = UUID.randomUUID().toString() + navController.navigate(NavRoutes.EditJournal.withArgs(workspaceId, newId, System.currentTimeMillis())) + }, icon = { Icon(Icons.Outlined.Create, contentDescription = null) }, text = { Text(stringResource(R.string.create)) } ) diff --git a/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt b/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt index 41b6dc0..af724a2 100644 --- a/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt @@ -80,6 +80,7 @@ import com.flux.ui.state.NotesState import com.flux.ui.state.Settings import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyStaggeredGridState +import java.util.UUID data class FilterState( val sort: String? = null, @@ -270,7 +271,10 @@ fun NotesScreen( ExtendedFloatingActionButton( modifier = buttonModifier, - onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, "")) }, + onClick = { + val newId = UUID.randomUUID().toString() + navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, newId)) + }, icon = { Icon(Icons.Outlined.Create, contentDescription = null) }, text = { Text(stringResource(R.string.create)) } ) diff --git a/app/src/main/java/com/flux/ui/screens/settings/Changelog.kt b/app/src/main/java/com/flux/ui/screens/settings/Changelog.kt index 95a42df..8d95251 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Changelog.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Changelog.kt @@ -321,5 +321,22 @@ val CHANGELOG_DATA = listOf( - Update changelog - prepare for release 3.2.2 """.trimIndent() + ), + ChangelogEntry( + version = "v3.2.3", + versionCode = 20, + date = "Aug 22, 2026", + changes = """ + fix: + - encrypted backup in backup manager. + - duplicate entry of data points. + - app crash on storage root on backup + - streak calculation in habits. + src: + - UI improvement in Authentication Screen. + - UI Improvement in Storage Root Selection. + + chore: update dependency to latest versions. + """.trimIndent() ) ) diff --git a/app/src/main/java/com/flux/ui/screens/settings/Data.kt b/app/src/main/java/com/flux/ui/screens/settings/Data.kt index e06c212..7171217 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Data.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Data.kt @@ -183,7 +183,7 @@ fun Data( icon = Icons.Rounded.Backup, radius = shapeManager(radius = radius, isFirst = true), actionType = ActionType.CUSTOM, - onCustomClick = { coroutineScope.launch { backupViewModel.exportBackup(context) } } + onCustomClick = { backupViewModel.exportBackup(context) } ) } diff --git a/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt b/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt index 037b459..f04ef74 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt @@ -2,36 +2,63 @@ package com.flux.ui.screens.settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -40,6 +67,12 @@ import com.flux.R import com.flux.navigation.NavRoutes import com.flux.other.ensureStorageRoot import com.flux.ui.viewModel.SettingsViewModel +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.sp @Composable fun StorageSelectionScreen( @@ -47,101 +80,721 @@ fun StorageSelectionScreen( settingsViewModel: SettingsViewModel, isStorageRootSelected: Boolean ) { - // Navigate immediately if storage root is already selected (inside LaunchedEffect) LaunchedEffect(isStorageRootSelected) { if (isStorageRootSelected) { navController.navigate(NavRoutes.Workspace.route) { - popUpTo(NavRoutes.StorageSelection.route) { inclusive = true } + popUpTo(NavRoutes.StorageSelection.route) { + inclusive = true + } launchSingleTop = true } } } - val rootPicker = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - val uri = result.data?.data ?: return@rememberLauncherForActivityResult - settingsViewModel.saveRootUri(uri) - } + val rootPicker = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val uri = result.data?.data ?: return@rememberLauncherForActivityResult + settingsViewModel.saveRootUri(uri) + } val scope = rememberCoroutineScope() - if(!isStorageRootSelected){ - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { innerPadding -> + if (!isStorageRootSelected) { + StorageSelectionContent( + onSelectFolder = { + ensureStorageRoot( + scope, + settingsViewModel, + rootPicker + ) { + navController.navigate(NavRoutes.Workspace.route) { + popUpTo(NavRoutes.StorageSelection.route) { + inclusive = true + } + launchSingleTop = true + } + } + } + ) + } +} + +@Composable +private fun StorageSelectionContent( + onSelectFolder: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + var started by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + started = true + } + + val infiniteTransition = rememberInfiniteTransition( + label = "ambient_background" + ) + + val gradientProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 8000, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "gradient_progress" + ) + + Scaffold( + containerColor = Color.Transparent + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf( + colorScheme.primary.copy(alpha = 0.14f), + colorScheme.tertiary.copy(alpha = 0.08f), + colorScheme.surfaceContainerLow + ), + center = Offset( + x = 350f + (500f * gradientProgress), + y = 100f + ), + radius = 1000f + ) + ) + ) { + + /* + * Ambient glow at the top. + */ Box( + modifier = Modifier + .size(300.dp) + .align(Alignment.TopCenter) + .offset(y = (-120).dp) + .alpha(0.18f) + .background( + brush = Brush.radialGradient( + colors = listOf( + colorScheme.primary, + Color.Transparent + ) + ), + shape = CircleShape + ) + ) + + Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding( + horizontal = 24.dp, + vertical = 32.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - Column( - modifier = Modifier.align(Alignment.Center).padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Card( - modifier = Modifier.clip(RoundedCornerShape(50)).clickable { }, - shape = RoundedCornerShape(50), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + + /* + * Flux branding + */ + val brandingTransition = rememberInfiniteTransition( + label = "flux_branding" + ) + + // Gentle breathing scale for the logo badge + val logoPulse by brandingTransition.animateFloat( + initialValue = 0.96f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2200, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "logo_pulse" + ) + + // Vertical float/bob + val logoFloat by brandingTransition.animateFloat( + initialValue = -4f, + targetValue = 4f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2600, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "logo_float" + ) + + // Subtle tilt wobble for a livelier feel + val logoTilt by brandingTransition.animateFloat( + initialValue = -4f, + targetValue = 4f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 3400, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "logo_tilt" + ) + + // Halo breathing + val haloAlpha by brandingTransition.animateFloat( + initialValue = 0.10f, + targetValue = 0.30f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "logo_halo" + ) + + // Continuously spinning ring behind the badge — one direction, no reverse + val ringRotation by brandingTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 7000, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "ring_rotation" + ) + + /* + * Animated gradient for the Flux wordmark. + * + * The gradient sweeps continuously across the letters using + * RepeatMode.Restart. The color list starts and ends on the + * same color (primary) so the loop point is seamless — no + * visible jump when it restarts. + */ + val textGradientProgress by brandingTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 3200, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "flux_text_gradient" + ) + + val gradientWidth = 900f + val gradientStart = textGradientProgress * gradientWidth + + val fluxGradient = Brush.linearGradient( + colors = listOf( + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary, + colorScheme.error, + colorScheme.secondary, + colorScheme.primary + ), + start = Offset( + x = gradientStart - gradientWidth, + y = 0f + ), + end = Offset( + x = gradientStart, + y = 0f + ) + ) + + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween(700) + ) + scaleIn( + initialScale = 0.72f, + animationSpec = tween( + durationMillis = 900, + easing = FastOutSlowInEasing ) + ) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(8.dp) + + /* + * Logo halo + spinning ring + */ + Box( + modifier = Modifier + .size(150.dp) + .drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + colorScheme.primary.copy( + alpha = haloAlpha + ), + colorScheme.tertiary.copy( + alpha = haloAlpha * 0.45f + ), + Color.Transparent + ) + ), + radius = size.minDimension / 2f + ) + }, + contentAlignment = Alignment.Center ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start + + // Spinning sweep ring sitting behind the badge + Box( + modifier = Modifier + .size(126.dp) + .graphicsLayer { + rotationZ = ringRotation + } + .clip(CircleShape) + .background( + brush = Brush.sweepGradient( + colors = listOf( + colorScheme.primary.copy(alpha = 0.55f), + colorScheme.tertiary.copy(alpha = 0.05f), + colorScheme.secondary.copy(alpha = 0.55f), + colorScheme.primary.copy(alpha = 0.05f), + colorScheme.primary.copy(alpha = 0.55f) + ) + ) + ) + ) + + /* + * Animated logo container + */ + Box( + modifier = Modifier + .size(110.dp) + .graphicsLayer { + scaleX = logoPulse + scaleY = logoPulse + translationY = logoFloat + rotationZ = logoTilt + } + .clip(CircleShape) + .background( + Brush.linearGradient( + colors = listOf( + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary + ) + ) + ) + .padding(2.dp), + contentAlignment = Alignment.Center ) { - CircleWrapper( - size = 0.dp, - color = MaterialTheme.colorScheme.surfaceContainerLow + Surface( + modifier = Modifier.fillMaxSize(), + shape = CircleShape, + color = colorScheme.surface ) { - Icon( - painter = painterResource(R.mipmap.ic_launcher_foreground), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource( + R.mipmap.ic_launcher_foreground + ), + contentDescription = stringResource( + R.string.app_name + ), + tint = Color.Unspecified, + modifier = Modifier.size(92.dp) + ) + } } - Spacer(modifier = Modifier.width(12.dp)) - Text( - stringResource(R.string.app_name), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.width(12.dp)) } } + + Spacer( + modifier = Modifier.height(14.dp) + ) + + /* + * Flux wordmark + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + animationSpec = tween( + durationMillis = 700, + delayMillis = 180 + ) + ) + slideInVertically( + animationSpec = tween( + durationMillis = 800, + delayMillis = 180, + easing = FastOutSlowInEasing + ), + initialOffsetY = { it / 3 } + ) + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.displayLarge.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-2).sp, + brush = fluxGradient + ), + textAlign = TextAlign.Center + ) + } } - Spacer(Modifier.height(16.dp)) + } + + Spacer(modifier = Modifier.height(14.dp)) + + /* + * Hero headline + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + tween( + durationMillis = 800, + delayMillis = 300 + ) + ) + slideInVertically( + animationSpec = tween( + durationMillis = 800, + delayMillis = 300, + easing = FastOutSlowInEasing + ), + initialOffsetY = { it / 2 } + ) + ) { Text( - stringResource(R.string.select_folder_description), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(16.dp), + text = buildAnnotatedString { + append("Your data.\n") + + pushStyle( + SpanStyle( + brush = Brush.linearGradient( + colors = listOf( + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary + ) + ), + fontWeight = FontWeight.ExtraBold + ) + ) + + append("Your storage.") + + pop() + }, + style = MaterialTheme.typography.displaySmall, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary + lineHeight = MaterialTheme.typography.displaySmall.lineHeight + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + /* + * Description + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + tween( + durationMillis = 700, + delayMillis = 450 + ) + ) + ) { + Text( + text = stringResource( + R.string.select_folder_description + ), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + + /* + * Benefits + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + tween( + durationMillis = 700, + delayMillis = 600 + ) + ) + slideInVertically( + animationSpec = tween( + durationMillis = 700, + delayMillis = 600 + ), + initialOffsetY = { it / 3 } + ) + ) { + StorageBenefitsCard() + } + + Spacer(modifier = Modifier.height(28.dp)) + + /* + * CTA + */ + AnimatedVisibility( + visible = started, + enter = fadeIn( + tween( + durationMillis = 700, + delayMillis = 750 + ) + ) + slideInVertically( + animationSpec = tween( + durationMillis = 700, + delayMillis = 750 + ), + initialOffsetY = { it / 3 } + ) + ) { + StorageSelectionButton( + onClick = onSelectFolder + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + AnimatedVisibility( + visible = started, + enter = fadeIn( + tween( + durationMillis = 500, + delayMillis = 900 + ) + ) + ) { + Text( + text = "You can change this later in Settings", + style = MaterialTheme.typography.labelMedium, + color = colorScheme.onSurfaceVariant.copy( + alpha = 0.7f + ), + textAlign = TextAlign.Center ) - Spacer(Modifier.height(8.dp)) - FilledTonalButton(onClick = { - ensureStorageRoot(scope, settingsViewModel, rootPicker){ - navController.navigate(NavRoutes.Workspace.route) { - popUpTo(NavRoutes.StorageSelection.route) { inclusive = true } - launchSingleTop = true - } - } - }) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Folder, null) - Text(stringResource(R.string.select_folder)) - } - } } } } } +} + +@Composable +private fun StorageBenefitsCard() { + val colorScheme = MaterialTheme.colorScheme + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surfaceContainer + ), + border = androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp + ) + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + StorageBenefitRow( + icon = Icons.Default.Folder, + title = "You choose the location", + description = "Flux stores your data where you decide." + ) + + StorageBenefitRow( + icon = Icons.Default.Lock, + title = "Your data stays yours", + description = "No cloud account is required for your local data." + ) + + StorageBenefitRow( + icon = Icons.Default.Security, + title = "Easy to back up", + description = "Your storage folder can be backed up like any other file." + ) + } + } +} + +@Composable +private fun StorageBenefitRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + description: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background( + colorScheme.primaryContainer + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.onPrimaryContainer, + modifier = Modifier.size(21.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +private fun StorageSelectionButton( + onClick: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + val infiniteTransition = rememberInfiniteTransition( + label = "button_glow" + ) + + val glowAlpha by infiniteTransition.animateFloat( + initialValue = 0.08f, + targetValue = 0.24f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "glow_alpha" + ) + + val glowScale by infiniteTransition.animateFloat( + initialValue = 0.98f, + targetValue = 1.015f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1800, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "glow_scale" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .scale(glowScale) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient( + colors = listOf( + colorScheme.primary.copy(alpha = glowAlpha), + colorScheme.tertiary.copy(alpha = glowAlpha) + ) + ) + ) + .padding(3.dp) + ) { + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + shape = RoundedCornerShape(17.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + text = stringResource(R.string.select_folder), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt index 6dd6469..136b6b4 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt @@ -52,6 +52,7 @@ import com.flux.ui.state.Settings import com.flux.ui.state.TodoState import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState +import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -120,7 +121,10 @@ fun TodoScreen( ) }, floatingActionButton = { - FloatingActionButton({ navController.navigate(NavRoutes.NewTodoList.withArgs(workspaceId, "")) }) { + FloatingActionButton({ + val newId = UUID.randomUUID().toString() + navController.navigate(NavRoutes.NewTodoList.withArgs(workspaceId, newId)) + }) { Icon(Icons.Default.AddTask, null) } } diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt index 91727c0..b0b8bd4 100644 --- a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt @@ -28,6 +28,9 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -58,6 +61,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -77,6 +82,7 @@ fun SetPasskeyDialog( onConfirmRequest: (String) -> Unit, onDismissRequest: () -> Unit ) { + var passwordVisible by remember { mutableStateOf(false) } var passKey by remember { mutableStateOf(key ?: "") } val isValid = passKey.isNotBlank() @@ -105,6 +111,40 @@ fun SetPasskeyDialog( onValueChange = { passKey = it }, modifier = Modifier.fillMaxWidth(), isError = passKey.isNotEmpty() && !isValid, + supportingText = { + if (passKey.isNotEmpty() && !isValid) { + Text("Invalid Passkey") + } + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = "Passkey" + ) + }, + trailingIcon = { + IconButton( + onClick = { passwordVisible = !passwordVisible } + ) { + Icon( + imageVector = if (passwordVisible) { + Icons.Outlined.VisibilityOff + } else { + Icons.Outlined.Visibility + }, + contentDescription = if (passwordVisible) { + "Hide passkey" + } else { + "Show passkey" + } + ) + } + }, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Done ), diff --git a/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt index 7c17d20..0d2fb5e 100644 --- a/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.withContext import javax.inject.Inject import kotlinx.serialization.json.Json - @HiltViewModel class BackupViewModel @Inject constructor( private val db: FluxDatabase, @@ -56,11 +55,12 @@ class BackupViewModel @Inject constructor( classDiscriminator = "type" } - suspend fun exportBackup(context: Context) { - val rootUri = settingsRepository.getStorageRoot() + fun exportBackup(context: Context) { viewModelScope.launch(Dispatchers.IO) { try { + val rootUri = settingsRepository.getStorageRoot() + val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) val backupDir = baseDir?.let { getOrCreateDirectory(context, it.uri, Constants.File.FLUX_BACKUP) } @@ -113,7 +113,7 @@ class BackupViewModel @Inject constructor( } try { String(backupEncryptor.decrypt(bytes, password), Charsets.UTF_8) - } catch (e: BackupCryptoException.WrongPasswordOrCorrupted) { + } catch (_: BackupCryptoException.WrongPasswordOrCorrupted) { _passwordRequired.emit(uri) // wrong/old password — ask the user for this file's actual one return } finally { diff --git a/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt index 8f5854b..1b0a562 100644 --- a/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt @@ -95,7 +95,7 @@ class JournalViewModel @Inject constructor( return renderer.render(parser.parse(markdown)) } - private suspend fun reduce(event: JournalEvents) { + private fun reduce(event: JournalEvents) { when (event) { is JournalEvents.DeleteEntry -> deleteEntry(event.entry) is JournalEvents.DeleteWorkspaceEntries -> deleteAllWorkspaceEntries(event.workspaceId) @@ -128,35 +128,45 @@ class JournalViewModel @Inject constructor( ) } } + is JournalEvents.ImportAudio -> { val context = event.context val sourceUri = event.sourceUri - val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState - val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) - val audioDir = baseDir?.let { dir -> getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_AUDIO) } + viewModelScope.launch(Dispatchers.IO) { + val rootUri = settingsRepository.getStorageRoot() + val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) + val audioDir = baseDir?.let { dir -> + getOrCreateDirectory( + context, + dir.uri, + Constants.File.FLUX_AUDIO + ) + } - audioDir?.let { dir -> - val sourceFile = DocumentFile.fromSingleUri(context, sourceUri) - val sourceFileName = sourceFile?.name + audioDir?.let { dir -> + val sourceFile = DocumentFile.fromSingleUri(context, sourceUri) + val sourceFileName = sourceFile?.name - if (sourceFileName != null && hasFileWithName(dir, sourceFileName)) { - contentState.edit { add("") } - return - } + if (sourceFileName != null && hasFileWithName(dir, sourceFileName)) { + contentState.edit { add("") } + return@launch + } - val extension = getFileExtension(context, sourceUri) - val fileName = "audio_${System.currentTimeMillis()}.$extension" - val mimeType = sourceFile?.type ?: "audio/*" + val extension = getFileExtension(context, sourceUri) + val fileName = "audio_${System.currentTimeMillis()}.$extension" + val mimeType = sourceFile?.type ?: "audio/*" - dir.createFile(mimeType, fileName)?.let { newFile -> - context.contentResolver.openInputStream(sourceUri)?.use { input -> - context.contentResolver.openOutputStream(newFile.uri)?.use { output -> - input.copyTo(output) + dir.createFile(mimeType, fileName)?.let { newFile -> + context.contentResolver.openInputStream(sourceUri)?.use { input -> + context.contentResolver.openOutputStream(newFile.uri) + ?.use { output -> + input.copyTo(output) + } } + contentState.edit { add("") } } - contentState.edit { add("") } } } } @@ -165,10 +175,10 @@ class JournalViewModel @Inject constructor( val context = event.context val contentResolver = context.contentResolver val uriList = event.uriList - val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState viewModelScope.launch(Dispatchers.IO) { + val rootUri = settingsRepository.getStorageRoot() val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) val imagesDir = baseDir?.let { dir -> getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_IMAGES) } val savedUriList = mutableListOf() @@ -204,10 +214,10 @@ class JournalViewModel @Inject constructor( is JournalEvents.ImportVideo ->{ val context = event.context val uri = event.uri - val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState viewModelScope.launch(Dispatchers.IO) { + val rootUri = settingsRepository.getStorageRoot() val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) val videosDir = baseDir?.let { dir -> getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_VIDEOS) } diff --git a/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt index 0da7673..d4fcfb5 100644 --- a/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt @@ -119,10 +119,10 @@ class NotesViewModel @Inject constructor( val context = event.context val contentResolver = context.contentResolver val uriList = event.uriList - val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState viewModelScope.launch(Dispatchers.IO) { + val rootUri = settingsRepository.getStorageRoot() val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) val imagesDir = baseDir?.let { dir -> getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_IMAGES) } val savedUriList = mutableListOf() @@ -158,10 +158,10 @@ class NotesViewModel @Inject constructor( is NotesEvents.ImportVideo -> { val context = event.context val uri = event.uri - val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState viewModelScope.launch(Dispatchers.IO) { + val rootUri = settingsRepository.getStorageRoot() val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) val videosDir = baseDir?.let { dir -> getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_VIDEOS) } @@ -189,35 +189,38 @@ class NotesViewModel @Inject constructor( is NotesEvents.ImportAudio -> { val context = event.context val sourceUri = event.sourceUri - val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState - val baseDir = - getOrCreateDirectory(context, rootUri, Constants.File.FLUX) - val audioDir = baseDir?.let { dir -> - getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_AUDIO) - } + viewModelScope.launch(Dispatchers.IO) { + val rootUri = settingsRepository.getStorageRoot() + val baseDir = + getOrCreateDirectory(context, rootUri, Constants.File.FLUX) + val audioDir = baseDir?.let { dir -> + getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_AUDIO) + } - audioDir?.let { dir -> - val sourceFile = DocumentFile.fromSingleUri(context, sourceUri) - val sourceFileName = sourceFile?.name + audioDir?.let { dir -> + val sourceFile = DocumentFile.fromSingleUri(context, sourceUri) + val sourceFileName = sourceFile?.name - if (sourceFileName != null && hasFileWithName(dir, sourceFileName)) { - contentState.edit { add("") } - return - } + if (sourceFileName != null && hasFileWithName(dir, sourceFileName)) { + contentState.edit { add("") } + return@launch + } - val extension = getFileExtension(context, sourceUri) - val fileName = "audio_${System.currentTimeMillis()}.$extension" - val mimeType = sourceFile?.type ?: "audio/*" + val extension = getFileExtension(context, sourceUri) + val fileName = "audio_${System.currentTimeMillis()}.$extension" + val mimeType = sourceFile?.type ?: "audio/*" - dir.createFile(mimeType, fileName)?.let { newFile -> - context.contentResolver.openInputStream(sourceUri)?.use { input -> - context.contentResolver.openOutputStream(newFile.uri)?.use { output -> - input.copyTo(output) + dir.createFile(mimeType, fileName)?.let { newFile -> + context.contentResolver.openInputStream(sourceUri)?.use { input -> + context.contentResolver.openOutputStream(newFile.uri) + ?.use { output -> + input.copyTo(output) + } } + contentState.edit { add("") } } - contentState.edit { add("") } } } } diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c80478a..538188e 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -574,4 +574,10 @@ Daten werden verschoben… …und %1$d weitere %1$d / %2$d Dateien + + Willkommen zurück bei + Authentifizieren + Privat & sicher + Verwende die Biometrie deines Geräts, um deine Flux-Daten sicher zu entsperren. + Deine biometrischen Daten verlassen dein Gerät niemals \ No newline at end of file diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 03a9b2f..f285208 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -578,4 +578,11 @@ Moviendo tus datos… …y %1$d más %1$d / %2$d archivos + + Bienvenido de nuevo a + Autenticar + Privado y seguro + Usa la biometría de tu dispositivo para desbloquear de forma segura tus datos de Flux. + Tus datos biométricos nunca salen de tu dispositivo + \ No newline at end of file diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 695e184..afea6ba 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -583,4 +583,10 @@ …et %1$d de plus %1$d / %2$d fichiers + Bon retour sur + S’authentifier + Privé & sécurisé + Utilisez la biométrie de votre appareil pour déverrouiller vos données Flux en toute sécurité. + Vos données biométriques ne quittent jamais votre appareil + \ No newline at end of file diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index f6ec7cc..10752c1 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -581,4 +581,10 @@ आपका डेटा स्थानांतरित किया जा रहा है… …और %1$d अन्य %1$d / %2$d फ़ाइलें + + आपका स्वागत है + प्रमाणित करें + निजी और सुरक्षित + अपने Flux डेटा को सुरक्षित रूप से अनलॉक करने के लिए अपने डिवाइस की बायोमेट्रिक सुविधा का उपयोग करें। + आपका बायोमेट्रिक डेटा आपके डिवाइस से कभी बाहर नहीं जाता diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..2da4cf8 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #121316 + \ No newline at end of file diff --git a/app/src/main/res/values-nl-rNL/strings.xml b/app/src/main/res/values-nl-rNL/strings.xml index e12372e..de103b3 100644 --- a/app/src/main/res/values-nl-rNL/strings.xml +++ b/app/src/main/res/values-nl-rNL/strings.xml @@ -581,4 +581,10 @@ Je gegevens worden verplaatst… …en nog %1$d %1$d / %2$d bestanden + + Welkom terug bij + Authenticeren + Privé & veilig + Gebruik de biometrische gegevens van je apparaat om je Flux-gegevens veilig te ontgrendelen. + Je biometrische gegevens verlaten je apparaat nooit diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f822ec0..b87296e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -581,4 +581,10 @@ Movendo seus dados… …e mais %1$d %1$d / %2$d arquivos + + Bem-vindo de volta ao + Autenticar + Privado & seguro + Use a biometria do seu dispositivo para desbloquear seus dados do Flux com segurança. + Seus dados biométricos nunca saem do seu dispositivo \ No newline at end of file diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 26dfca9..617ee71 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -581,4 +581,10 @@ Перемещение данных… …и ещё %1$d %1$d / %2$d файлов + + С возвращением в + Аутентификация + Конфиденциально и безопасно + Используйте биометрию устройства для безопасной разблокировки данных Flux. + Ваши биометрические данные никогда не покидают устройство \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 551ad82..5189f03 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -586,4 +586,10 @@ 正在移动您的数据… …以及另外 %1$d 个 %1$d / %2$d 个文件 + + 欢迎回来,进入 + 身份验证 + 私密且安全 + 使用设备的生物识别功能安全地解锁您的 Flux 数据。 + 您的生物识别数据永远不会离开您的设备 \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..b27a068 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,5 @@ #FF018786 #FF000000 #FFFFFFFF + #FAF8FF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0bddf2..419178b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -579,4 +579,10 @@ Moving your data… …and %1$d more %1$d / %2$d files + + Welcome back to + Authenticate + Private & secure + Use your device biometric to securely unlock your Flux data. + Your biometric data never leaves your device \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bdcdb8c..2ac0f37 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,9 +2,11 @@ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 283a8e4..efb3342 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -agp = "9.2.1" +agp = "9.3.1" kotlin = "2.4.10" ksp = "2.3.9" kotlinxSerializationJson = "1.11.0" biometric = "1.4.0-alpha07" gson = "2.14.0" coreKtx = "1.19.0" -appcompat = "1.7.1" +appcompat = "1.8.0" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" -composeBom = "2026.06.01" +composeBom = "2026.08.00" coreSplashscreen = "1.2.0" navigationCompose = "2.9.8" reorderable = "3.1.0" @@ -26,12 +26,13 @@ browser = "1.10.0" documentfile = "1.1.0" securityCrypto = "1.1.0" workRuntimeKtx = "2.11.2" -foundationLayout = "1.11.4" -adaptive = "1.2.0" -foundation = "1.11.4" +foundationLayout = "1.12.0" +adaptive = "1.3.0" +foundation = "1.12.0" navigationCommonKtx = "2.9.8" glanceAppwidget = "1.1.1" -uiText = "1.11.4" +uiText = "1.12.0" +hiltCommon = "1.4.0" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -83,6 +84,7 @@ androidx-navigation-common-ktx = { group = "androidx.navigation", name = "naviga reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glanceAppwidget" } androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" } +androidx-hilt-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "hiltCommon" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/metadata/en-US/changelogs/20.txt b/metadata/en-US/changelogs/20.txt new file mode 100644 index 0000000..4ececc0 --- /dev/null +++ b/metadata/en-US/changelogs/20.txt @@ -0,0 +1,10 @@ +fix: + - encrypted backup in backup manager. + - duplicate entry of data points. + - app crash on storage root on backup + - streak calculation in habits. +src: + - UI improvement in Authentication Screen. + - UI Improvement in Storage Root Selection. + +chore: update dependency to latest versions. \ No newline at end of file