feat: UI 2.0 and other Improvements (#357)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-06-01 10:37:19 +02:00 committed by GitHub
parent 9ca06eaa37
commit e7b5bb40ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 4584 additions and 3626 deletions

View file

@ -29,19 +29,15 @@ class AdaptiveFab {
duration: const Duration(milliseconds: 250),
height: 60,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ElevatedButton(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: FilledButton.tonal(
onPressed: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
child,
const Spacer(),
Flexible(child: Text(title)),
const Spacer(),
],
),
child: Row(
spacing: 24,
children: [
child,
Flexible(child: Text(title)),
],
),
),
),

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/util/fladder_image.dart';
class BackgroundImage extends ConsumerStatefulWidget {
final List<ItemBaseModel> items;
final List<ImagesData> images;
const BackgroundImage({this.items = const [], this.images = const [], super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _BackgroundImageState();
}
class _BackgroundImageState extends ConsumerState<BackgroundImage> {
ImageData? backgroundImage;
@override
void didUpdateWidget(covariant BackgroundImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.items.length != widget.items.length || oldWidget.images.length != widget.images.length) {
updateItems();
}
}
@override
void initState() {
super.initState();
updateItems();
}
void updateItems() {
WidgetsBinding.instance.addPostFrameCallback((value) async {
if (widget.images.isNotEmpty) {
setState(() {
backgroundImage = widget.images.shuffled().firstOrNull?.primary;
});
return;
}
final randomItem = widget.items.shuffled().firstOrNull;
if (widget.items.isEmpty) return;
final itemId = switch (randomItem?.type) {
FladderItemType.folder => randomItem?.id,
FladderItemType.series => randomItem?.parentId ?? randomItem?.id,
_ => randomItem?.id,
} ??
randomItem?.id;
if (itemId == null) return;
final apiProvider = await ref.read(jellyApiProvider).usersUserIdItemsItemIdGet(
itemId: itemId,
);
setState(() {
backgroundImage = apiProvider.body?.parentBaseModel.getPosters?.randomBackDrop ??
apiProvider.body?.getPosters?.randomBackDrop;
});
});
}
@override
Widget build(BuildContext context) {
return FladderImage(
image: backgroundImage,
fit: BoxFit.cover,
blurOnly: false,
);
}
}

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_button.dart';
import 'package:flutter/material.dart';
class DestinationModel {
final String label;
@ -79,12 +81,13 @@ class DestinationModel {
);
}
NavigationButton toNavigationButton(bool selected, bool expanded) {
NavigationButton toNavigationButton(bool selected, bool horizontal, bool expanded) {
return NavigationButton(
label: label,
selected: selected,
onPressed: action,
horizontal: expanded,
horizontal: horizontal,
expanded: expanded,
selectedIcon: selectedIcon!,
icon: icon!,
);

View file

@ -1,5 +1,5 @@
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:flutter/material.dart';

View file

@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fladder/screens/shared/default_title_bar.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
bool get _isDesktop {
if (kIsWeb) return false;

View file

@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:window_manager/window_manager.dart';
import 'package:fladder/models/media_playback_model.dart';
@ -11,14 +11,15 @@ import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/video_player/video_player.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
const videoPlayerHeroTag = "HeroPlayer";
const floatingPlayerHeight = 70.0;
class FloatingPlayerBar extends ConsumerStatefulWidget {
const FloatingPlayerBar({super.key});
@ -71,29 +72,29 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
},
direction: DismissDirection.vertical,
child: InkWell(
onLongPress: () {
fladderSnackbar(context, title: "Swipe up/down to open/close the player");
},
onLongPress: () => fladderSnackbar(context, title: "Swipe up/down to open/close the player"),
child: Card(
elevation: 5,
color: Theme.of(context).colorScheme.primaryContainer,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 50, maxHeight: 85),
child: SizedBox(
height: floatingPlayerHeight,
child: LayoutBuilder(builder: (context, constraints) {
return Row(
children: [
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
if (playbackInfo.state == VideoPlayerState.minimized)
Card(
child: SizedBox(
child: Padding(
padding: MediaQuery.paddingOf(context).copyWith(top: 0, bottom: 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
spacing: 7,
children: [
if (playbackInfo.state == VideoPlayerState.minimized)
Card(
child: AspectRatio(
aspectRatio: 1.67,
child: MouseRegion(
@ -131,72 +132,76 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
),
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
playbackModel?.title ?? "",
style: Theme.of(context).textTheme.titleLarge,
),
),
if (playbackModel?.detailedName(context)?.isNotEmpty == true)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
playbackModel?.detailedName(context) ?? "",
playbackModel?.title ?? "",
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
),
if (!progress.isNaN && constraints.maxWidth > 500)
Text(
"${playbackInfo.position.readAbleDuration} / ${playbackInfo.duration.readAbleDuration}"),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: IconButton.filledTonal(
onPressed: () => ref.read(videoPlayerProvider).playOrPause(),
icon: playbackInfo.playing
? const Icon(Icons.pause_rounded)
: const Icon(Icons.play_arrow_rounded),
),
),
if (constraints.maxWidth > 500) ...{
IconButton(
onPressed: () {
final volume = player.lastState?.volume == 0 ? 100.0 : 0.0;
player.setVolume(volume);
},
icon: Icon(
ref.watch(videoPlayerSettingsProvider.select((value) => value.volume)) <= 0
? IconsaxPlusBold.volume_cross
: IconsaxPlusBold.volume_high,
if (playbackModel?.detailedName(context)?.isNotEmpty == true)
Flexible(
child: Text(
playbackModel?.detailedName(context) ?? "",
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65),
),
),
),
],
),
),
},
Tooltip(
message: context.localized.stop,
waitDuration: const Duration(milliseconds: 500),
child: IconButton(
onPressed: () async => stopPlayer(),
icon: const Icon(IconsaxPlusBold.stop),
if (!progress.isNaN && constraints.maxWidth > 500)
Text(
"${playbackInfo.position.readAbleDuration} / ${playbackInfo.duration.readAbleDuration}"),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: IconButton.filledTonal(
onPressed: () => ref.read(videoPlayerProvider).playOrPause(),
icon: playbackInfo.playing
? const Icon(Icons.pause_rounded)
: const Icon(Icons.play_arrow_rounded),
),
),
),
].addInBetween(const SizedBox(width: 6)),
if (constraints.maxWidth > 500) ...[
IconButton(
onPressed: () {
final volume = player.lastState?.volume == 0 ? 100.0 : 0.0;
player.setVolume(volume);
},
icon: Icon(
ref.watch(videoPlayerSettingsProvider.select((value) => value.volume)) <= 0
? IconsaxPlusBold.volume_cross
: IconsaxPlusBold.volume_high,
),
),
Tooltip(
message: context.localized.stop,
waitDuration: const Duration(milliseconds: 500),
child: IconButton(
onPressed: () async => stopPlayer(),
icon: const Icon(IconsaxPlusBold.stop),
),
),
],
],
),
),
),
),
LinearProgressIndicator(
minHeight: 6,
backgroundColor: Colors.black.withValues(alpha: 0.25),
color: Theme.of(context).colorScheme.primary,
value: progress.clamp(0, 1),
),
],
LinearProgressIndicator(
minHeight: 6,
backgroundColor: Colors.black.withValues(alpha: 0.25),
color: Theme.of(context).colorScheme.primary,
value: progress.clamp(0, 1),
),
],
),
),
),
],

