refactor(android): extract MinstrelTopAppBar (DRY 3b)

Ten screens duplicated the same TopAppBar + Text(title) +
optional-back-arrow + MainAppBarActions sandwich. Extract into
MinstrelTopAppBar(title, navController, currentRouteName, onBack,
actions) at shared/widgets/. Library's tab row still composes
above it inside a Column; Library's Shuffle button slots into the
extra actions trailing-lambda; drill-down screens pass onBack for
the back arrow; root tabs leave onBack null.

Net: -48 lines across 10 screens, +50 lines for the helper.

SearchScreen keeps its own TopAppBar (title is a SearchField
composable, not a string).

Per-screen TrackActionsViewModel snackbar wiring (Library/Search/
detail screens) was intentionally NOT consolidated here: each
hiltViewModel() returns a different VM instance than the shell-
scoped one already in ShellScaffold, so dedup belongs in 3c
(shell-scope the VM via CompositionLocal) rather than freezing the
current duplicate into a helper.
This commit is contained in:
2026-05-30 16:00:21 -04:00
parent 2b17a4ed69
commit 02a6c45958
11 changed files with 106 additions and 154 deletions
@@ -11,13 +11,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -29,7 +26,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.TriangleAlert
@@ -44,7 +40,7 @@ import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -104,7 +100,6 @@ class AdminLandingViewModel @Inject constructor(
// ─── Screen ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminLandingScreen(
navController: NavHostController,
@@ -114,19 +109,11 @@ fun AdminLandingScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Admin") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Admin::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Admin",
navController = navController,
currentRouteName = Admin::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
@@ -12,14 +12,11 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -29,13 +26,11 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@@ -48,19 +43,11 @@ fun AdminQuarantineScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Admin · Quarantine") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = AdminQuarantine::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Admin · Quarantine",
navController = navController,
currentRouteName = AdminQuarantine::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
@@ -12,13 +12,10 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -28,13 +25,11 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@@ -47,19 +42,11 @@ fun AdminRequestsScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Admin · Requests") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = AdminRequests::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Admin · Requests",
navController = navController,
currentRouteName = AdminRequests::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
@@ -14,12 +14,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Copy
import com.composables.icons.lucide.KeyRound
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Trash2
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -30,7 +28,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import android.content.ClipData
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
@@ -52,11 +49,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminUsersScreen(
navController: NavHostController,
@@ -117,7 +113,6 @@ fun AdminUsersScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AdminUsersScaffold(
navController: NavHostController,
@@ -134,19 +129,11 @@ private fun AdminUsersScaffold(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Admin · Users") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = AdminUsers::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Admin · Users",
navController = navController,
currentRouteName = AdminUsers::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
@@ -23,7 +23,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -35,7 +34,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search as LucideSearch
import com.fabledsword.minstrel.discover.data.RequestOutcome
@@ -44,7 +42,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@@ -63,19 +61,11 @@ fun DiscoverScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Discover") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Discover::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Discover",
navController = navController,
currentRouteName = Discover::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
snackbarHost = { SnackbarHost(snackbar) },
@@ -21,14 +21,12 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -70,7 +68,7 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
@@ -236,7 +234,6 @@ class HomeViewModel @Inject constructor(
// ─── Screen ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
navController: NavHostController,
@@ -249,14 +246,10 @@ fun HomeScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Minstrel") },
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Home::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Minstrel",
navController = navController,
currentRouteName = Home::class.qualifiedName,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -22,7 +22,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -49,7 +48,7 @@ import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
@@ -88,8 +87,10 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(),
topBar = {
Column {
TopAppBar(
title = { Text("Library") },
MinstrelTopAppBar(
title = "Library",
navController = navController,
currentRouteName = Library::class.qualifiedName,
actions = {
IconButton(onClick = viewModel::shuffleAll) {
Icon(
@@ -98,10 +99,6 @@ fun LibraryScreen(
tint = MaterialTheme.colorScheme.onSurface,
)
}
MainAppBarActions(
navController = navController,
currentRouteName = Library::class.qualifiedName,
)
},
)
PrimaryScrollableTabRow(
@@ -9,11 +9,9 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -33,7 +31,7 @@ import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -95,7 +93,6 @@ class PlaylistsListViewModel @Inject constructor(
// ─── Screen ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistsListScreen(
navController: NavHostController,
@@ -104,14 +101,10 @@ fun PlaylistsListScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Playlists") },
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Playlists::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Playlists",
navController = navController,
currentRouteName = Playlists::class.qualifiedName,
)
},
) { inner ->
@@ -14,16 +14,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -36,7 +33,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
@@ -51,10 +47,9 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequestsScreen(
navController: NavHostController,
@@ -64,19 +59,11 @@ fun RequestsScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Your requests") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Requests::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Your requests",
navController = navController,
currentRouteName = Requests::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
@@ -30,7 +30,6 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -55,11 +54,10 @@ import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navController: NavHostController,
@@ -82,14 +80,10 @@ fun SettingsScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Settings") },
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = SettingsRoute::class.qualifiedName,
)
},
MinstrelTopAppBar(
title = "Settings",
navController = navController,
currentRouteName = SettingsRoute::class.qualifiedName,
)
},
) { inner ->
@@ -0,0 +1,50 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
/**
* The standard Minstrel top app bar — a `Text(title)` on the left, the
* universal [MainAppBarActions] row on the right, optionally preceded
* by extra screen-specific actions and a back arrow.
*
* Replaces the TopAppBar + title + navigationIcon + MainAppBarActions
* sandwich that was duplicated across ~10 screens. Library wraps this
* in a `Column` with its tab row; drill-down screens pass [onBack]
* for the back arrow; Library passes its Shuffle button via [actions].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MinstrelTopAppBar(
title: String,
navController: NavHostController,
currentRouteName: String?,
onBack: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
}
},
actions = {
actions()
MainAppBarActions(
navController = navController,
currentRouteName = currentRouteName,
)
},
)
}