Skip to content
Open
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
7 changes: 7 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class SnackbarErrorObserver(
override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
when (activity) {
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
is BottomNavOwner -> snackbar.anchorView = activity.bottomNavView
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
}
if (canResolve(value)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() {
val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',')
return if (raw.isNullOrEmpty()) {
listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED)
FUTON_DEFAULT_NAV_ITEMS
} else {
raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) }
}
Expand All @@ -81,6 +81,30 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}

/** Returns true once the one-time Futon nav default migration has been applied. */
val isFutonNavDefaultApplied: Boolean
get() = prefs.getBoolean(KEY_NAV_FUTON_DEFAULT_APPLIED, false)

/**
* Migrates users who still have the old Kotatsu 4-item default to the new Futon 5-item
* default (History, Favourites, Local, Explore, Settings) and marks the migration as done
* so the tutorial tip is shown exactly once. No-op on subsequent calls.
*/
fun applyFutonNavDefaultIfNeeded() {
if (isFutonNavDefaultApplied) return
val raw = prefs.getString(KEY_NAV_MAIN, null)
// Migrate only when the pref is absent or still the old Kotatsu default ordering.
val isOldDefault = raw == null ||
raw == "HISTORY,FAVORITES,EXPLORE,FEED" ||
raw == "HISTORY,FAVORITES,FEED,EXPLORE"
prefs.edit {
if (isOldDefault) {
putString(KEY_NAV_MAIN, FUTON_DEFAULT_NAV_ITEMS.joinToString(",") { it.name })
}
putBoolean(KEY_NAV_FUTON_DEFAULT_APPLIED, true)
}
}

val isNavLabelsVisible: Boolean
get() = prefs.getBoolean(KEY_NAV_LABELS, false)

Expand Down Expand Up @@ -839,6 +863,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_NAV_PINNED = "nav_pinned"
const val KEY_MAIN_FAB = "main_fab"
/** Marks that the one-time migration to Futon default nav items has been applied. */
const val KEY_NAV_FUTON_DEFAULT_APPLIED = "nav_futon_default_applied"
/** Tip key for the "new nav bar defaults" one-time tutorial banner. */
const val TIP_NEW_NAV_DEFAULT = "tip_new_nav_default"

/** The 5-item default introduced by Futon: History, Favourites, Local, Explore, Settings. */
val FUTON_DEFAULT_NAV_ITEMS: List<NavItem> = listOf(
NavItem.HISTORY,
NavItem.FAVORITES,
NavItem.LOCAL,
NavItem.EXPLORE,
NavItem.SETTINGS,
)
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,80 @@ import io.github.landwarderer.futon.R
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,
/** Selector drawable used by the legacy NavigationBarView / NavigationRailView. */
@DrawableRes val icon: Int,
/** Plain vector shown in Compose when this item IS selected. */
@DrawableRes val iconSelected: Int,
/** Plain vector shown in Compose when this item is NOT selected. */
@DrawableRes val iconUnselected: Int,
/** When true, tapping this item opens an Activity instead of swapping a Fragment. */
val isActivityLauncher: Boolean = false,
) {

HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector),
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
UPDATED(R.id.nav_updated, R.string.updated, R.drawable.ic_updated_selector),
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
HISTORY(
id = R.id.nav_history,
title = R.string.history,
icon = R.drawable.ic_history_selector,
iconSelected = R.drawable.ic_history,
iconUnselected = R.drawable.ic_history,
),
FAVORITES(
id = R.id.nav_favorites,
title = R.string.favourites,
icon = R.drawable.ic_favourites_selector,
iconSelected = R.drawable.ic_heart,
iconUnselected = R.drawable.ic_heart_outline,
),
LOCAL(
id = R.id.nav_local,
title = R.string.on_device,
icon = R.drawable.ic_storage_selector,
iconSelected = R.drawable.ic_storage_checked,
iconUnselected = R.drawable.ic_storage,
),
EXPLORE(
id = R.id.nav_explore,
title = R.string.explore,
icon = R.drawable.ic_explore_selector,
iconSelected = R.drawable.ic_explore_checked,
iconUnselected = R.drawable.ic_explore_normal,
),
SUGGESTIONS(
id = R.id.nav_suggestions,
title = R.string.suggestions,
icon = R.drawable.ic_suggestion_selector,
iconSelected = R.drawable.ic_suggestion_checked,
iconUnselected = R.drawable.ic_suggestion,
),
FEED(
id = R.id.nav_feed,
title = R.string.feed,
icon = R.drawable.ic_feed_selector,
iconSelected = R.drawable.ic_feed,
iconUnselected = R.drawable.ic_feed,
),
UPDATED(
id = R.id.nav_updated,
title = R.string.updated,
icon = R.drawable.ic_updated_selector,
iconSelected = R.drawable.ic_updated_checked,
iconUnselected = R.drawable.ic_updated,
),
BOOKMARKS(
id = R.id.nav_bookmarks,
title = R.string.bookmarks,
icon = R.drawable.ic_bookmark_selector,
iconSelected = R.drawable.ic_bookmark_checked,
iconUnselected = R.drawable.ic_bookmark,
),
SETTINGS(
id = R.id.nav_settings,
title = R.string.settings,
icon = R.drawable.ic_settings_filled,
iconSelected = R.drawable.ic_settings_filled,
iconUnselected = R.drawable.ic_settings,
isActivityLauncher = true,
),
;