View file

@ -2,21 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_drawer.dart';
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart';
class NavigationBody extends ConsumerStatefulWidget {
final BuildContext parentContext;
@ -40,7 +33,7 @@ class NavigationBody extends ConsumerStatefulWidget {
}
class _NavigationBodyState extends ConsumerState<NavigationBody> {
bool expandedSideBar = true;
double currentSideBarWidth = 80;
@override
void initState() {
@ -52,9 +45,9 @@ class _NavigationBodyState extends ConsumerState<NavigationBody> {
@override
Widget build(BuildContext context) {
final views = ref.watch(viewsProvider.select((value) => value.views));
final hasOverlay = AdaptiveLayout.layoutModeOf(context) == LayoutMode.dual ||
homeRoutes.any((element) => element.name.contains(context.router.current.name));
ref.listen(
clientSettingsProvider,
(previous, next) {
@ -66,56 +59,28 @@ class _NavigationBodyState extends ConsumerState<NavigationBody> {
},
);
return switch (AdaptiveLayout.layoutOf(context)) {
ViewSize.phone => MediaQuery.removePadding(
context: widget.parentContext,
Widget paddedChild() => MediaQuery(
data: semiNestedPadding(widget.parentContext, hasOverlay),
child: widget.child,
),
ViewSize.tablet => Row(
children: [
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: hasOverlay ? navigationRail(context) : const SizedBox(),
),
Flexible(
child: MediaQuery(
data: semiNestedPadding(context, hasOverlay),
child: widget.child,
),
);
return switch (AdaptiveLayout.layoutOf(context)) {
ViewSize.phone => paddedChild(),
ViewSize.tablet => hasOverlay
? SideNavigationBar(
currentIndex: widget.currentIndex,
destinations: widget.destinations,
currentLocation: widget.currentLocation,
child: paddedChild(),
scaffoldKey: widget.drawerKey,
)
],
),
ViewSize.desktop => Row(
children: [
AnimatedFadeSize(
duration: const Duration(milliseconds: 125),
child: hasOverlay
? expandedSideBar
? MediaQuery.removePadding(
context: widget.parentContext,
child: NestedNavigationDrawer(
isExpanded: expandedSideBar,
actionButton: actionButton(),
toggleExpanded: (value) {
setState(() {
expandedSideBar = value;
});
},
views: views,
destinations: widget.destinations,
currentLocation: widget.currentLocation,
),
)
: navigationRail(context)
: const SizedBox(),
),
Flexible(
child: MediaQuery(
data: semiNestedPadding(context, hasOverlay),
child: widget.child,
),
),
],
: paddedChild(),
ViewSize.desktop => SideNavigationBar(
currentIndex: widget.currentIndex,
destinations: widget.destinations,
currentLocation: widget.currentLocation,
child: paddedChild(),
scaffoldKey: widget.drawerKey,
)
};
}
@ -126,89 +91,4 @@ class _NavigationBodyState extends ConsumerState<NavigationBody> {
padding: paddingOf.copyWith(left: hasOverlay ? 0 : paddingOf.left),
);
}
AdaptiveFab? actionButton() {
return (widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length)
? widget.destinations[widget.currentIndex].floatingActionButton
: null;
}
Widget navigationRail(BuildContext context) {
return Column(
children: [
if (AdaptiveLayout.of(context).isDesktop && AdaptiveLayout.of(context).platform != TargetPlatform.macOS) ...{
const SizedBox(height: 4),
Text(
"Fladder",
style: Theme.of(context).textTheme.titleSmall,
),
},
if (AdaptiveLayout.of(context).platform == TargetPlatform.macOS)
SizedBox(height: MediaQuery.of(context).padding.top),
Flexible(
child: Padding(
key: const Key('navigation_rail'),
padding:
MediaQuery.paddingOf(context).copyWith(right: 0, top: AdaptiveLayout.of(context).isDesktop ? 8 : null),
child: Column(
children: [
IconButton(
onPressed: () {
if (AdaptiveLayout.layoutOf(context) != ViewSize.desktop) {
widget.drawerKey.currentState?.openDrawer();
} else {
setState(() {
expandedSideBar = true;
});
}
},
icon: const Icon(IconsaxPlusBold.menu),
),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.dual) ...[
const SizedBox(height: 8),
AnimatedFadeSize(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: actionButton()?.normal,
),
),
],
const Spacer(),
IconTheme(
data: const IconThemeData(size: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...widget.destinations.mapIndexed(
(index, destination) => destination.toNavigationButton(widget.currentIndex == index, false),
)
],
),
),
const Spacer(),
SizedBox(
height: 48,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: widget.currentLocation.contains(const SettingsRoute().routeName)
? Card(
color: Theme.of(context).colorScheme.primaryContainer,
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(IconsaxPlusBold.setting_3),
),
)
: const SettingsUserIcon()),
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) const SizedBox(height: 16),
],
),
),
),
],
);
}
}

