Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[*.{kt,kts}]
ktlint_function_naming_ignore_when_annotated_with = Composable
compose_allowed_composition_locals = LocalNavigator,LocalSharedText
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -90,7 +91,7 @@ class DeeprApplication : Application() {
}
}

viewModel { AccountViewModel(get(), get(), get(), get(), get(), get(), get()) }
viewModelOf(::AccountViewModel)

single {
HtmlParser()
Expand Down
181 changes: 108 additions & 73 deletions app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,43 @@ 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
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -123,70 +132,96 @@ class MainActivity : ComponentActivity() {
}
}

private val TOP_LEVEL_ROUTES: List<TopLevelRoute> =
listOf(Dashboard2(), TagSelectionScreen, Settings)

val LocalSharedText =
compositionLocalOf<Pair<SharedLink?, () -> Unit>?> { null }

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Dashboard(
modifier: Modifier = Modifier,
sharedText: SharedLink? = null,
resetSharedText: () -> Unit,
) {
val backStack = remember(sharedText) { mutableStateListOf<Any>(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<BaseScreen>(
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
Comment on lines +165 to +172
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition (TOP_LEVEL_ROUTES.any { it::class == current::class }) uses reference equality on class instances. Since Dashboard2() creates a new instance each time, this comparison might not work as expected if instances are not the same. Consider using a sealed interface or enum for route identification instead.

Suggested change
(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
(TOP_LEVEL_ROUTES.any { it.id == current.id }),
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
BottomAppBar(scrollBehavior = scrollBehavior) {
TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
val isSelected =
topLevelRoute.id == backStack.topLevelKey.id

Copilot uses AI. Check for mistakes.
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") }
}
},
)
},
)
}
}
}
}
92 changes: 92 additions & 0 deletions app/src/main/java/com/yogeshpaliyal/deepr/ui/Navigation.kt
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unused import statement for kotlin.collections.remove should be removed. The remove function is already available on LinkedHashMap without this import.

Suggested change
import kotlin.collections.remove

Copilot uses AI. Check for mistakes.

val LocalNavigator =
compositionLocalOf<TopLevelBackStack<BaseScreen>> { TopLevelBackStack(Dashboard2()) }
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory leak: The LocalNavigator composition local is initialized with a default TopLevelBackStack(Dashboard2()) that's never used. This creates an unnecessary instance. Consider using a nullable type or throwing an error if accessed without being provided.

Suggested change
compositionLocalOf<TopLevelBackStack<BaseScreen>> { TopLevelBackStack(Dashboard2()) }
compositionLocalOf<TopLevelBackStack<BaseScreen>> { error("LocalNavigator not provided") }

Copilot uses AI. Check for mistakes.

sealed interface BaseScreen : NavKey

interface Screen : BaseScreen {
@Composable
fun Content()
}

interface TopLevelRoute : BaseScreen {
val icon: ImageVector

val label: Int

@Composable
fun Content(windowInsets: WindowInsets)
}

Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation for the TopLevelBackStack class. This is a core navigation component that manages the app's navigation state and should have comprehensive KDoc explaining its purpose, behavior, and usage patterns.

Suggested change
/**
* Core navigation component that manages the app's navigation state for top-level routes.
*
* This class maintains a separate navigation stack for each top-level route (such as tabs or main sections).
* When navigating to a new top-level route, it either creates a new stack or restores the existing one,
* allowing users to preserve their navigation history within each section.
*
* The [TopLevelBackStack] exposes methods to add new routes, switch between top-level routes,
* and manage the back stack. It is designed to be used with Compose's CompositionLocal system
* (see [LocalNavigator]) to provide navigation state throughout the app.
*
* Typical usage:
* ```
* val navigator = TopLevelBackStack(Dashboard2())
* navigator.addTopLevel(SettingsScreen())
* navigator.add(DetailsScreen())
* navigator.removeLast()
* ```
*
* @param T The type representing navigation keys/screens.
* @property startKey The initial top-level route.
*/

Copilot uses AI. Check for mistakes.
class TopLevelBackStack<T : Any>(
startKey: T,
) {
// Maintain a stack for each top level route
private var topLevelStacks: LinkedHashMap<T, SnapshotStateList<T>> =
linkedMapOf(
startKey to mutableStateListOf(startKey),
)
Comment on lines +40 to +42
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linkedMapOf constructor is used but the generic type for the map values is SnapshotStateList<T>. This could cause type safety issues. Consider explicitly declaring the type: LinkedHashMap<T, SnapshotStateList<T>>() for better clarity.

Suggested change
linkedMapOf(
startKey to mutableStateListOf(startKey),
)
LinkedHashMap<T, SnapshotStateList<T>>().apply {
put(startKey, mutableStateListOf(startKey))
}

Copilot uses AI. Check for mistakes.

// 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)
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method clearStackAndAdd clears all navigation stacks but doesn't check if the backStack is empty before adding the new key. If navigation state relies on having at least one item, this could cause issues. Consider documenting this behavior or adding a safeguard.

Suggested change
addTopLevel(key)
addTopLevel(key)
// Safeguard: Ensure backStack is not empty after operation
check(backStack.isNotEmpty()) { "Navigation backStack must not be empty after clearStackAndAdd. Key: $key" }

Copilot uses AI. Check for mistakes.
}

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()
Comment on lines +87 to +89
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removeLast() method could throw an exception if the backStack is empty. Consider adding a null safety check or returning a boolean to indicate success/failure, similar to how removeLastOrNull() was used in the old code.

Suggested change
// If the removed key was a top level key, remove the associated top level stack
topLevelStacks.remove(removedKey)
topLevelKey = topLevelStacks.keys.last()
if (removedKey != null) {
// If the removed key was a top level key, remove the associated top level stack
topLevelStacks.remove(removedKey)
}
if (topLevelStacks.isNotEmpty()) {
topLevelKey = topLevelStacks.keys.last()
}

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null pointer exception: topLevelStacks.keys.last() on line 89 can throw NoSuchElementException if topLevelStacks becomes empty after removing the last key. Add a check to prevent navigation state from becoming invalid.

Suggested change
topLevelKey = topLevelStacks.keys.last()
if (topLevelStacks.isNotEmpty()) {
topLevelKey = topLevelStacks.keys.last()
}

Copilot uses AI. Check for mistakes.
updateBackStack()
}
}
18 changes: 11 additions & 7 deletions app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/AboutUs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Any>,
modifier: Modifier = Modifier,
) {
fun AboutUsScreen(modifier: Modifier = Modifier) {
val backStack = LocalNavigator.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

Scaffold(modifier = modifier.fillMaxSize(), topBar = {
Expand All @@ -62,7 +66,7 @@ fun AboutUsScreen(
},
navigationIcon = {
IconButton(onClick = {
backStack.removeLastOrNull()
backStack.removeLast()
}) {
Icon(
TablerIcons.ArrowLeft,
Expand Down
Loading