fun isAvailable(settings: AppSettings): Boolean = when (this) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.github.landwarderer.futon.core.ui.components

import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.github.landwarderer.futon.core.util.ext.isNightMode

/**
* Minimal Compose theme wrapper for View-Compose interop.
*
* Uses Material3 dynamic colors on Android 12+ to match the system/app palette,
* and falls back to the default Material3 color scheme on older devices.
* Typography and shapes are left at Material3 defaults.
*/
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val isDark = remember(context) { context.resources.isNightMode }

val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} else {
if (isDark) androidx.compose.material3.darkColorScheme()
else androidx.compose.material3.lightColorScheme()
}

MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package io.github.landwarderer.futon.core.ui.components

import androidx.annotation.IdRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.github.landwarderer.futon.R
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.prefs.NavItem

/**
* Compose bottom navigation bar.
*
* @param navItems Ordered list of items to display (from [AppSettings.mainNavItems]).
* @param selectedItemId Resource id of the currently selected item, or 0 if none.
* @param badgeCounts Map from item resource id to badge count.
* Positive = number badge, negative = dot-only badge, 0/absent = no badge.
* @param showTip Whether to show the one-time "new defaults" tutorial tip.
* @param onItemClick Called when the user taps an item. Receives the [NavItem].
* @param onTipDismiss Called when the user dismisses the tutorial tip.
*/
@Composable
fun BottomNavBar(
navItems: List<NavItem>,
@IdRes selectedItemId: Int,
badgeCounts: Map<Int, Int> = emptyMap(),
showTip: Boolean = false,
onItemClick: (NavItem) -> Unit = {},
onTipDismiss: () -> Unit = {},
) {
Column {
AnimatedVisibility(
visible = showTip,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
) {
NavBarTutorialTip(onDismiss = onTipDismiss)
}

NavigationBar {
navItems.forEach { item ->
val count = badgeCounts[item.id] ?: 0
val selected = item.id == selectedItemId
NavigationBarItem(
selected = selected,
onClick = { onItemClick(item) },
icon = {
val iconRes = if (selected) item.iconSelected else item.iconUnselected
BadgedBox(
badge = {
when {
count > 0 -> Badge { Text(count.toString()) }
count < 0 -> Badge()
else -> Unit
}
},
) {
Icon(
painter = painterResource(iconRes),
contentDescription = stringResource(item.title),
)
}
},
label = { Text(stringResource(item.title)) },
)
}
}
}
}

/**
* One-time tutorial tip shown below the top of the nav bar area, informing the user that
* Futon has a new set of default navigation items. Dismissed permanently on tap.
*/
@Composable
private fun NavBarTutorialTip(onDismiss: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.tip_new_nav_default),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(stringResource(R.string.got_it))
}
}
}
}

@Preview(showBackground = true)
@Composable
private fun PreviewBottomNavBar() {
BottomNavBar(
navItems = AppSettings.FUTON_DEFAULT_NAV_ITEMS,
selectedItemId = AppSettings.FUTON_DEFAULT_NAV_ITEMS.first().id,
badgeCounts = mapOf(AppSettings.FUTON_DEFAULT_NAV_ITEMS[2].id to 3),
showTip = false,
)
}

@Preview(showBackground = true)
@Composable
private fun PreviewBottomNavBarWithTip() {
BottomNavBar(
navItems = AppSettings.FUTON_DEFAULT_NAV_ITEMS,
selectedItemId = AppSettings.FUTON_DEFAULT_NAV_ITEMS.first().id,
showTip = true,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.github.landwarderer.futon.core.ui.components

import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.prefs.NavItem

/**
* Compose-observable state for [BottomNavBar].
*
* MainActivity creates one instance and drives it; the ComposeView reads from it.
* All fields are Compose state so the bar recomposes only when something actually changes.
*/
@Stable
class BottomNavBarState(
settings: AppSettings,
initialItems: List<NavItem> = settings.mainNavItems,
initialSelectedId: Int = initialItems.firstOrNull()?.id ?: 0,
showTipInitially: Boolean = settings.isTipEnabled(AppSettings.TIP_NEW_NAV_DEFAULT),
) {
var navItems: List<NavItem> by mutableStateOf(initialItems)
var selectedItemId: Int by mutableIntStateOf(initialSelectedId)
var showTip: Boolean by mutableStateOf(showTipInitially)
var isVisible: Boolean by mutableStateOf(true)

/** Per-item badge counts. Positive = number, negative = dot-only, absent/0 = none. */
val badgeCounts: MutableMap<Int, Int> = mutableStateMapOf()

fun setBadge(itemId: Int, count: Int) {
if (count == 0) {
badgeCounts.remove(itemId)
} else {
badgeCounts[itemId] = count
}
}
}
Loading
Loading