View file

@ -2,14 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/util/widget_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
class NavigationButton extends ConsumerStatefulWidget {
final String? label;
final Widget selectedIcon;
final Widget icon;
final bool horizontal;
final bool expanded;
final Function()? onPressed;
final Function()? onLongPress;
final List<ItemAction> trailing;
final bool selected;
final Duration duration;
const NavigationButton({
@ -17,8 +21,11 @@ class NavigationButton extends ConsumerStatefulWidget {
required this.selectedIcon,
required this.icon,
this.horizontal = false,
this.expanded = false,
this.onPressed,
this.onLongPress,
this.selected = false,
this.trailing = const [],
this.duration = const Duration(milliseconds: 125),
super.key,
});
@ -28,106 +35,119 @@ class NavigationButton extends ConsumerStatefulWidget {
}
class _NavigationButtonState extends ConsumerState<NavigationButton> {
bool showPopupButton = false;
@override
Widget build(BuildContext context) {
return Tooltip(
waitDuration: const Duration(seconds: 1),
preferBelow: false,
triggerMode: TooltipTriggerMode.longPress,
message: widget.label ?? "",
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
final foreGroundColor = widget.selected
? widget.expanded
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: ElevatedButton(
onHover: (value) => setState(() => showPopupButton = value),
style: ButtonStyle(
elevation: const WidgetStatePropertyAll(0),
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
backgroundColor: WidgetStatePropertyAll(
widget.expanded && widget.selected ? Theme.of(context).colorScheme.primary : Colors.transparent,
),
iconSize: const WidgetStatePropertyAll(24),
iconColor: WidgetStateProperty.resolveWith((states) {
return foreGroundColor;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
return foreGroundColor;
})),
onPressed: widget.onPressed,
onLongPress: widget.onLongPress,
child: widget.horizontal
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: getChildren(context),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: getChildren(context),
),
),
);
}
List<Widget> getChildren(BuildContext context) {
return [
Flexible(
child: ElevatedButton(
style: ButtonStyle(
elevation: const WidgetStatePropertyAll(0),
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
backgroundColor: const WidgetStatePropertyAll(Colors.transparent),
iconSize: const WidgetStatePropertyAll(24),
iconColor: WidgetStateProperty.resolveWith((states) {
return widget.selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45);
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
return widget.selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45);
})),
onPressed: widget.onPressed,
child: AnimatedContainer(
curve: Curves.fastOutSlowIn,
duration: widget.duration,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
? Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: SizedBox(
height: 35,
child: Row(
spacing: 4,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 250),
height: widget.selected ? 16 : 0,
margin: const EdgeInsets.only(top: 1.5),
width: 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: widget.selected && !widget.expanded ? 1 : 0),
),
),
AnimatedSwitcher(
duration: widget.duration,
child: widget.selected
? widget.selectedIcon.setKey(Key("${widget.label}+selected"))
: widget.icon.setKey(Key("${widget.label}+normal")),
child: widget.selected ? widget.selectedIcon : widget.icon,
),
if (widget.horizontal && widget.label != null)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: _Label(widget: widget),
)
const SizedBox(width: 6),
if (widget.horizontal && widget.expanded) ...[
if (widget.label != null)
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 80),
child: Text(
widget.label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
if (widget.trailing.isNotEmpty)
AnimatedOpacity(
duration: const Duration(milliseconds: 125),
opacity: showPopupButton ? 1 : 0,
child: PopupMenuButton(
tooltip: context.localized.options,
iconColor: foreGroundColor,
iconSize: 18,
itemBuilder: (context) => widget.trailing.popupMenuItems(useIcons: true),
),
)
],
],
),
AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: EdgeInsets.only(top: widget.selected ? 8 : 0),
height: widget.selected ? 6 : 0,
width: widget.selected ? 14 : 0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.primary.withValues(alpha: widget.selected ? 1 : 0),
),
)
: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
spacing: 8,
children: [
AnimatedSwitcher(
duration: widget.duration,
child: widget.selected ? widget.selectedIcon : widget.icon,
),
if (widget.label != null && widget.horizontal && widget.expanded)
Flexible(child: Text(widget.label!))
],
),
),
],
AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: EdgeInsets.only(top: widget.selected ? 4 : 0),
height: widget.selected ? 6 : 0,
width: widget.selected ? 14 : 0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.primary.withValues(alpha: widget.selected ? 1 : 0),
),
),
],
),
),
),
),
),
),
];
}
}
class _Label extends StatelessWidget {
const _Label({required this.widget});
final NavigationButton widget;
@override
Widget build(BuildContext context) {
return Text(
widget.label!,
maxLines: 1,
overflow: TextOverflow.fade,
style:
Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer),
);
}
}

