diff --git a/.editorconfig b/.editorconfig index 310628a0..96ee3b98 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1,3 @@ [*.{kt,kts}] ktlint_function_naming_ignore_when_annotated_with = Composable +compose_allowed_composition_locals = LocalNavigator,LocalSharedText diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt index 171a227d..40fd01da 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt @@ -33,6 +33,7 @@ import kotlinx.serialization.json.Json import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module class DeeprApplication : Application() { @@ -90,7 +91,7 @@ class DeeprApplication : Application() { } } - viewModel { AccountViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModelOf(::AccountViewModel) single { HtmlParser() diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt b/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt index 24b740ad..51797eea 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt @@ -6,15 +6,28 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Column +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry @@ -22,20 +35,14 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.yogeshpaliyal.deepr.preference.AppPreferenceDataStore -import com.yogeshpaliyal.deepr.ui.screens.AboutUs -import com.yogeshpaliyal.deepr.ui.screens.AboutUsScreen -import com.yogeshpaliyal.deepr.ui.screens.BackupScreen -import com.yogeshpaliyal.deepr.ui.screens.BackupScreenContent -import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServer -import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServerScreen -import com.yogeshpaliyal.deepr.ui.screens.RestoreScreen -import com.yogeshpaliyal.deepr.ui.screens.RestoreScreenContent +import com.yogeshpaliyal.deepr.ui.BaseScreen +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen +import com.yogeshpaliyal.deepr.ui.TopLevelBackStack +import com.yogeshpaliyal.deepr.ui.TopLevelRoute import com.yogeshpaliyal.deepr.ui.screens.Settings -import com.yogeshpaliyal.deepr.ui.screens.SettingsScreen -import com.yogeshpaliyal.deepr.ui.screens.TransferLinkLocalNetworkServer -import com.yogeshpaliyal.deepr.ui.screens.TransferLinkLocalServerScreen -import com.yogeshpaliyal.deepr.ui.screens.home.Home -import com.yogeshpaliyal.deepr.ui.screens.home.HomeScreen +import com.yogeshpaliyal.deepr.ui.screens.home.Dashboard2 +import com.yogeshpaliyal.deepr.ui.screens.home.TagSelectionScreen import com.yogeshpaliyal.deepr.ui.theme.DeeprTheme import com.yogeshpaliyal.deepr.util.LanguageUtil import kotlinx.coroutines.flow.MutableStateFlow @@ -81,7 +88,9 @@ class MainActivity : ComponentActivity() { setContent { val preferenceDataStore = remember { AppPreferenceDataStore(this) } - val themeMode by preferenceDataStore.getThemeMode.collectAsStateWithLifecycle(initialValue = "system") + val themeMode by preferenceDataStore.getThemeMode.collectAsStateWithLifecycle( + initialValue = "system", + ) DeeprTheme(themeMode = themeMode) { Surface { @@ -123,70 +132,96 @@ class MainActivity : ComponentActivity() { } } +private val TOP_LEVEL_ROUTES: List = + listOf(Dashboard2(), TagSelectionScreen, Settings) + +val LocalSharedText = + compositionLocalOf Unit>?> { null } + +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Dashboard( modifier: Modifier = Modifier, sharedText: SharedLink? = null, resetSharedText: () -> Unit, ) { - val backStack = remember(sharedText) { mutableStateListOf(Home) } - - Column(modifier = modifier) { - NavDisplay( - backStack = backStack, - entryDecorators = - listOf( - // Add the default decorators for managing scenes and saving state - rememberSceneSetupNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - // Then add the view model store decorator - rememberViewModelStoreNavEntryDecorator(), - ), - onBack = { backStack.removeLastOrNull() }, - entryProvider = { key -> - when (key) { - is Home -> - NavEntry(key) { - HomeScreen( - backStack, - sharedText = sharedText, - resetSharedText = resetSharedText, - ) - } - - is Settings -> - NavEntry(key) { - SettingsScreen(backStack) - } - - is AboutUs -> - NavEntry(key) { - AboutUsScreen(backStack) - } - - is LocalNetworkServer -> - NavEntry(key) { - LocalNetworkServerScreen(backStack) - } - - is TransferLinkLocalNetworkServer -> - NavEntry(key) { - TransferLinkLocalServerScreen(backStack) - } - - is BackupScreen -> - NavEntry(key) { - BackupScreenContent(backStack) + val backStack = + remember { + TopLevelBackStack( + Dashboard2(), + ) + } + val current = backStack.getLast() + val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior() + val hapticFeedback = LocalHapticFeedback.current + val layoutDirection = LocalLayoutDirection.current + + CompositionLocalProvider(LocalSharedText provides Pair(sharedText, resetSharedText)) { + CompositionLocalProvider(LocalNavigator provides backStack) { + Scaffold( + modifier = modifier, + bottomBar = { + AnimatedVisibility( + (TOP_LEVEL_ROUTES.any { it::class == current::class }), + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ) { + BottomAppBar(scrollBehavior = scrollBehavior) { + TOP_LEVEL_ROUTES.forEach { topLevelRoute -> + val isSelected = + topLevelRoute::class == backStack.topLevelKey::class + NavigationBarItem( + selected = isSelected, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + backStack.addTopLevel(topLevelRoute) + }, + label = { + Text(stringResource(topLevelRoute.label)) + }, + icon = { + Icon( + imageVector = topLevelRoute.icon, + contentDescription = null, + ) + }, + ) + } } - - is RestoreScreen -> - NavEntry(key) { - RestoreScreenContent(backStack) + } + }, + ) { contentPadding -> + NavDisplay( + backStack = backStack.backStack, + entryDecorators = + listOf( + // Add the default decorators for managing scenes and saving state + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + // Then add the view model store decorator + rememberViewModelStoreNavEntryDecorator(), + ), + onBack = { + backStack.removeLast() + }, + entryProvider = { + NavEntry(it) { entryItem -> + if (entryItem is TopLevelRoute) { + entryItem.Content( + WindowInsets( + left = contentPadding.calculateLeftPadding(layoutDirection), + right = contentPadding.calculateRightPadding(layoutDirection), + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ), + ) + } else if (entryItem is Screen) { + entryItem.Content() + } } - - else -> NavEntry(Unit) { Text("Unknown route") } - } - }, - ) + }, + ) + } + } } } diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/Navigation.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/Navigation.kt new file mode 100644 index 00000000..1e810937 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/Navigation.kt @@ -0,0 +1,92 @@ +package com.yogeshpaliyal.deepr.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavKey +import com.yogeshpaliyal.deepr.ui.screens.home.Dashboard2 +import kotlin.collections.remove + +val LocalNavigator = + compositionLocalOf> { TopLevelBackStack(Dashboard2()) } + +sealed interface BaseScreen : NavKey + +interface Screen : BaseScreen { + @Composable + fun Content() +} + +interface TopLevelRoute : BaseScreen { + val icon: ImageVector + + val label: Int + + @Composable + fun Content(windowInsets: WindowInsets) +} + +class TopLevelBackStack( + startKey: T, +) { + // Maintain a stack for each top level route + private var topLevelStacks: LinkedHashMap> = + linkedMapOf( + startKey to mutableStateListOf(startKey), + ) + + // Expose the current top level route for consumers + var topLevelKey by mutableStateOf(startKey) + private set + + // Expose the back stack so it can be rendered by the NavDisplay + val backStack = mutableStateListOf(startKey) + + private fun updateBackStack() = + backStack.apply { + clear() + addAll(topLevelStacks.flatMap { it.value }) + } + + fun clearStackAndAdd(key: T) { + topLevelStacks.clear() + addTopLevel(key) + } + + fun addTopLevel(key: T) { + // If the top level doesn't exist, add it + if (topLevelStacks[key] == null) { + topLevelStacks.put(key, mutableStateListOf(key)) + } else { + // Otherwise just move it to the end of the stacks + topLevelStacks.apply { + remove(key)?.let { + put(key, it) + } + } + } + topLevelKey = key + updateBackStack() + } + + fun add(key: T) { + topLevelStacks[topLevelKey]?.add(key) + updateBackStack() + } + + fun getLast() = backStack.last() + + fun removeLast() { + val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelKey = topLevelStacks.keys.last() + updateBackStack() + } +} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/AboutUs.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/AboutUs.kt index cd0b4cf6..5c589967 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/AboutUs.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/AboutUs.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,20 +37,25 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.yogeshpaliyal.deepr.BuildConfig import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen import compose.icons.TablerIcons import compose.icons.tablericons.ArrowLeft import compose.icons.tablericons.BrandGithub import compose.icons.tablericons.BrandLinkedin import compose.icons.tablericons.BrandTwitter -data object AboutUs +object AboutUs : Screen { + @Composable + override fun Content() { + AboutUsScreen() + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AboutUsScreen( - backStack: SnapshotStateList, - modifier: Modifier = Modifier, -) { +fun AboutUsScreen(modifier: Modifier = Modifier) { + val backStack = LocalNavigator.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl Scaffold(modifier = modifier.fillMaxSize(), topBar = { @@ -62,7 +66,7 @@ fun AboutUsScreen( }, navigationIcon = { IconButton(onClick = { - backStack.removeLastOrNull() + backStack.removeLast() }) { Icon( TablerIcons.ArrowLeft, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/BackupScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/BackupScreen.kt index d21e9c93..d1600207 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/BackupScreen.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/BackupScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext @@ -34,6 +33,8 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen import com.yogeshpaliyal.deepr.ui.components.ServerStatusBar import com.yogeshpaliyal.deepr.ui.components.SettingsItem import com.yogeshpaliyal.deepr.ui.components.SettingsSection @@ -50,15 +51,20 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -data object BackupScreen +object BackupScreen : Screen { + @Composable + override fun Content() { + BackupScreenContent() + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupScreenContent( - backStack: SnapshotStateList, modifier: Modifier = Modifier, viewModel: AccountViewModel = koinViewModel(), ) { + val backStack = LocalNavigator.current val context = LocalContext.current // Launcher for picking CSV export location @@ -137,7 +143,7 @@ fun BackupScreenContent( val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl IconButton(onClick = { - backStack.removeLastOrNull() + backStack.removeLast() }) { Icon( TablerIcons.ArrowLeft, @@ -154,7 +160,7 @@ fun BackupScreenContent( ) ServerStatusBar( onServerStatusClick = { - if (backStack.lastOrNull() !is LocalNetworkServer) { + if (backStack.getLast() !is LocalNetworkServer) { backStack.add(LocalNetworkServer) } }, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt index b25ceab0..27f4f7a9 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt @@ -39,7 +39,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -63,6 +62,8 @@ import com.google.accompanist.permissions.rememberPermissionState import com.lightspark.composeqr.QrCodeView import com.yogeshpaliyal.deepr.R import com.yogeshpaliyal.deepr.server.LocalServerService +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen import com.yogeshpaliyal.deepr.viewmodel.LocalServerViewModel import compose.icons.TablerIcons import compose.icons.tablericons.ArrowLeft @@ -77,16 +78,21 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel -data object LocalNetworkServer +object LocalNetworkServer : Screen { + @Composable + override fun Content() { + LocalNetworkServerScreen() + } +} @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun LocalNetworkServerScreen( - backStack: SnapshotStateList, modifier: Modifier = Modifier, viewModel: LocalServerViewModel = koinViewModel(), ) { val context = LocalContext.current + val navigatorContext = LocalNavigator.current val hapticFeedback = LocalHapticFeedback.current val isRunning by viewModel.isRunning.collectAsStateWithLifecycle() val serverUrl by viewModel.serverUrl.collectAsStateWithLifecycle() @@ -130,7 +136,7 @@ fun LocalNetworkServerScreen( navigationIcon = { IconButton(onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - backStack.removeLastOrNull() + navigatorContext.removeLast() }) { Icon( TablerIcons.ArrowLeft, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt index a2132643..04facd8e 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext @@ -33,6 +32,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen import com.yogeshpaliyal.deepr.ui.components.ServerStatusBar import com.yogeshpaliyal.deepr.ui.components.SettingsItem import com.yogeshpaliyal.deepr.ui.components.SettingsSection @@ -43,16 +44,21 @@ import compose.icons.tablericons.Download import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel -data object RestoreScreen +object RestoreScreen : Screen { + @Composable + override fun Content() { + RestoreScreenContent() + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable fun RestoreScreenContent( - backStack: SnapshotStateList, modifier: Modifier = Modifier, viewModel: AccountViewModel = koinViewModel(), ) { val context = LocalContext.current + val backStack = LocalNavigator.current // Get available importers from the view model val availableImporters = remember { viewModel.getAvailableImporters() } @@ -96,7 +102,7 @@ fun RestoreScreenContent( val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl IconButton(onClick = { - backStack.removeLastOrNull() + backStack.removeLast() }) { Icon( TablerIcons.ArrowLeft, @@ -113,7 +119,7 @@ fun RestoreScreenContent( ) ServerStatusBar( onServerStatusClick = { - if (backStack.lastOrNull() !is LocalNetworkServer) { + if (backStack.getLast() !is LocalNetworkServer) { backStack.add(LocalNetworkServer) } }, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ScanQRVirtualScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ScanQRVirtualScreen.kt new file mode 100644 index 00000000..75bafae9 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ScanQRVirtualScreen.kt @@ -0,0 +1,55 @@ +package com.yogeshpaliyal.deepr.ui.screens + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.journeyapps.barcodescanner.ScanOptions +import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.components.SettingsItem +import com.yogeshpaliyal.deepr.ui.screens.home.Dashboard2 +import com.yogeshpaliyal.deepr.ui.screens.home.createDeeprObject +import com.yogeshpaliyal.deepr.util.QRScanner +import com.yogeshpaliyal.deepr.util.isValidDeeplink +import com.yogeshpaliyal.deepr.util.normalizeLink +import compose.icons.TablerIcons +import compose.icons.tablericons.Qrcode + +@Composable +fun ScanQRCode() { + val navigatorContext = LocalNavigator.current + val context = LocalContext.current + + val qrScanner = + rememberLauncherForActivityResult( + QRScanner(), + ) { result -> + if (result.contents == null) { + Toast.makeText(context, "No Data found", Toast.LENGTH_SHORT).show() + } else { + val normalizedLink = normalizeLink(result.contents) + if (isValidDeeplink(normalizedLink)) { + navigatorContext.clearStackAndAdd( + Dashboard2( + mSelectedLink = + createDeeprObject( + link = normalizedLink, + ), + ), + ) + } else { + Toast.makeText(context, "Invalid deeplink", Toast.LENGTH_SHORT).show() + } + } + } + + SettingsItem( + TablerIcons.Qrcode, + title = stringResource(R.string.scan_qr_code), + onClick = { + qrScanner.launch(ScanOptions()) + }, + ) +} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt index b7fedbcb..0b9fa03c 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -32,10 +33,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource @@ -49,6 +50,8 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.yogeshpaliyal.deepr.BuildConfig import com.yogeshpaliyal.deepr.MainActivity import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.TopLevelRoute import com.yogeshpaliyal.deepr.ui.components.LanguageSelectionDialog import com.yogeshpaliyal.deepr.ui.components.ServerStatusBar import com.yogeshpaliyal.deepr.ui.components.ThemeSelectionDialog @@ -70,16 +73,27 @@ import compose.icons.tablericons.Star import compose.icons.tablericons.Upload import org.koin.androidx.compose.koinViewModel -data object Settings +object Settings : TopLevelRoute { + override val icon: ImageVector + get() = TablerIcons.Settings + override val label: Int + get() = R.string.settings + + @Composable + override fun Content(windowInsets: WindowInsets) { + SettingsScreen(windowInsets) + } +} @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun SettingsScreen( - backStack: SnapshotStateList, + windowInsets: WindowInsets, modifier: Modifier = Modifier, viewModel: AccountViewModel = koinViewModel(), ) { val context = LocalContext.current + val navigatorContext = LocalNavigator.current // Collect the shortcut icon preference state val useLinkBasedIcons by viewModel.useLinkBasedIcons.collectAsStateWithLifecycle() @@ -97,6 +111,7 @@ fun SettingsScreen( val isThumbnailEnable by viewModel.isThumbnailEnable.collectAsStateWithLifecycle() Scaffold( + contentWindowInsets = windowInsets, modifier = modifier.fillMaxSize(), topBar = { Column { @@ -108,7 +123,7 @@ fun SettingsScreen( val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl IconButton(onClick = { - backStack.removeLastOrNull() + navigatorContext.removeLast() }) { Icon( TablerIcons.ArrowLeft, @@ -126,8 +141,8 @@ fun SettingsScreen( ServerStatusBar( onServerStatusClick = { // Navigate to LocalNetworkServer screen when status bar is clicked - if (backStack.lastOrNull() !is LocalNetworkServer) { - backStack.add(LocalNetworkServer) + if (navigatorContext.getLast() !is LocalNetworkServer) { + navigatorContext.add(LocalNetworkServer) } }, ) @@ -150,7 +165,7 @@ fun SettingsScreen( title = stringResource(R.string.backup), description = "Export to CSV, Local file sync, Auto backup", onClick = { - backStack.add(BackupScreen) + navigatorContext.add(BackupScreen) }, ) @@ -159,7 +174,7 @@ fun SettingsScreen( title = stringResource(R.string.restore), description = "Import from CSV, Bookmarks, and other formats", onClick = { - backStack.add(RestoreScreen) + navigatorContext.add(RestoreScreen) }, ) } @@ -169,10 +184,12 @@ fun SettingsScreen( TablerIcons.Server, title = stringResource(R.string.local_network_server), onClick = { - backStack.add(LocalNetworkServer) + navigatorContext.add(LocalNetworkServer) }, ) + ScanQRCode() + SettingsItem( TablerIcons.Settings, title = stringResource(R.string.shortcut_icon), @@ -332,7 +349,7 @@ fun SettingsScreen( TablerIcons.InfoCircle, title = stringResource(R.string.about_us), onClick = { - backStack.add(AboutUs) + navigatorContext.add(AboutUs) }, ) } diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Splash.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Splash.kt new file mode 100644 index 00000000..f127f793 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Splash.kt @@ -0,0 +1,47 @@ +package com.yogeshpaliyal.deepr.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen +import com.yogeshpaliyal.deepr.ui.screens.home.Dashboard2 +import kotlinx.serialization.Serializable + +@Serializable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +object Splash : Screen { + @Composable + override fun Content() { + val navigator = LocalNavigator.current + val context = LocalContext.current + + LaunchedEffect(Unit) { +// // Check if user is opening the app for the first time +// val isFirstTime = context.isFirstTimeUser() +// +// // Navigate to appropriate screen +// if (isFirstTime) { +// navigator.add(IntroScreen) +// } else { + navigator.clearStackAndAdd(Dashboard2()) +// } + } + + // Loading screen while checking user status + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + ContainedLoadingIndicator() + } + } +} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt index 16185b7b..69077208 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt @@ -39,7 +39,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -60,6 +59,8 @@ import com.lightspark.composeqr.QrCodeView import com.yogeshpaliyal.deepr.R import com.yogeshpaliyal.deepr.server.LocalServerService import com.yogeshpaliyal.deepr.server.LocalServerTransferLink +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.Screen import com.yogeshpaliyal.deepr.util.QRScanner import com.yogeshpaliyal.deepr.viewmodel.TransferLinkLocalServerViewModel import compose.icons.TablerIcons @@ -72,16 +73,21 @@ import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject -data object TransferLinkLocalNetworkServer +object TransferLinkLocalNetworkServer : Screen { + @Composable + override fun Content() { + TransferLinkLocalServerScreen() + } +} @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun TransferLinkLocalServerScreen( - backStack: SnapshotStateList, modifier: Modifier = Modifier, viewModel: TransferLinkLocalServerViewModel = koinViewModel(), ) { val context = LocalContext.current + val backStack = LocalNavigator.current val localServerInstance = koinInject() val isRunning by localServerInstance.isRunning.collectAsStateWithLifecycle() val serverUrl by localServerInstance.serverUrl.collectAsStateWithLifecycle() @@ -136,7 +142,7 @@ fun TransferLinkLocalServerScreen( }, navigationIcon = { IconButton(onClick = { - backStack.removeLastOrNull() + backStack.removeLast() }) { Icon( TablerIcons.ArrowLeft, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt index 60b93c41..9e2cc3e8 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt @@ -4,7 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.scaleIn @@ -12,6 +12,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,16 +20,21 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.selection.SelectionContainer @@ -39,10 +45,8 @@ import androidx.compose.material3.AppBarWithSearch import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingToolbarDefaults -import androidx.compose.material3.FloatingToolbarExitDirection -import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -58,7 +62,9 @@ import androidx.compose.material3.SearchBarValue import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults @@ -67,19 +73,21 @@ import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf 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.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -89,22 +97,23 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import com.journeyapps.barcodescanner.ScanOptions import com.yogeshpaliyal.deepr.DeeprQueries import com.yogeshpaliyal.deepr.GetLinksAndTags +import com.yogeshpaliyal.deepr.LocalSharedText import com.yogeshpaliyal.deepr.R import com.yogeshpaliyal.deepr.SharedLink import com.yogeshpaliyal.deepr.Tags import com.yogeshpaliyal.deepr.analytics.AnalyticsEvents import com.yogeshpaliyal.deepr.analytics.AnalyticsManager import com.yogeshpaliyal.deepr.analytics.AnalyticsParams +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.TopLevelRoute import com.yogeshpaliyal.deepr.ui.components.ClearInputIconButton import com.yogeshpaliyal.deepr.ui.components.CreateShortcutDialog import com.yogeshpaliyal.deepr.ui.components.DeleteConfirmationDialog import com.yogeshpaliyal.deepr.ui.components.QrCodeDialog import com.yogeshpaliyal.deepr.ui.components.ServerStatusBar import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServer -import com.yogeshpaliyal.deepr.ui.screens.Settings import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.Click import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.Copy import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.Delete @@ -114,7 +123,6 @@ import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.MoreOptionsBottomSheet import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.ResetCounter import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.Shortcut import com.yogeshpaliyal.deepr.ui.screens.home.MenuItem.ShowQrCode -import com.yogeshpaliyal.deepr.util.QRScanner import com.yogeshpaliyal.deepr.util.isValidDeeplink import com.yogeshpaliyal.deepr.util.normalizeLink import com.yogeshpaliyal.deepr.util.openDeeplink @@ -122,15 +130,16 @@ import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel import compose.icons.TablerIcons import compose.icons.tablericons.ArrowLeft import compose.icons.tablericons.Edit +import compose.icons.tablericons.Home import compose.icons.tablericons.Link import compose.icons.tablericons.Note import compose.icons.tablericons.Plus import compose.icons.tablericons.Qrcode import compose.icons.tablericons.Refresh import compose.icons.tablericons.Search -import compose.icons.tablericons.Settings import compose.icons.tablericons.Tag import compose.icons.tablericons.Trash +import compose.icons.tablericons.X import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -138,12 +147,40 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinActivityViewModel data object Home +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalHazeMaterialsApi::class, + ExperimentalMaterial3ExpressiveApi::class, +) +class Dashboard2( + val mSelectedLink: GetLinksAndTags? = null, +) : TopLevelRoute { + override val icon: ImageVector + get() = TablerIcons.Home + override val label: Int + get() = R.string.home + + @Composable + override fun Content(windowInsets: WindowInsets) { + val localSharedText = LocalSharedText.current + Surface(shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) { + HomeScreen( + windowInsets, + mSelectedLink = mSelectedLink, + sharedText = localSharedText?.first, + resetSharedText = { + localSharedText?.second?.invoke() + }, + ) + } + } +} + @OptIn( ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, @@ -151,18 +188,20 @@ data object Home ) @Composable fun HomeScreen( - backStack: SnapshotStateList, + windowInsets: WindowInsets, modifier: Modifier = Modifier, - viewModel: AccountViewModel = koinViewModel(), deeprQueries: DeeprQueries = koinInject(), analyticsManager: AnalyticsManager = koinInject(), + mSelectedLink: GetLinksAndTags? = null, sharedText: SharedLink? = null, resetSharedText: () -> Unit, ) { - var isTagsSelectionActive by remember { mutableStateOf(false) } + val viewModel: AccountViewModel = koinActivityViewModel() val currentViewType by viewModel.viewType.collectAsStateWithLifecycle() + val localNavigator = LocalNavigator.current + val hapticFeedback = LocalHapticFeedback.current - var selectedLink by remember { mutableStateOf(null) } + var selectedLink by remember { mutableStateOf(mSelectedLink) } val selectedTag by viewModel.selectedTagFilter.collectAsStateWithLifecycle() val hazeState = rememberHazeState(blurEnabled = true) val context = LocalContext.current @@ -172,24 +211,31 @@ fun HomeScreen( val scope = rememberCoroutineScope() val totalLinks by viewModel.countOfLinks.collectAsStateWithLifecycle() val favouriteLinks by viewModel.countOfFavouriteLinks.collectAsStateWithLifecycle() - val allTagsWithCount by viewModel.allTagsWithCount.collectAsStateWithLifecycle() val favouriteFilter by viewModel.favouriteFilter.collectAsStateWithLifecycle() - - val qrScanner = - rememberLauncherForActivityResult( - QRScanner(), - ) { result -> - if (result.contents == null) { - Toast.makeText(context, "No Data found", Toast.LENGTH_SHORT).show() + val listState = if (currentViewType == ViewType.GRID) rememberLazyStaggeredGridState() else rememberLazyListState() + val isExpanded by remember(listState) { + // Example: expanded only when at the very top of the list + derivedStateOf { + if (listState is LazyStaggeredGridState) { + listState.firstVisibleItemIndex == 0 + } else if (listState is LazyListState) { + listState.firstVisibleItemIndex == 0 } else { - val normalizedLink = normalizeLink(result.contents) - if (isValidDeeplink(normalizedLink)) { - selectedLink = createDeeprObject(link = normalizedLink) - } else { - Toast.makeText(context, "Invalid deeplink", Toast.LENGTH_SHORT).show() - } + true + } + } + } + + BackHandler(enabled = selectedTag.isNotEmpty() || searchBarState.currentValue == SearchBarValue.Expanded) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.VirtualKey) + if (searchBarState.currentValue == SearchBarValue.Expanded) { + scope.launch { + searchBarState.animateToCollapsed() } + } else if (selectedTag.isNotEmpty()) { + viewModel.setTagFilter(null) } + } // Handle shared text from other apps LaunchedEffect(sharedText) { @@ -300,6 +346,7 @@ fun HomeScreen( } Scaffold( + contentWindowInsets = windowInsets, modifier = modifier.fillMaxSize(), topBar = { Column( @@ -323,8 +370,8 @@ fun HomeScreen( onServerStatusClick = { // Navigate to LocalNetworkServer screen when status bar is clicked analyticsManager.logEvent(AnalyticsEvents.NAVIGATE_LOCAL_SERVER) - if (backStack.lastOrNull() !is LocalNetworkServer) { - backStack.add(LocalNetworkServer) + if (localNavigator.getLast() !is LocalNetworkServer) { + localNavigator.add(LocalNetworkServer) } }, ) @@ -357,71 +404,80 @@ fun HomeScreen( label = { Text(stringResource(R.string.favourites) + " (${favouriteLinks ?: 0})") }, ) } - } - }, - bottomBar = { - Box( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding(), - contentAlignment = Alignment.Center, - ) { - HorizontalFloatingToolbar( - expanded = true, - scrollBehavior = FloatingToolbarDefaults.exitAlwaysScrollBehavior(exitDirection = FloatingToolbarExitDirection.Bottom), - colors = FloatingToolbarDefaults.standardFloatingToolbarColors(), - content = { - IconButton(onClick = { - analyticsManager.logEvent(AnalyticsEvents.SCAN_QR_CODE) - qrScanner.launch(ScanOptions()) - }) { - Icon( - TablerIcons.Qrcode, - contentDescription = stringResource(R.string.qr_scanner), - ) - } - IconButton(onClick = { - isTagsSelectionActive = true - }) { - Icon( - TablerIcons.Tag, - contentDescription = stringResource(R.string.tags), - ) - } - IconButton(onClick = { - // Settings action - analyticsManager.logEvent(AnalyticsEvents.NAVIGATE_SETTINGS) - backStack.add(Settings) - }) { - Icon( - TablerIcons.Settings, - contentDescription = stringResource(R.string.settings), - ) - } - }, - floatingActionButton = { - FloatingToolbarDefaults.VibrantFloatingActionButton(onClick = { - selectedLink = createDeeprObject() - }) { - Icon( - TablerIcons.Plus, - contentDescription = stringResource(R.string.add_link), + + // Selected tags filter chips + AnimatedVisibility( + visible = selectedTag.isNotEmpty(), + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + selectedTag.forEach { tag -> + FilterChip( + selected = true, + onClick = { viewModel.setTagFilter(tag) }, + label = { Text(tag.name) }, + leadingIcon = { + Icon( + TablerIcons.Tag, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + trailingIcon = { + Icon( + TablerIcons.X, + contentDescription = stringResource(R.string.remove_tag), + modifier = Modifier.size(18.dp), + ) + }, ) } - }, - ) + } + } } }, + floatingActionButton = { + ExtendedFloatingActionButton( + icon = { + Icon( + TablerIcons.Plus, + contentDescription = stringResource(R.string.add_link), + ) + }, + text = { + Text(stringResource(R.string.add_link)) + }, + expanded = isExpanded, + onClick = { + selectedLink = createDeeprObject() + }, + ) + }, ) { contentPadding -> Box( modifier = Modifier .fillMaxSize(), ) { + val layoutDirection = LocalLayoutDirection.current Content( + listState = listState, + viewModel = viewModel, hazeState = hazeState, - contentPaddingValues = contentPadding, + contentPaddingValues = + PaddingValues( + start = contentPadding.calculateLeftPadding(layoutDirection), + end = contentPadding.calculateRightPadding(layoutDirection), + top = contentPadding.calculateTopPadding() + 8.dp, + bottom = contentPadding.calculateBottomPadding() + 8.dp, + ), selectedTag = selectedTag, currentViewType = currentViewType, searchQuery = textFieldState.text.toString(), @@ -446,46 +502,21 @@ fun HomeScreen( resetSharedText() } } - - if (isTagsSelectionActive) { - TagSelectionBottomSheet( - tagsWithCount = allTagsWithCount, - selectedTag = selectedTag, - dismissBottomSheet = { - isTagsSelectionActive = false - }, - setTagFilter = { viewModel.setTagFilter(it) }, - editTag = { tag -> - runBlocking { - try { - viewModel.updateTag(tag) - Result.success(true) - } catch (e: Exception) { - return@runBlocking Result.failure(e) - } - } - }, - deleteTag = { - viewModel.deleteTag(it.id) - Result.success(true) - }, - deeprQueries = deeprQueries, - ) - } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun Content( + listState: ScrollableState, hazeState: HazeState, selectedTag: List, contentPaddingValues: PaddingValues, currentViewType: @ViewType Int, searchQuery: String, favouriteFilter: Int, + viewModel: AccountViewModel, modifier: Modifier = Modifier, - viewModel: AccountViewModel = koinViewModel(), editDeepr: (GetLinksAndTags) -> Unit = {}, ) { val accounts by viewModel.accounts.collectAsStateWithLifecycle() @@ -549,7 +580,7 @@ fun Content( showDeleteConfirmDialog = it.item } - is MenuItem.Edit -> { + is Edit -> { analyticsManager.logEvent(AnalyticsEvents.ITEM_MENU_EDIT) editDeepr(it.item) } @@ -594,11 +625,12 @@ fun Content( Column(modifier.fillMaxSize()) { DeeprList( + listState = listState, modifier = Modifier .weight(1f) .hazeSource(state = hazeState) - .padding(8.dp), + .padding(horizontal = 8.dp), contentPaddingValues = contentPaddingValues, accounts = accounts!!, selectedTag = selectedTag, @@ -792,7 +824,7 @@ fun Content( // Show "Load More" or "Show Less" button if there are more than 9 tags if ((selectedTags?.size ?: 0) > 9) { - androidx.compose.material3.TextButton( + TextButton( onClick = { tagsExpanded = !tagsExpanded }, modifier = Modifier.padding(start = 4.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), @@ -860,6 +892,7 @@ fun MenuListItem( @Composable fun DeeprList( + listState: ScrollableState, accounts: List, selectedTag: List, contentPaddingValues: PaddingValues, @@ -961,6 +994,7 @@ fun DeeprList( when (viewType) { ViewType.LIST -> { LazyColumn( + state = listState as? LazyListState ?: rememberLazyListState(), modifier = modifier, contentPadding = contentPaddingValues, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -985,6 +1019,7 @@ fun DeeprList( ViewType.GRID -> { LazyVerticalStaggeredGrid( + state = listState as? LazyStaggeredGridState ?: rememberLazyStaggeredGridState(), columns = StaggeredGridCells.Adaptive(minSize = 160.dp), modifier = modifier, contentPadding = contentPaddingValues, @@ -1008,6 +1043,7 @@ fun DeeprList( ViewType.COMPACT -> { LazyColumn( + state = listState as? LazyListState ?: rememberLazyListState(), modifier = modifier, contentPadding = contentPaddingValues, verticalArrangement = Arrangement.spacedBy(4.dp), diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/TagSelectionBottomSheet.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/TagSelectionBottomSheet.kt deleted file mode 100644 index 24ab197d..00000000 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/TagSelectionBottomSheet.kt +++ /dev/null @@ -1,385 +0,0 @@ -package com.yogeshpaliyal.deepr.ui.screens.home - -import android.database.sqlite.SQLiteConstraintException -import android.widget.Toast -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -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.graphics.Color -import androidx.compose.ui.platform.LocalContext -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.withStyle -import androidx.compose.ui.unit.dp -import com.yogeshpaliyal.deepr.GetAllTagsWithCount -import com.yogeshpaliyal.deepr.R -import com.yogeshpaliyal.deepr.Tags -import com.yogeshpaliyal.deepr.ui.components.ClearInputIconButton -import compose.icons.TablerIcons -import compose.icons.tablericons.Edit -import compose.icons.tablericons.Plus -import compose.icons.tablericons.Trash - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TagSelectionBottomSheet( - tagsWithCount: List, - selectedTag: List, - dismissBottomSheet: () -> Unit, - setTagFilter: (Tags?) -> Unit, - editTag: (Tags) -> Result, - deleteTag: (Tags) -> Result, - deeprQueries: com.yogeshpaliyal.deepr.DeeprQueries, - modifier: Modifier = Modifier, -) { - val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var isTagEditEnable by remember { mutableStateOf(null) } - var isTagDeleteEnable by remember { mutableStateOf(null) } - var tagEditError by remember { mutableStateOf(null) } - val context = LocalContext.current - var newTagName by remember { mutableStateOf("") } - - isTagEditEnable?.let { tag -> - AlertDialog( - onDismissRequest = { - isTagEditEnable = null - tagEditError = null - }, - title = { - Text(text = stringResource(R.string.edit_tag)) - }, - text = { - Column { - TextField( - value = tag.name, - onValueChange = { - isTagEditEnable = tag.copy(name = it) - }, - isError = tagEditError != null, - supportingText = { - tagEditError?.let { - Text(text = it) - } - }, - suffix = - if (isTagEditEnable?.name.isNullOrEmpty()) { - null - } else { - { - ClearInputIconButton( - onClick = { - isTagEditEnable = tag.copy(name = "") - }, - ) - } - }, - ) - } - }, - confirmButton = { - Button(onClick = { - val result = editTag(Tags(tag.id, tag.name)) - if (result.isFailure) { - val exception = result.exceptionOrNull() - tagEditError = - when (exception) { - is SQLiteConstraintException -> { - context.getString(R.string.tag_name_exists) - } - - else -> { - context.getString(R.string.failed_to_edit_tag) - } - } - } else { - isTagEditEnable = null - tagEditError = null - Toast - .makeText( - context, - context.getString(R.string.tag_edited_successfully), - Toast.LENGTH_SHORT, - ).show() - } - }) { - Text(stringResource(R.string.edit)) - } - }, - dismissButton = { - Button(onClick = { - isTagEditEnable = null - tagEditError = null - }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - - isTagDeleteEnable?.let { tag -> - AlertDialog( - onDismissRequest = { - isTagDeleteEnable = null - }, - title = { - Text(text = stringResource(R.string.delete_tag)) - }, - text = { - val message = - buildAnnotatedString { - append("Are you sure you want to delete ") - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("'${tag.name}'") - } - append(" tag?") - } - Text(text = message) - }, - confirmButton = { - Button( - onClick = { - val result = deleteTag(Tags(tag.id, tag.name)) - if (result.isFailure) { - Toast - .makeText( - context, - context.getString( - R.string.failed_to_delete_tag, - result.exceptionOrNull(), - ), - Toast.LENGTH_SHORT, - ).show() - } else { - isTagDeleteEnable = null - Toast - .makeText( - context, - context.getString(R.string.tag_deleted_successfully), - Toast.LENGTH_SHORT, - ).show() - } - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Text(stringResource(R.string.delete)) - } - }, - dismissButton = { - OutlinedButton(onClick = { - isTagDeleteEnable = null - }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } - - ModalBottomSheet(sheetState = modalBottomSheetState, onDismissRequest = dismissBottomSheet) { - Column(modifier) { - TopAppBar( - title = { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(end = 24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(R.string.tags)) - - Spacer(modifier = Modifier.width(24.dp)) - - OutlinedTextField( - value = newTagName, - onValueChange = { newTagName = it }, - modifier = Modifier.weight(1f), - label = { Text(stringResource(R.string.new_tag)) }, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - suffix = - if (newTagName.isNotBlank()) { - { - ClearInputIconButton( - onClick = { - newTagName = "" - }, - ) - } - } else { - null - }, - ) - - Spacer(modifier = Modifier.width(8.dp)) - - FilledIconButton( - onClick = { - val trimmedTagName = newTagName.trim() - if (trimmedTagName.isNotBlank()) { - val existingTag = - tagsWithCount.find { - it.name.equals( - trimmedTagName, - ignoreCase = true, - ) - } - - if (existingTag != null) { - Toast - .makeText( - context, - context.getString(R.string.tag_name_exists), - Toast.LENGTH_SHORT, - ).show() - } else { - deeprQueries.insertTag(trimmedTagName) - newTagName = "" - } - } - }, - enabled = newTagName.isNotBlank(), - shape = CircleShape, - ) { - Icon( - imageVector = TablerIcons.Plus, - contentDescription = stringResource(R.string.create_tag), - ) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - HorizontalDivider() - LazyColumn { - // Show "Clear All Filters" option if any tags are selected - if (selectedTag.isNotEmpty()) { - item { - ListItem( - modifier = - Modifier.clickable { - setTagFilter(null) - }, - headlineContent = { - Text( - stringResource(R.string.clear_all_filters), - color = MaterialTheme.colorScheme.error, - ) - }, - ) - } - } - - item { - ListItem( - modifier = - Modifier.clickable { - // Don't dismiss, allow multi-selection - }, - headlineContent = { - Text( - if (selectedTag.isEmpty()) { - stringResource(R.string.all) - } else { - stringResource(R.string.selected_tags_count, selectedTag.size) - }, - ) - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - ) - } - - items(tagsWithCount.sortedBy { it.name }) { tag -> - val isSelected = selectedTag.any { it.id == tag.id } - ListItem( - modifier = - Modifier.clickable { - setTagFilter(Tags(tag.id, tag.name)) - // Don't dismiss to allow multi-selection - }, - leadingContent = { - androidx.compose.material3.Checkbox( - checked = isSelected, - onCheckedChange = { - setTagFilter(Tags(tag.id, tag.name)) - }, - ) - }, - headlineContent = { Text("${tag.name} (${tag.linkCount})") }, - trailingContent = { - Row { - IconButton(onClick = { - isTagEditEnable = tag - }) { - Icon( - imageVector = TablerIcons.Edit, - contentDescription = stringResource(R.string.edit_tag_description), - ) - } - - IconButton(onClick = { - isTagDeleteEnable = tag - }) { - Icon( - imageVector = TablerIcons.Trash, - contentDescription = stringResource(R.string.delete_tag_description), - ) - } - } - }, - colors = - if (isSelected) { - ListItemDefaults.colors( - headlineColor = MaterialTheme.colorScheme.primary, - ) - } else { - ListItemDefaults.colors(containerColor = Color.Transparent) - }, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/TagSelectionScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/TagSelectionScreen.kt new file mode 100644 index 00000000..e9fad689 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/TagSelectionScreen.kt @@ -0,0 +1,601 @@ +package com.yogeshpaliyal.deepr.ui.screens.home + +import android.database.sqlite.SQLiteConstraintException +import android.view.Surface +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +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.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.yogeshpaliyal.deepr.DeeprQueries +import com.yogeshpaliyal.deepr.GetAllTagsWithCount +import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.Tags +import com.yogeshpaliyal.deepr.ui.LocalNavigator +import com.yogeshpaliyal.deepr.ui.TopLevelRoute +import com.yogeshpaliyal.deepr.ui.components.ClearInputIconButton +import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel +import compose.icons.TablerIcons +import compose.icons.tablericons.Edit +import compose.icons.tablericons.Eye +import compose.icons.tablericons.Plus +import compose.icons.tablericons.Tag +import compose.icons.tablericons.Trash +import kotlinx.coroutines.runBlocking +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinActivityViewModel + +object TagSelectionScreen : TopLevelRoute { + override val icon: ImageVector + get() = TablerIcons.Tag + override val label: Int + get() = R.string.tags + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content(windowInsets: WindowInsets) { + val viewModel: AccountViewModel = koinActivityViewModel() + val selectedTag by viewModel.selectedTagFilter.collectAsStateWithLifecycle() + var newTagName by remember { mutableStateOf("") } + val tagsWithCount by viewModel.allTagsWithCount.collectAsStateWithLifecycle() + val context = LocalContext.current + val navigator = LocalNavigator.current + val deeprQueries: DeeprQueries = koinInject() + var isTagEditEnable by remember { mutableStateOf(null) } + var isTagDeleteEnable by remember { mutableStateOf(null) } + var tagEditError by remember { mutableStateOf(null) } + + Scaffold( + contentWindowInsets = windowInsets, + floatingActionButton = { + AnimatedVisibility( + selectedTag.isNotEmpty(), + enter = scaleIn(), + exit = scaleOut(), + ) { + ExtendedFloatingActionButton( + onClick = { + navigator.clearStackAndAdd(Dashboard2()) + }, + icon = { + Icon( + imageVector = TablerIcons.Eye, + contentDescription = "View Filtered Links", + ) + }, + text = { Text("View Filtered Links") }, + ) + } + }, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + // Top Section - Create New Tag + Surface( + modifier = Modifier.fillMaxWidth(), + tonalElevation = 2.dp, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.tags), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = newTagName, + onValueChange = { newTagName = it }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.new_tag)) }, + placeholder = { Text("Enter tag name") }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + trailingIcon = + if (newTagName.isNotBlank()) { + { + ClearInputIconButton( + onClick = { + newTagName = "" + }, + ) + } + } else { + null + }, + ) + + FilledIconButton( + onClick = { + val trimmedTagName = newTagName.trim() + if (trimmedTagName.isNotBlank()) { + val existingTag = + tagsWithCount.find { + it.name.equals( + trimmedTagName, + ignoreCase = true, + ) + } + + if (existingTag != null) { + Toast + .makeText( + context, + context.getString(R.string.tag_name_exists), + Toast.LENGTH_SHORT, + ).show() + } else { + deeprQueries.insertTag(trimmedTagName) + newTagName = "" + Toast + .makeText( + context, + "Tag created successfully", + Toast.LENGTH_SHORT, + ).show() + } + } + }, + enabled = newTagName.isNotBlank(), + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = TablerIcons.Plus, + contentDescription = stringResource(R.string.create_tag), + ) + } + } + + // Show selected tags info + AnimatedVisibility(selectedTag.isNotEmpty()) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + stringResource( + R.string.selected_tags_count, + selectedTag.size, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + TextButton( + onClick = { viewModel.setTagFilter(null) }, + ) { + Text(stringResource(R.string.clear_all_filters)) + } + } + } + } + } + } + } + + Surface { + // Tags List + if (tagsWithCount.isEmpty()) { + // Empty State + Box( + modifier = + Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = TablerIcons.Tag, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No tags yet", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Create your first tag to organize your links", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center, + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(tagsWithCount.sortedBy { it.name }) { tag -> + val isSelected = selectedTag.any { it.id == tag.id } + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { + viewModel.setTagFilter(Tags(tag.id, tag.name)) + }, + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + border = + if (isSelected) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.Checkbox( + checked = isSelected, + onCheckedChange = { + viewModel.setTagFilter(Tags(tag.id, tag.name)) + }, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = tag.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${tag.linkCount} ${if (tag.linkCount == 1L) "link" else "links"}", + style = MaterialTheme.typography.bodySmall, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.7f, + ) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = { isTagEditEnable = tag }, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) { + Icon( + imageVector = TablerIcons.Edit, + contentDescription = stringResource(R.string.edit_tag_description), + modifier = Modifier.size(20.dp), + ) + } + + IconButton( + onClick = { isTagDeleteEnable = tag }, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + imageVector = TablerIcons.Trash, + contentDescription = stringResource(R.string.delete_tag_description), + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + } + } + + isTagEditEnable?.let { tag -> + AlertDialog( + onDismissRequest = { + isTagEditEnable = null + tagEditError = null + }, + title = { + Text( + text = stringResource(R.string.edit_tag), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column { + OutlinedTextField( + value = tag.name, + onValueChange = { + isTagEditEnable = tag.copy(name = it) + tagEditError = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Tag name") }, + singleLine = true, + isError = tagEditError != null, + supportingText = { + tagEditError?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + ) + } + }, + shape = RoundedCornerShape(12.dp), + trailingIcon = + if (isTagEditEnable?.name?.isNotBlank() == true) { + { + ClearInputIconButton( + onClick = { + isTagEditEnable = tag.copy(name = "") + }, + ) + } + } else { + null + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + val trimmedName = isTagEditEnable?.name?.trim() ?: "" + if (trimmedName.isBlank()) { + tagEditError = "Tag name cannot be empty" + return@Button + } + + val result = + runBlocking { + try { + viewModel.updateTag(Tags(tag.id, trimmedName)) + Result.success(true) + } catch (e: Exception) { + return@runBlocking Result.failure(e) + } + } + if (result.isFailure) { + val exception = result.exceptionOrNull() + tagEditError = + when (exception) { + is SQLiteConstraintException -> { + context.getString(R.string.tag_name_exists) + } + + else -> { + context.getString(R.string.failed_to_edit_tag) + } + } + } else { + isTagEditEnable = null + tagEditError = null + Toast + .makeText( + context, + context.getString(R.string.tag_edited_successfully), + Toast.LENGTH_SHORT, + ).show() + } + }, + enabled = isTagEditEnable?.name?.isNotBlank() == true, + ) { + Text(stringResource(R.string.edit)) + } + }, + dismissButton = { + TextButton(onClick = { + isTagEditEnable = null + tagEditError = null + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + isTagDeleteEnable?.let { tag -> + AlertDialog( + onDismissRequest = { + isTagDeleteEnable = null + }, + icon = { + Icon( + imageVector = TablerIcons.Trash, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { + Text( + text = stringResource(R.string.delete_tag), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column { + val message = + buildAnnotatedString { + append("Are you sure you want to delete ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("'${tag.name}'") + } + append(" tag?") + } + Text(text = message) + + if (tag.linkCount > 0) { + Spacer(modifier = Modifier.height(8.dp)) + Card( + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.3f, + ), + ), + ) { + Text( + text = "This tag is used by ${tag.linkCount} ${if (tag.linkCount == 1L) "link" else "links"}", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + viewModel.deleteTag(tag.id) + + isTagDeleteEnable = null + Toast + .makeText( + context, + context.getString(R.string.tag_deleted_successfully), + Toast.LENGTH_SHORT, + ).show() + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { + isTagDeleteEnable = null + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e27473c..19ca581b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ Tags: Suggestions: Filter + Home +%d more Tags: %d Show less