View file

@ -6,12 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/collection_types.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/models/view_model.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/metadata/refresh_metadata.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
@ -54,7 +53,7 @@ class NestedNavigationDrawer extends ConsumerWidget {
),
IconButton(
onPressed: () => toggleExpanded(false),
icon: const Icon(IconsaxPlusLinear.menu_1),
icon: const Icon(IconsaxPlusLinear.sidebar_left),
),
],
),
@ -71,16 +70,18 @@ class NestedNavigationDrawer extends ConsumerWidget {
),
),
),
...destinations.map((destination) => DrawerListButton(
label: destination.label,
selected: context.router.current.name == destination.route?.routeName,
selectedIcon: destination.selectedIcon!,
icon: destination.icon!,
onPressed: () {
destination.action!();
Scaffold.of(context).closeDrawer();
},
)),
...destinations.map(
(destination) => DrawerListButton(
label: destination.label,
selected: context.router.current.name == destination.route?.routeName,
selectedIcon: destination.selectedIcon!,
icon: destination.icon!,
onPressed: () {
destination.action!();
Scaffold.of(context).closeDrawer();
},
),
),
if (views.isNotEmpty) ...{
const Divider(indent: 28, endIndent: 28),
Padding(

View file

@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/user_icon.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
class SettingsUserIcon extends ConsumerWidget {
@ -15,13 +15,11 @@ class SettingsUserIcon extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final users = ref.watch(userProvider);
final user = ref.watch(userProvider);
return Tooltip(
message: context.localized.settings,
waitDuration: const Duration(seconds: 1),
child: UserIcon(
user: users,
cornerRadius: 200,
child: FlatButton(
onLongPress: () => context.router.push(const LockRoute()),
onTap: () {
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) {
@ -30,6 +28,10 @@ class SettingsUserIcon extends ConsumerWidget {
context.router.push(const ClientSettingsRoute());
}
},
child: UserIcon(
user: user,
cornerRadius: 200,
),
),
);
}

View file

@ -0,0 +1,258 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:overflow_view/overflow_view.dart';
import 'package:fladder/models/collection_types.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/metadata/refresh_metadata.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_button.dart';
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
class SideNavigationBar extends ConsumerStatefulWidget {
final int currentIndex;
final List<DestinationModel> destinations;
final String currentLocation;
final Widget child;
final GlobalKey<ScaffoldState> scaffoldKey;
const SideNavigationBar({
required this.currentIndex,
required this.destinations,
required this.currentLocation,
required this.child,
required this.scaffoldKey,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SideNavigationBarState();
}
class _SideNavigationBarState extends ConsumerState<SideNavigationBar> {
bool expandedSideBar = false;
bool showOnHover = false;
Timer? timer;
double currentWidth = 80;
void startTimer() {
timer?.cancel();
timer = Timer(const Duration(milliseconds: 650), () {
setState(() {
showOnHover = true;
});
});
}
void stopTimer() {
timer?.cancel();
timer = Timer(const Duration(milliseconds: 350), () {
setState(() {
showOnHover = false;
});
});
}
@override
Widget build(BuildContext context) {
final views = ref.watch(viewsProvider.select((value) => value.views));
final expandedWidth = 250.0;
final padding = MediaQuery.paddingOf(context);
final collapsedWidth = 90.0 + padding.left;
final largeBar = AdaptiveLayout.layoutModeOf(context) != LayoutMode.single;
final fullyExpanded = largeBar ? expandedSideBar : false;
final shouldExpand = showOnHover || fullyExpanded;
final isDesktop = AdaptiveLayout.of(context).isDesktop;
return Stack(
children: [
AdaptiveLayoutBuilder(
adaptiveLayout: AdaptiveLayout.of(context).copyWith(
sideBarWidth: fullyExpanded ? expandedWidth : collapsedWidth,
),
child: (context) => widget.child,
),
AnimatedFadeSize(
alignment: Alignment.topLeft,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85),
width: shouldExpand ? expandedWidth : collapsedWidth,
child: MouseRegion(
onEnter: (value) => startTimer(),
onExit: (event) => stopTimer(),
child: Column(
children: [
if (isDesktop && AdaptiveLayout.of(context).platform != TargetPlatform.macOS) ...{
const SizedBox(height: 4),
Text(
"Fladder",
style: Theme.of(context).textTheme.titleSmall,
),
},
if (AdaptiveLayout.of(context).platform == TargetPlatform.macOS) SizedBox(height: padding.top),
Expanded(
child: Padding(
key: const Key('navigation_rail'),
padding: padding.copyWith(right: 0, top: isDesktop ? 8 : null),
child: Column(
spacing: 2,
children: [
Align(
alignment: largeBar && expandedSideBar ? Alignment.centerRight : Alignment.center,
child: Opacity(
opacity: largeBar && expandedSideBar ? 0.65 : 1.0,
child: IconButton(
onPressed: !largeBar
? () => widget.scaffoldKey.currentState?.openDrawer()
: () => setState(() {
expandedSideBar = !expandedSideBar;
if (!expandedSideBar) {
showOnHover = false;
}
}),
icon: Icon(
largeBar && expandedSideBar ? IconsaxPlusLinear.sidebar_left : IconsaxPlusLinear.menu,
),
),
),
),
const SizedBox(height: 8),
if (largeBar) ...[
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: shouldExpand ? actionButton(context).extended : actionButton(context).normal,
),
],
Expanded(
child: Column(
spacing: 2,
mainAxisAlignment: !largeBar ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
...widget.destinations.mapIndexed(
(index, destination) =>
destination.toNavigationButton(widget.currentIndex == index, true, shouldExpand),
),
if (views.isNotEmpty && largeBar) ...[
const Divider(
indent: 32,
endIndent: 32,
),
Flexible(
child: OverflowView.flexible(
direction: Axis.vertical,
spacing: 4,
children: views.map(
(view) {
final actions = [
ItemActionButton(
label: Text(context.localized.scanLibrary),
icon: const Icon(IconsaxPlusLinear.refresh),
action: () => showRefreshPopup(context, view.id, view.name),
)
];
return view.toNavigationButton(
context.router.currentUrl.contains(view.id),
true,
shouldExpand,
() => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
onLongPress: () => showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: actions.listTileItems(context, useIcons: true),
),
),
trailing: actions,
);
},
).toList(),
builder: (context, remaining) {
return PopupMenuButton(
iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45),
padding: EdgeInsets.zero,
icon: NavigationButton(
label: context.localized.other,
selectedIcon: const Icon(IconsaxPlusLinear.arrow_square_down),
icon: const Icon(IconsaxPlusLinear.arrow_square_down),
expanded: shouldExpand,
horizontal: true,
),
itemBuilder: (context) => views
.sublist(views.length - remaining)
.map(
(e) => PopupMenuItem(
onTap: () => context.pushRoute(LibrarySearchRoute(viewModelId: e.id)),
child: Row(
spacing: 8,
children: [
Icon(e.collectionType.iconOutlined),
Text(e.name),
],
),
),
)
.toList(),
);
},
),
),
],
],
),
),
NavigationButton(
label: context.localized.settings,
selected: widget.currentLocation.contains(const SettingsRoute().routeName),
selectedIcon: const Icon(IconsaxPlusBold.setting_3),
horizontal: true,
expanded: shouldExpand,
icon: const SizedBox(height: 32, child: SettingsUserIcon()),
onPressed: () {
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) {
context.router.push(const SettingsRoute());
} else {
context.router.push(const ClientSettingsRoute());
}
},
),
],
),
),
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) const SizedBox(height: 16),
],
),
),
),
),
],
);
}
AdaptiveFab actionButton(BuildContext context) {
return ((widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length)
? widget.destinations[widget.currentIndex].floatingActionButton
: null) ??
AdaptiveFab(
context: context,
title: context.localized.search,
key: const Key("Search"),
onPressed: () => context.router.navigate(LibrarySearchRoute()),
child: const Icon(IconsaxPlusLinear.search_normal_1),
);
}
}